Mustafa Yusuf
Mustafa Yusuf

Reputation: 160

How to update a nested react state

I'm trying to update a react state that holds nested values. I want to update data that is 3 levels deep.

Here is the state that holds the data:

const [companies, setCompanies] = useState(companies)

Here is the data for the first company (the companies array holds many companies):

const companies = [
    {
      companyId: 100,
      transactions: [
        {
          id: "10421A",
          amount: "850",
        }
        {
          id: "1893B",
          amount: "357",
        }
    }
]

Here is the code for the table component:

function DataTable({ editCell, vendors, accounts }) {
    const columns = useMemo(() => table.columns, [table]);
    const data = useMemo(() => table.rows, [table]);
    const tableInstance = useTable({ columns, data, initialState: { pageIndex: 0 } }, useGlobalFilter, useSortBy, usePagination);
    const {
        getTableProps,
        getTableBodyProps,
        headerGroups,
        prepareRow,
        rows,
        page,
        state: { pageIndex, pageSize, globalFilter },
    } = tableInstance;

    return (
        <Table {...getTableProps()}>
            <MDBox component="thead">
                {headerGroups.map((headerGroup) => (
                    <TableRow {...headerGroup.getHeaderGroupProps()}>
                        {headerGroup.headers.map((column) => (
                            <DataTableHeadCell
                                {...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
                                width={column.width ? column.width : "auto"}
                                align={column.align ? column.align : "left"}
                                sorted={setSortedValue(column)}
                            >
                                {column.render("Header")}
                            </DataTableHeadCell>
                        ))}
                    </TableRow>
                ))}
            </MDBox>
            <TableBody {...getTableBodyProps()}>
                {page.map((row, key) => {
                    prepareRow(row);
                    return (
                        <TableRow {...row.getRowProps()}>
                            {row.cells.map((cell) => {
                                cell.itemsSelected = itemsSelected;
                                cell.editCell = editCell;
                                cell.vendors = vendors;
                                cell.accounts = accounts;
                                return (
                                    <DataTableBodyCell
                                        noBorder={noEndBorder && rows.length - 1 === key}
                                        align={cell.column.align ? cell.column.align : "left"}
                                        {...cell.getCellProps()}
                                    >
                                        {cell.render("Cell")}
                                    </DataTableBodyCell>
                                );
                            })}
                        </TableRow>
                    );
                })}
            </TableBody>
        </Table>
    )
}

For example, I want to update the amount in the first object inside the transactions array. What I'm doing now is update the entire companies array, but doing this rerenders the whole table and creates problems. Is there a way I can only update the specific value in a manner that rerenders just the updated field in the table without rerendering the whole table? I've seen other answers but they assume that all values are named object properties.

FYI, I'm not using any state management and would prefer not to use one for now.

Upvotes: 2

Views: 990

Answers (3)

abgregs
abgregs

Reputation: 1380

What I'm doing now is update the entire companies array, but doing this rerenders the whole table and creates problems.

When you say it creates problems what type of problems exactly? How does re-rendering create problems? This is expected behavior. When state or props change, by default a component will re-render.

You seem to be asking two questions. The first, how to update state when only modifying a subset of state (an amount of a transaction). The second, how to prevent unnecessary re-rendering when render relies on state or props that hasn't changed. I've listed some strategies for each below.

1. What is a good strategy to update state when we only need to modify a small subset of it?

Using your example, you need to modify some data specific to a company in a list of companies. We can use map to iterate over each company and and conditionally update the data for the company that needs updating. Since map returns a new array, we can map over state directly without worrying about mutating state.

We need to know a couple things first.

  1. What transaction are we updating?
  2. What is the new amount?
  3. We will assume we also want the company ID to identify the correct company that performed the transaction.

We could pass these as args to our function that will ultimately update the state.

  • the ID of the company
  • the ID of the transaction
  • the new amount

Any companies that don't match the company ID, we just return the previous value.

When we find a match for the company ID, we want to modify one of the transactions, but return a copy of all the other previous values. The spread operator is a convenient way to do this. The ...company below will merge a copy of the previous company object along with our updated transaction.

Transactions is another array, so we can use the same strategy with map() as we did before.

const handleChangeAmount = ({ companyId, transactionId, newAmount }) => {
    setCompanies(() => {
      return companies.map((company) => {
        return company.id === companyId
          ? {
              ...company,
              transactions: company.transactions.map((currTransaction) => {
                return currTransaction.id === transactionId
                  ? {
                      id: currTransaction.id,
                      amount: newAmount
                    }
                  : currTransaction;
              })
            }
          : company;
      });
    });
  };

2. How can we tell React to skip re-rendering if state or props hasn't changed?

If we are tasked with skipping rendering for parts of the table that use state that didn't change, we need a way of making that comparison within our component(s) for each individual company. A reasonable approach would be to have a reusable child component <Company /> that renders for each company, getting passed props specific to that company only.

Despite our child company only being concerned with its props (rather than all of state), React will still render the component whenever state is updated since React uses referential equality (whether something refers to the same object in memory) whenever it receives new props or state, rather than the values they hold.

If we want to create a stable reference, which helps React's rendering engine understand if the value of the object itself hasn't changed, the React hooks for this are useCallback() and useMemo()

With these hooks we can essentially say:

  • if we get new values from props, we re-render the component
  • if the values of props didn't change, skip re-rendering and just use the values from before.

You haven't listed a specific problem in your question, so it's unclear if these hooks are what you need, but below is a short summary and example solution.

From the docs on useCallback()

This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders

From the docs on useMemo()

This optimization helps to avoid expensive calculations on every render.

Demo/Solution

https://codesandbox.io/s/use-memo-skip-child-update-amount-vvonum

import { useState, useMemo } from "react";

const companiesData = [
  {
    id: 1,
    transactions: [
      {
        id: "10421A",
        amount: "850"
      },
      {
        id: "1893B",
        amount: "357"
      }
    ]
  },
  {
    id: 2,
    transactions: [
      {
        id: "3532C",
        amount: "562"
      },
      {
        id: "2959D",
        amount: "347"
      }
    ]
  }
];

const Company = ({ company, onChangeAmount }) => {
  const memoizedCompany = useMemo(() => {
    console.log(
      `AFTER MEMOIZED CHECK COMPANY ${company.id} CHILD COMPONENT RENDERED`
    );
    return (
      <div>
        <p>Company ID: {company.id}</p>
        {company.transactions.map((t, i) => {
          return (
            <div key={i}>
              <span>id: {t.id}</span>
              <span>amount: {t.amount}</span>
            </div>
          );
        })}
        <button onClick={onChangeAmount}> Change Amount </button>
      </div>
    );
  }, [company]);

  return <div>{memoizedCompany}</div>;
};

export default function App() {
  const [companies, setCompanies] = useState(companiesData);

  console.log("<App /> rendered");

  const handleChangeAmount = ({ companyId, transactionId, newAmount }) => {
    setCompanies(() => {
      return companies.map((company) => {
        return company.id === companyId
          ? {
              ...company,
              transactions: company.transactions.map((currTransaction) => {
                return currTransaction.id === transactionId
                  ? {
                      id: currTransaction.id,
                      amount: newAmount
                    }
                  : currTransaction;
              })
            }
          : company;
      });
    });
  };

  return (
    <div className="App">
      {companies.map((company) => {
        return (
          <Company
            key={company.id}
            company={company}
            onChangeAmount={() =>
              handleChangeAmount({
                companyId: company.id,
                transactionId: company.transactions[0].id,
                newAmount: Math.floor(Math.random() * 1000)
              })
            }
          />
        );
      })}
    </div>
  );
}

Explanation

  • On mount, the child component renders twice, once for each company.
  • The button will update the amount on the first transaction just for that company.
  • When the button is clicked, only one <Company /> component will render while the other one will skip rendering and use the memoized value.

You can inspect the console to see this in action. Extending this scenario, if you had 100 companies, updating the amount for one company would result in 99 skipped re-renders with only one new component rendering for the updated company.

Upvotes: 0

ltanica
ltanica

Reputation: 17

When updating state based on the previous state, you probably want to pass a callback to setCompanies(). For example:

setCompanies((currCompanies) => {
  const nextCompanies = [...currCompanies];
  // modify nextCompanies
  return nextCompanies;
})

Then, in order for React to only re-render the elements that changed in the DOM, you should make sure to set the key prop in each of those elements. This way, React will know which element changed.

// inside your component code
return (
  <div>
    companies.map(company => (
        <Company key={company.id} data={company} /> 
    ))
  </div>
)

Does this solve the problem? If not, it may be helpful to add some more details so we can understand it fully.

Upvotes: 1

Roman Bova
Roman Bova

Reputation: 54

You have to copy data (at least shallow copy) to update state:

const nextCompanies = { ...companies };
nextCompanies.transactions[3].amount = 357;
setState(nextCompanies);

Otherwise react won't see changes to the original object. Sure thing you can use memoization to the child component to skip useless rerenders. But I strongly recommend to provide an optimisation only when it is needed to optimise. You will make the code overcomplicated without real profit.

Upvotes: 1

Related Questions