Search code examples
reactjsmaterial-uireact-hook-form

React hook form won't remove items correctly from provider context in a nested component


I have a main component here:

// import { useState } from "react";
import {
  useForm,
  Controller,
  FormProvider,
  SubmitHandler,
} from "react-hook-form";
// import "./App.css";
import { Box, Button, TextField } from "@mui/material";
import { useState } from "react";
import NestedForm from "./NestedForm";

type Inputs = {
  outerInput: string;
};

type InnerInput = {
  innerString1: string;
  innerString2: string;
};

function App() {
  const methods = useForm<Inputs>({
    shouldUnregister: true,
  });

  const [innerInput, setInnerInput] = useState<InnerInput[]>([
    { innerString1: "test1", innerString2: "test2" },
    { innerString1: "test3", innerString2: "test4" },
    { innerString1: "test5", innerString2: "test6" },
    { innerString1: "test7", innerString2: "test8" },
    { innerString1: "test9", innerString2: "test10" },
  ]);

  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data);

  function removeItem(idx: number) {
    setInnerInput((prevInnerInput) =>
      prevInnerInput.filter((_, index) => index !== idx)
    );
  }
  return (
    <>
      <FormProvider {...methods}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>
          <div className="relative mb-5 flex">
            <div className="w-7/12">
              <Controller
                control={methods.control}
                name="outerInput"
                defaultValue=""
                render={({ field, fieldState }) => (
                  <TextField
                    label="Outer Input"
                    error={!!fieldState.error}
                    helperText={fieldState.error?.message}
                    {...field}
                    fullWidth
                    variant="standard"
                    type="text"
                  />
                )}
              />
            </div>

            {innerInput.map((item, idx) => (
              <NestedForm
                key={`inner-${idx}`}
                idx={idx}
                data={item}
                handleRemove={() => removeItem(idx)}
              />
            ))}
          </div>
          <Box width={"20%"} display="flex" justifyContent={"center"}>
            <Button type="submit">Save</Button>
          </Box>
        </form>
      </FormProvider>
    </>
  );
}

export default App;

And a nested form with two text fields:

import { Box, Button, TextField } from "@mui/material";
import { useFormContext } from "react-hook-form";

type InnerInput = {
  innerString1: string;
  innerString2: string;
};

function NestedForm({
  idx,
  data,
  handleRemove,
}: {
  idx: number;
  data: InnerInput;
  handleRemove: () => void;
}) {
  const { register } = useFormContext();
  return (
    <div>
      <TextField
        {...register(`inner[${idx}.innerString1`)}
        label="Inner Input 1"
        defaultValue={data.innerString1}
        fullWidth
        variant="standard"
        type="text"
      />

      <TextField
        {...register(`inner[${idx}.innerString2`)}
        defaultValue={data.innerString2}
        label="Inner Input 2"
        fullWidth
        variant="standard"
        type="text"
      />

      <Box width={"20%"} display="flex" justifyContent={"center"}>
        <Button onClick={handleRemove}>Remove</Button>
      </Box>
    </div>
  );
}

export default NestedForm;

I am getting a strange behavior.

When I click on Remove on the first item, this will remove the last each time I click on the Remove, and finally when none left this will remove the first item.

But still when I click on Submit, I can still see all the items of the innerInput

CodeSandbox: repro


Solution

  • I found your problem and fixed it: https://codesandbox.io/p/devbox/mui-react-form-forked-69s8rf?file=%2Fsrc%2FApp.tsx%3A40%2C44&workspaceId=117705be-c8bb-48c0-8ae7-a3443e7260ba

    What was your problem?

    You used the array index as an id for elements inside an array, which is never a good idea because the ID is a unique value; an array index is not. If you delete an element inside an array, the index moves, nut the ID will stay the same.

    How did I fix it?

    1. First, I changed the Input Objects to have an extra property, id. For convenience, I used the numbers from 0 to 4, which you should change to a uuid or something else.

    2. I changed the filter function to verify the item.id instead of the index.

      function removeItem(idx: number) {
        setInnerInput((prevInnerInput) =>
          prevInnerInput.filter((item) => item.id !== idx),
        );
      }
      
    3. Lastly I changed the map function to use the item.id instead of the index.

      {innerInput.map((item) => (
        <NestedForm
          key={`inner-${item.id}`}
          idx={item.id}
          data={item}
          handleRemove={() => removeItem(item.id)}
        />
      ))}
      

    In general, it is better for objects inside arrays that you manipulate to have an id and not use an index.