Search code examples
reactjsformsreact-hooksmaterial-uidialog

React Material UI Dialog form data state not set correctly when updated


When selecting new data for the dialog form it will incorrectly display the original data. Only after closing the dialog and then reopening the same one, attempted before, does the form display the correct values. I believe there is some useEffect and useState shenanigans but not sure how to resolve.

To Test click Button Data 1 to verify data is correct and then click Data 2. It will show the data corresponding to Data1 not Data 2 where we obviously want to see the Data 2 values.

In the demo, and actual implementation, the selectedId state is needed because it is refernce elsewhere.

codesandbox

import React, { useState, useCallback, useEffect } from "react";
import SomeDialog from "./SomeDialog";
import { FormData } from "./FormData";
import { Button } from "@mui/material";

let initialFormData: FormData[] = [
  { name: "data1", id: 0, location: "here" },
  { name: "data2", id: 1, location: "there" }
];

function Demo(): any {
  const [selectedData, setSelectedData] = useState<FormData>(
    initialFormData[0]
  );

  const [selectedId, setSelectedId] = useState<number>(0);

  const [dialogOpen, setDialogOpen] = useState<boolean>(false);

  const handleDialogOpen = useCallback(() => setDialogOpen(true), []);
  const handleDialogClose = useCallback(() => setDialogOpen(false), []);

  const onClick = (index: number) => {
    setSelectedId(index);
    handleDialogOpen();
  };

  useEffect(() => {
    let data = initialFormData.find((data) => data.id === selectedId);

    setSelectedData(data);
  }, [selectedId]);

  return (
    <div style={{ height: "100vh", width: "100vw" }}>
      <Button
        onClick={() => {
          onClick(0);
        }}
      >
        Data 1
      </Button>

      <Button
        onClick={() => {
          onClick(1);
        }}
      >
        Data 2
      </Button>

      <SomeDialog
        open={dialogOpen}
        onClose={handleDialogClose}
        data={selectedData}
      />
    </div>
  );
}

export default Demo;

Dialog Form

import React, { useState, useEffect, ChangeEvent } from "react";
import TextField from "@mui/material/TextField";
import { Button, Dialog, Box } from "@mui/material";

import { FormData } from "./FormData";

interface SomeDialogProps {
  open: boolean;
  onClose: () => void;
  data: FormData;
}

function SomeDialog(props: SomeDialogProps) {
  const { open, onClose, data } = props;

  const [formData, setFormData] = useState<FormData>(data);

  useEffect(() => {
    setFormData(data);
  }, [data]);

  function handleDataInputChange(event: ChangeEvent<HTMLInputElement>) {
    const { id, value } = event.target;
    setFormData({ ...formData, [id]: value });
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    console.info(formData);

    onClose();
  }

  return (
    <Dialog
      open={open}
      onClose={onClose}
      aria-labelledby="modal-modal-title"
      aria-describedby="modal-modal-description"
    >
      <Box
        component="form"
        sx={{
          "& .MuiTextField-root": { m: 1, width: "25ch" }
        }}
        noValidate
        autoComplete="off"
        onSubmit={handleSubmit}
      >
        <TextField
          id="id"
          label="Id"
          defaultValue={formData.id}
          onChange={handleDataInputChange}
        />

        <TextField
          id="name"
          label="Name"
          defaultValue={formData.name}
          onChange={handleDataInputChange}
        />

        <TextField
          id="location"
          label="Location"
          defaultValue={formData.location}
          onChange={handleDataInputChange}
        />

        <Button type="submit">Submit</Button>
      </Box>
    </Dialog>
  );
}

export default SomeDialog;

export interface FormData {
  name: string;
  id: number;
  location: string;
}


Solution

  • I see two problems here:

    Demo.tsx

    In your Demo.tsx file, setSelectedData(data) call in the useEffect is asynchronous. It means that when you call handleDialogOpen() immediately after setting selectedId, the selectedData state may not have been updated yet, leading to the dialog displaying the old data. Instead, you can combine the logic of both selectedId and selectedData in the same function before calling handleDialogOpen() like this:

    const onClick = (index: number) => {
      let data = initialFormData.find((data) => data.id === index);
      setSelectedData(data);
      setSelectedId(index);
      handleDialogOpen();
    };
    

    This ensures that when you open the dialog, selectedData is updated with the correct data immediately before opening the dialog.

    SomeDialog.tsx

    Similarly, in your SomeDialog.tsx file, formData is set to the value of data prop on the first render. However, as aforementioned, React state updates are asynchronous, which means that when the data prop changes, the formData state variable is not updated immediately during the render cycle.

    So, if the data prop changes (for example, when you open the dialog with different data), formData will still hold the previous value from the previous render. This is because the state update caused by changing the data prop hasn't had the chance to take effect yet.

    To fix this issue, you should directly use the data prop to initialize the form fields, and you can remove the local formData state entirely:

    import React, { useEffect, ChangeEvent } from "react";
    import TextField from "@mui/material/TextField";
    import { Button, Dialog, Box } from "@mui/material";
    
    import { FormData } from "./FormData";
    
    interface SomeDialogProps {
      open: boolean;
      onClose: () => void;
      data: FormData;
    }
    
    function SomeDialog(props: SomeDialogProps) {
      const { open, onClose, data } = props;
    
      function handleDataInputChange(event: ChangeEvent<HTMLInputElement>) {
        const { id, value } = event.target;
        // No need to update local state, directly use data prop
        data[id] = value;
      }
    
      function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
        event.preventDefault();
    
        console.info(data);
    
        onClose();
      }
    
      return (
        <Dialog
          open={open}
          onClose={onClose}
          aria-labelledby="modal-modal-title"
          aria-describedby="modal-modal-description"
        >
          <Box
            component="form"
            sx={{
              "& .MuiTextField-root": { m: 1, width: "25ch" }
            }}
            noValidate
            autoComplete="off"
            onSubmit={handleSubmit}
          >
            <TextField
              id="id"
              label="Id"
              defaultValue={data.id}
              onChange={handleDataInputChange}
            />
    
            <TextField
              id="name"
              label="Name"
              defaultValue={data.name}
              onChange={handleDataInputChange}
            />
    
            <TextField
              id="location"
              label="Location"
              defaultValue={data.location}
              onChange={handleDataInputChange}
            />
    
            <Button type="submit">Submit</Button>
          </Box>
        </Dialog>
      );
    }
    
    export default SomeDialog;