React design of a stateful list where every item depends on the previous one
Asked Answered
K

1

7

I'm developing one of my projects with React, and I need to create some list where each item represents a table row, and is dependent on the previous line (and partially on the next line) - similar to how Excel or Google Sheet works.
Say each item has three fields, a, b and c. Each item updates its own a field depending on the a field of the previous item, and updates its b field depending on the c field of the next one.

My current design is to hold each item as a separate component (rendering the table row), and to hold 2 lists in the parent component - one for the states of each item, and one for the items themselves.
Looks something like this:

interface ItemState {
  a: number,
  b: number,
  c: number,
}

function ItemComponent({prevState, nextState, state, setState}:
        {prevState: ItemState; nextState: ItemState; state: ItemState; setState: (state: ItemState) => void;}) {

  React.useEffect(() => {
    setState({...state, a: prevState.a+1});
  }, [prevState.a]);

  React.useEffect(() => {
    setState({...state, b: nextState.c-1});
  }, [nextState.c]);

  return (<...>); // table row
}

function ParentComponent() {
  const [stateList, setStateList] = React.useState<ItemState[]>([item1, item2, item3]);
  
  const mapState = React.useCallback(
    (prev: ItemState | null, curr: ItemState, next: ItemState | null) => {
      return (
        <ItemComponent
          key="someUniqueId"
          prevState={prev}
          nextState={next}
          state={curr}
          setState="some function which sets a specific item in the list based on id"
        />
      );
    },
    []
  );

  const itemList = React.useMemo(
    () =>
      stateList.map((state, i) => {
        const prev = i === 0 ? null : stateList[i - 1];
        const next = i === stateList.length - 1 ? null : stateList[i + 1];
        return mapState(prev, stintState, next);
      }),
    [stateList, mapState]
  );

  return (<...>); // rendered table containing the itemList
}

(Obviously this is a simplified version, in reality I perform more complex calculations over more fields than just a, b and c.)

This solution works. However, its performance is terrible, as I normally have much more than just 3 items in the list, causing it to take a significant amount of time to update all of the decedents when one item is updated.
Moreover, when the list contains over ~14 items I start getting "Maximum update depth exceeded" errors, caused by the large amount of setStates inside useEffects.

I'm sure there's a much more elegant and performative solution to this problem, I just couldn't figure it out myself - and I'd love to get some help on that.

Edit:

The main goal here is to display a table where I can modify some values in each row. Modifications to row i should trigger the following flows:

  1. Re-calculate specific values of row i-1 and re-render it, but do not trigger any further calculations/renders as a result of that.
  2. Re-calculate specific values different than those in step 1. for row i+1, and trigger the same process on row i+1 (steps 1. and 2.).

So, once row i has been modified, row i-1 should be re-rendered, and any preceding row should be re-rendered.

The calculations I'm doing are rather complicated - hence the row dependency (otherwise I would've directly calculated the data for each row on every update, without having to wait for the previous one).

Kreplach answered 27/4 at 15:57 Comment(3)
this is not really an answer to your question, but since I actually did something similar recently I have insight, I think you should if possible explore some reactive approaches, for example I used solidjs, where you have signals, so const a=createSignal<number>(1)... then you can do const b=()=>a()*2 and b() will be double the value of a(). Its nice to use like that and dependencies are tracked implicitly, although you can state them explicitly. If you prefer something closer to react there's preact that has signals too which may be useful.Inject
Is the intent that, updating a value on one row will cascade update to all rows of the table? Or is it somehow limited to the row before and the row after? If the intent is all rows should cascade, then I think what you really want is one object in state, that updates then causes a re-render. Individual states for each row does not feel like the right way to handle this.Rizzio
The intent is so that when you update one row, it updates all the rows beneath it (and just one above).Kreplach
B
1

I think you're significantly over complicating this. I think the best approach is to just memoize the data you're using in the form that will match the props of the component. Do something like:

const formattedData = useMemo(() => doMyCalculations(stateList), [stateList])

And then just map that formattedData, which should be an array of objects (or arrays if that fit's your case better).

<div>
{formattedData.map((data) => {
    return <ItemComponent .../>
})}
</div>

Right now you're memoizing both the component, and the function to create the component, which are two different versions of essentially the same thing.

Also, if you set state in a useEffect hook that runs right on render, which changes the state of the list that the object is a part of you're going to create an infinite loop. You need to calculate that data ahead of time, or only on a user interaction or something that runs conditionally... not immediately on render. That will trigger a re-render, which in turn will trigger another change to the list, which in turn triggers a re-render... and you're in a black hole.

Why this causes an infinite loop

The itemList, which contains the functions that generate the ItemComponent is dependent on the stateList piece of state. In each ItemComponent there's a useEffect hook, which will run immediately after every rendering. In that useEffect hook you're setting state which changes the stateList piece of state. It doesn't matter if you only change one item in the array, this will change the stateList piece of state, which will cause all children, of which the ItemComponent is one, to re-render. This will in turn trigger the useEffect hook again, which in turn will set state again, which in turn will cause another re-render.

If you can provide more details about what you're exactly trying to accomplish that you need that useEffect hook for, I can give you a more complete example of how you would want to go about this.

Edit:

To accomplish something where rows less than i are updated without requiring a lot of re-running an expensive computation, you can do one of two things. Either lift up a piece of state to the parent component (which you pretty much have right now) and memoize the data in the child component (which won't be recalculated, even though the component would re-render), or you can put aside memoizing data in the child component, and instead calculate all of the data in the parent component, but check if the item index is an index that should be updated, and if not use the existing value. Both will require a re-render, but in both cases you won't be running the expensive calculation on the indices that shouldn't be updated. With the first, you might run into some difficulties trying to run the useMemo hook based on an index that was clicked, especially without adding more state which would likely increase the number of renders, but the latter would just look like this:

<div>
{formattedData.map((data, i) => {
    return <ItemComponent ... index={i} updateState={setStateList} stateList={stateList} />
})}
</div>

And in the ItemComponent:

const ItemComponent = ({index, updateState, stateList, ...props}) => {
   const theExpensiveComputation = (item) => doStuff(item)

   const handleUpdate = () => {
        updateState(stateList.map((item, i) => i < index ? theExpensiveComputation(item) : item))
    }
   return (<div>...</div>)
}
Borax answered 4/5 at 15:56 Comment(7)
Can you explain the infinite loop thing? Because the code I attached does not cause an infinite loop, it updates only as many times as needed, only took slow.Kreplach
And as for the solution suggestion - say I do it this way. And then I update the 5th element for example, and consequently the 4th and everything after the 5th. Will only these rows be re-rendered in the table? Or will all the rows be re-rendered? Newbie question, but that's what I currently am when it comes to React :)Kreplach
It does cause an infinite loop, but eventually it will fail to send the next useEffect hook once the computer becomes so overwhelmed that timing issues get in the way. I'll edit this answer to describe it better. I can't fit it all in a comment.Borax
Ok, understood. I've edited the original question - see if that clarifies thing a bit and whether you can now perhaps go into more detail as for the solution.Kreplach
Ok, I see a bit more of what you're saying now. I'll edit this answer again.Borax
Do you have this in a repo somewhere where I can take a look?Borax
Afraid not, will be keeping this project private for the time being. I'll look into your update tomorrow.Kreplach

© 2022 - 2024 — McMap. All rights reserved.