reactjstypescriptreact-hook-form

Re-render form if array changes with React-hook-form


I have a form for adding a cocktail recipe. I am using React-Hook-Form to build it. One of the sections is list of ingredients. For a start there should be only a single row. If the "Add ingredient" button is clicked, another row should show.

I can get it to add a row, but I cannot get it to re-render, when that happens. I am aware that I could probably do it with useState, but I cannot figure out how to use that with React-Hook-Forms, if that's even possible.

Here is how it looks in the initial render:

Ingredients at initial render.

And after clicking the "Tilføj ingediens" (add ingredient), it should look like this:

Desired outcome after cliking "Add ingredient".

However, this doesn't happen. From using console.log it appears that the ingredients array is in fact updated. It just doesn't re-render the form. Does anyone know how to do that, without abusing React (like triggering a re-render by changing some hidden component, etc.)? I have tried looking into the watch feature of React-Hook-Forms. It feels like a step in the right direction, but I cannot figure out how exactly it can be done.

Code

import FileInput from "../components/FileInput";
import Input from "../components/Input";
import Label from "../components/Label";
import TextArea from "../components/TextArea";
import { useForm, SubmitHandler } from "react-hook-form";

interface Cocktail {
    name: string;
    description: string;
    imageUrl: string;
    ingredients: Ingredient[];
}

interface Ingredient {
    name: string;
    quantity: string;
    unit: string;
}

const CreateCocktail = () => { 
    const {register, getValues, handleSubmit} = useForm<Cocktail>({ 
        defaultValues: { 
            name: '',
            description: '',
            imageUrl: '',
            ingredients: [
                {name: '', quantity: '', unit: ''}, {name: '', quantity: '', unit: ''}
            ]
        } 
    });

    const onAddIngredient = () => {
        // This part should somehow trigger a re-render of the form.
        console.log("Adding ingredient");
        getValues().ingredients.push({name: '', quantity: '', unit: ''});
    }
    
    const onSubmit: SubmitHandler<Cocktail> = async data => {
        try {
            console.log("Submitting cocktail", data);

            data.description = "Test description";
            data.imageUrl = "Test image url";

            const response = await fetch('/api/cocktails', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data),
            });

            if (response.ok) {
                console.log('Cocktail created');
            } else {
                console.log('Error creating cocktail');
            }
        } catch (error) {
            console.log(error);
        }
    };
    
    return (
        <>
            <div>
                <form onSubmit={handleSubmit(onSubmit)} >  
                        // Rest of the form isn't relevant for this example                      
                        <Label htmlFor="ingredients" text="Ingredienser" />                            
                        <div>
                            {getValues().ingredients.map(() => (
                            <div key={Math.random()}>
                                <input
                                    type="text"
                                    placeholder="Lys rom" />
                                
                                <input 
                                    type="text" 
                                    placeholder="60" />

                                <select>
                                    <option value="ml">ml</option>
                                    <option value="dashes">stænk</option>
                                    <option value="stk">stk</option>    
                                </select>                 
                            </div>
                            ))} 
                            <div>
                                <button 
                                    type="button"
                                    onClick={onAddIngredient}>
                                    Tilføj ingrediens
                                </button>
                            </div>
                        </div> 
                                            
                    </div>
                    <div>
                        <input type="submit" 
                            value="Gem" />
                    </div>
                </form>
            </div>
        </>
    );
}

export default CreateCocktail;

Solution

  • Problem

    The code you provided uses getValues incorrectly becaus getValues will only give you the values when it is called but does not update itself or others when they are changed (get is only for fetching current values).

    Instead, we must use setValue to change form values.

    Furthermore, if you want to get real-time/onChange updates from hook-form values you cannot use getValues (this only gets the current value when called but does not update when changed), you must use watch('fieldNameGoesHere') //tracks specific field by name OR watch() //tracks all values for this instead.

    Refer to the code below for a working example of what you are trying to achieve.

    Solution

    import { useForm, SubmitHandler } from "react-hook-form";
    
    interface Cocktail {
        name: string;
        description: string;
        imageUrl: string;
        ingredients: Ingredient[];
    }
    
    interface Ingredient {
        name: string;
        quantity: string;
        unit: string;
    }
    
    export const CreateCocktail = () => { 
        const { getValues, handleSubmit, setValue, watch} = useForm<Cocktail>({ 
            defaultValues: { 
                name: '',
                description: '',
                imageUrl: '',
                ingredients: [
                    {name: '', quantity: '', unit: ''}, {name: '', quantity: '', unit: ''}
                ]
            } 
        });
        //create a watch for ingredients, you could also pass a string array to watch multiple values if needed
        const IngridientsList = watch('ingredients')
        //update item on click via setValue
        const onAddIngredient = () => {
            setValue('ingredients', [...getValues('ingredients'), {name: '', quantity: '', unit: ''}])
        }
        
        const onSubmit: SubmitHandler<Cocktail> = async data => {
            try {
                console.log("Submitting cocktail", data);
    
                data.description = "Test description";
                data.imageUrl = "Test image url";
    
                const response = await fetch('/api/cocktails', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(data),
                });
    
                if (response.ok) {
                    console.log('Cocktail created');
                } else {
                    console.log('Error creating cocktail');
                }
            } catch (error) {
                console.log(error);
            }
        };
        
        return (
            <>
                <div>
                    <form onSubmit={handleSubmit(onSubmit)} >  
                            <div>
                                {IngridientsList.map(() => (
                                <div key={Math.random()}>
                                    <input
                                        type="text"
                                        placeholder="Lys rom" />
                                    
                                    <input 
                                        type="text" 
                                        placeholder="60" />
    
                                    <select>
                                        <option value="ml">ml</option>
                                        <option value="dashes">stænk</option>
                                        <option value="stk">stk</option>    
                                    </select>                 
                                </div>
                                ))} 
                                <div>
                                    <button 
                                        type="button"
                                        onClick={onAddIngredient}>
                                        Tilføj ingrediens
                                    </button>
                                </div>
                            </div> 
                                                
                        
                        <div>
                            <input type="submit" 
                                value="Gem" />
                        </div>
                    </form>
                </div>
            </>
        );
    }
    
    export default CreateCocktail;