reactjsreact-hook-form

Dynamic form in react-hook-form with automatic appending


I am struggling creating a dynamic form with react-hook-form's useFieldArry, that appends a new field/input whenever the last field element gets non-empty (so that the user do not have to care about adding more inputs).

While I have a working solution for simple cases (adding data by typing), it fails in the following case: When resetting that form and filling it programmatically with data (e.g. by clicking a button), it appends two empty inputs in the end. First I thought this is related to reacts Strict Mode, but it happens even in the production build.

enter image description here

Any clue why this happens, a solution (or even best practice) to handle this issue?

Here's a minimal working example:

import { useEffect } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";

interface FormValues {
  items: {
    text: string;
  }[];
}

export default function DynamicForm() {
  const { control, reset } = useForm<FormValues>({
    defaultValues: {
      items: [{ text: "" }],
    },
  });

  const { fields, append } = useFieldArray({
    control,
    name: "items",
  });

  const watchItems = useWatch({
    control,
    name: "items",
  });

  useEffect(() => {
    if (watchItems && watchItems.length > 0) {
      const lastItem = watchItems[watchItems.length - 1];
      if (lastItem?.text && lastItem.text.trim() !== "") {
        append({ text: "" }, { shouldFocus: false });
      }
    }
  }, [watchItems, append]);

  const handleInsertData = () => {
    reset({
      items: [{ text: "X" }],
    });
  };

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
      <button onClick={handleInsertData}>Insert Data</button>
      <h2 className="text-xl font-bold mb-4">Dynamic Form</h2>

      <form>
        <div className="space-y-4">
          {fields.map((field, index) => (
            <div key={field.id} className="flex items-start space-x-2">
              <div className="flex-grow">
                <Controller
                  control={control}
                  name={`items.${index}.text`}
                  render={({ field }) => (
                    <textarea
                      {...field}
                      className="w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                      placeholder="Enter text..."
                    />
                  )}
                />
              </div>
            </div>
          ))}
        </div>
      </form>
    </div>
  );
}

Solution

  • though i'm not 100% sure about the initial problem, i believe it may stem from the blurb in their docs that says:

    useWatch's result is optimised for render phase instead of useEffect's deps, to detect value updates you may want to use an external custom hook for value comparison.

    i could be wrong, but suspecting that, i used "watch" instead and was able to create the behavior you wanted. their proposed solution didn't seem to be practical, at least in this case.

    // retrieved "watch" from useForm hook
    const { control, reset, watch } = useForm<FormValues>({
        defaultValues: {
            items: [{ text: "" }],
        },
    });
    
    useEffect(() => {
        const { unsubscribe } = watch((values) => {
            if (values.items?.length) {
                const lastItem = values.items[values.items.length - 1];
                if (lastItem?.text && lastItem.text.trim() != '') {
                    append({ text: '' }, { shouldFocus: false });
                }
            }
        });
        return () => { unsubscribe(); };
    }, [watch]);
    

    if anyone can confirm the initial cause of the problem, that would be appreciated.