Search code examples
reactjsreact-hooksreact-propsreact-statereact-refresh

React state from props that doesn't update (even doing destructuring and using different keys)


Sorry for the long question in advance but I have been struggling with this for some time

To give some context of what I am trying to accomplish I am loading this table when my app loads

enter image description here

And when I click the button inside red square will bring the user to edit mode

enter image description here

In edit mode user should be able to for example toggle the High School Core (inside green square) checkbox and then discard the change by clicking Discard Changes button (inside blue square)

enter image description here

The thing is that it's not working, this checkbox should not be enabled because that Discard Changes button is only setting the editMode state to false and by doing that the table should be created by mapping other object: const whichCourse = editMode ? modifiedValues : originalValues

The original data from which originalValues and modifiedValues are created is passed to the <Table /> component as a prop (that's a requirement for this app) from App.tsx

import { v4 as uuidv4 } from "uuid";
import { Container } from "@mui/material";

import Table from "./Table";

const ID1 = uuidv4();
const ID2 = uuidv4();
const ID3 = uuidv4();

export const typedData = {
  bundles: [
    {
      id: ID1,
      name: "High School Core",
    },
    {
      id: ID2,
      name: "Middle School Core",
    },
    {
      id: ID3,
      name: "Elementary School Core",
    },
  ],
  schools: [
    {
      id: uuidv4(),
      name: "First School",
      licensedproducts: [ID1, ID2],
    },
    {
      id: uuidv4(),
      name: "Second School",
      licensedproducts: [ID2, ID3],
    },
  ],
};

export default function App() {
  return (
    <Container>
      <Table propsData={typedData} />
    </Container>
  );
}

File Table.tsx contains the following to render the UI and handle all logic

import { useState, useEffect } from "react";
import CheckIcon from "@mui/icons-material/Check";
import { Box, Grid, Button, Checkbox } from "@mui/material";

import { typedData } from "./App";

const tableStyles = {
  display: "block",
  overflowX: "auto",
  paddingTop: "36px",
  whiteSpace: "nowrap",
  fontFamily: "Helvetica Neue",
  "& table": {
    width: "100%",
    textAlign: "center",
    borderCollapse: "collapse",
  },
  "& th, td": {
    px: "17px",
    color: "#1E1E24",
    fontSize: "14px",
    fontWeight: "400",
    lineHeight: "40px",
  },
  "& th": {
    borderBottom: "2px solid #00006D",
  },
  "& td": {
    borderBottom: "1px solid #dddddd",
  },
  "& th:nth-of-type(1), td:nth-of-type(1), th:nth-of-type(2), td:nth-of-type(2)": {
    textAlign: "left",
  },
};

export default function Table({ propsData }: { propsData: typeof typedData }) {
  const [editMode, setEditMode] = useState(false);

  const [originalValues, setOriginalValues] = useState({ ...propsData });
  const [modifiedValues, setModifiedValues] = useState({ ...propsData });

  useEffect(() => {
    console.log("running useEffect");
    setOriginalValues({ ...propsData });
    setModifiedValues({ ...propsData });
  }, [propsData]);

  const whichCourse = editMode ? modifiedValues : originalValues;
  const keyComplement = editMode ? "yes" : "not";

  const toggleEdit = () => {
    setEditMode((current) => !current);
  };

  const saveButton = () => {
    setOriginalValues(modifiedValues);
  };

  return (
    <Box sx={{ textAlign: "center", pt: "10px" }}>
      <Grid container spacing={2}>
        <Grid item xs>
          <Button variant="contained" onClick={toggleEdit}>
            {editMode ? "Discard changes" : `Edit Mode - ${keyComplement}`}
          </Button>
        </Grid>
        {editMode && (
          <Grid item xs>
            <Button variant="contained" onClick={saveButton}>
              Save changes
            </Button>
          </Grid>
        )}
      </Grid>
      <Box sx={tableStyles}>
        <Box component="table" sx={{ overflowX: "auto" }} tabIndex={0}>
          <thead>
            <tr>
              <Box component="th">ID</Box>
              <Box component="th">School Name</Box>
              {whichCourse.bundles.map((thisBundle) => {
                return (
                  <Box component="th" key={`th-${thisBundle.id}-${keyComplement}`}>
                    {thisBundle.name}
                  </Box>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {whichCourse.schools.map((thisSchool, currentIndex) => {
              return (
                <tr key={`td-${thisSchool.id}-${keyComplement}`}>
                  <Box component="td">{thisSchool.id}</Box>
                  <Box component="td">{thisSchool.name}</Box>
                  {whichCourse.bundles.map((thisBundle) => {
                    const isEnabled = thisSchool.licensedproducts.includes(thisBundle.id);
                    return (
                      <Box component="td" key={`td-${thisBundle.id}-${keyComplement}`}>
                        {editMode ? (
                          <Checkbox
                            size="small"
                            checked={isEnabled}
                            sx={{
                              color: "#000000",
                              "&.Mui-checked": {
                                color: "#3F51B5",
                              },
                            }}
                            onChange={() =>
                              setModifiedValues((currentValue) => {
                                if (isEnabled) {
                                  currentValue.schools[currentIndex].licensedproducts = currentValue.schools[currentIndex].licensedproducts.filter((value) => value !== thisBundle.id);
                                } else {
                                  currentValue.schools[currentIndex].licensedproducts.push(thisBundle.id);
                                }
                                return { ...currentValue };
                              })
                            }
                          />
                        ) : (
                          isEnabled && <CheckIcon sx={{ verticalAlign: "middle" }} />
                        )}
                      </Box>
                    );
                  })}
                </tr>
              );
            })}
          </tbody>
        </Box>
      </Box>
    </Box>
  );
}

I created a very simple repo with this code and a CloudFlare Pages deploy


Solution

  • Problem with your code is this:

    const [originalValues, setOriginalValues] = useState({ ...propsData });
    const [modifiedValues, setModifiedValues] = useState({ ...propsData });
    

    You just spread same object in two different states, by spread you shallowly copied only top level of object, but all other nested things are shared between originalvalues and modifiedvalues since those nested references were not copied.

    And like that each time you check unchecked checkbox, like this:

    else {                         
           currentValue.schools[currentIndex].licensedproducts.push(thisBundle.id);
         }
    

    You are basically modifying both original and modified values, since you just pushed in that same licencesedproducts array that was shared because you were not copied deeply when setting initial state. You should either do deep copy on initial state set(which I suggest), or/and in this part where you modify state to use spread of array in order to create new one, eg: currentValue.schools[currentIndex].licensedproducts = [...currentValue.schools[currentIndex].licensedproducts, thisBundle.id]