javascriptreactjscallbackscopes

reactjs - State variable is null when callback in parent component is called from child


Edit: I found the solution as a result of cbreezier's advice, as well as learning more about how the table hook was working (which I had found on YouTube and foolishly did not fully understand). Sending currentData back to ParentComponent via the props callback was what I took from their answer. However, passing currentData to the callback was not working from tableHooks. I then realized that while it was able to access the rows, it was not able to access the currentData. Changing Cell: ({ row }) to Cell: ({ currentData, row }) did the trick.

If you are trying to figure out a similar issue in your own React app, this may not be the exact answer, but I would suggest understanding cbreezier's answer and hopefully that sets you on your way.

Original Question:

I have been encountering an issue for a few days now that appears to be rooted in my inexperience with React and Javascript, but without knowing what the issue is, it has been difficult to search for a solution. Here is some example code that somewhat isolates the issue:

export const ParentComponent = () => {
    const [currentData, setCurrentData] = useState([]);

    const fetchCurrentData = async () => {
        let fetchedCurrentData = await getFunctions.getCurrentData();
        setCurrentData(fetchedCurrentData);
    }

    const handleButtonPress = (row) => {
        console.log(currentData);
    }

    return (
        <div>
            <ChildComponent data={currentData} handleButtonPress={handleButtonPress} />
        </div>
    )
}

export default function ChildComponent(props) => {
    const columns = useMemo(() => COLUMNS, []);
    const data = useMemo(() => props.data, [props.data]);

    const tableHooks = (hooks) => {
        hooks.visibleColumns.push((columns) => [
            ...columns, {
                id: "add",
                Header: "Add",
                Cell: ({ row }) => (
                    <button onClick={() => props.handleButtonPress()}>Add</button>
                )
            }
        ])
    }

    const {
        getTableProps,
        getTableBodyProps,
        headerGroups,
        rows,
        prepareRow
    } = useTable({
        columns,
        data
    }, tableHooks);

    return (
        <div>
            <table {...getTableProps()}>
                <thead>
                    {headerGroups.map(headerGroup => (
                        <tr {...headerGroup.getHeaderGroupProps()}>
                            {
                                headerGroup.headers.map(column => (
                                    <th {...column.getHeaderProps()}>{column.render('Header')}</th>
                                ))
                            }
                        </tr>
                    ))}
                </thead>
                <tbody {...getTableBodyProps()}>
                    {rows.map((row, i) => {
                        prepareRow(row);
                        return (
                            <tr {...row.getRowProps()}>
                                {
                                    row.cells.map(cell => {
                                        return <td {...cell.getCellProps()}>{cell.render('Cell')}</td>;
                                    })
                                }
                            </tr>
                        );
                    })}
                </tbody>
            </table>
        </div>
    )
}

The flow is as so:

  1. Data is received
  2. currentData state variable is updated using setCurrentData
  3. ParentComponent is re-rendered
  4. ChildComponent is also rendered and receives currentData via props
  5. Additionally, ChildComponent receives a function handleButtonPress via props
  6. ChildComponent uses react-table (v7.8.0) to display the data, and then via a hook is able to create an additional column in the table, with a button for each row
  7. The button is set to call handleButtonPress from props onClick
  8. Button is pressed and handleButtonPress is called

The above flow is working correctly. The issue occurs at the end of this flow, when the function handleButtonPress is called from ChildComponent, as console.log(currentData) logs an empty array instead of the actual state of currentData. I have verified that currentData is populated before re-render of ParentComponent, and that it successfully reaches ChildComponent.

Would greatly appreciate some help with this! If I had to guess where I'm going wrong, it's that something about passing the function handleButtonPress via props is potentially sending a version of the function before currentData is set, but like I said I'm quite inexperienced with React and that's just a guess!

Thanks for reading.


Solution

  • There are two pieces of missing information that makes it impossible to answer your question with 100% certainty:

    1. How are you invoking fetchCurrentData?
    2. How is tableHooks used and invoked?

    However, even without that information, I can give you a pretty good idea about what the issue is.

    The first thing to understand is that every time ParentComponent re-renders, you are defining a completely new handleButtonPress function.

    The second thing to understand is that the currentData variable in ParentComponent changes identity after setCurrentData is called within the fetchCurrentData function. What that means is that it's actually a completely difference variable on the second re-render of ParentComponent compared to the first.

    The third thing to understand is that your handleButtonPress function forms a closure around the currentData variable. However, because the identity of currentData changes between re-renders, the variable being closed over is actually different. In other words, the handleButtonPress function from the first re-render has closed over the currentData variable from the first re-render, which is stuck as []. The handleButtonPress function from the second re-render has closed over the currentData variable from the second re-render, which contains your actual data.

    Finally, I suspect that tableHooks is only invoked once during the first render of ChildComponent, and that the props.handleButtonPress function is the first handleButtonPress function which is stuck with the first currentData which is [].

    The solution?

    Pass through the relevant data through to the handleButtonPress function in the invocation of the function. Ie, change the function signature to:

        const handleButtonPress = (row, data) => {
            console.log(data);
        }
    

    Incidentally, now that function is a pure function - it has no side effects (well, other than logging to console) and depends purely on its inputs. You can move that function to be defined outside of your render loop for a (very small) performance improvement, and a pretty useful way to declare "this is indeed a pure function".

    In general, prefer pure functions and avoid relying on side effects to avoid tricky situations like you encountered here.