Workaround For Stale React State

Quick workaround for stale state in React. Make a few minor changes to fix any existing setup.

Workaround For Stale React State

This is a problem that I forget about until it bites me. Dealing with closures and stale state. I am going to show you a very simple workaround for stale React state and a common scenario that I run into that causes this problem.

The Scenario

The scenario is simple. I have a parent object that holds some state, but I want child components to be able to do something simple, like delete themselves.

Here I set up a 'Parent' with some simple state and an array of child components. There is a button to add more rows as well as a deletion function that gets triggered from the child row.

export default function Parent() {
    const [childRows, setChildRows] = useState([])
    const [keyNumber, setKeyNumber] = useState(0)

    const addRow = () => {
    	setChildRows([...childRows, <Child keyNumber={keyNumber} deleteSelf={deleteRow}></Child>]);
        setKeyNumber(keyNumber + 1);
    }
    
    const deleteRow = (key: number) => {
    	console.log("Deleting Row", key);
        console.log("Current Key Number", keyNumber);
        //deletion logic not important
    }

    return (
    	<div>
            <button onClick={addRow}>Add Row</button>
            {childRows}
        </div>
    );
}

And here is a basic Child component with props from the parent.

export type ChildInput = {
	keyNumber: number;
    deleteSelf: (keyNumber: number) => void;
}

export default function Child(input: ChildInput) {
    
    return (
    	<div>
        	<p>Look im a child!</p>
            <button onClick={() => {
            	input.deleteSelf(input.keyNumber);
            }>
                Delete Self
            </button>
        </div>
    );
}

Okay with that code in place let's try a little contrived scenario. Let's say I hit the 'Add Row' button 3 times and now have 3 child rows visible, each with its own 'Delete Self' button.

If I press 'Delete Self' on the top row what would you expect the console output to be? At a quick glance, I would guess:
Deleting Row 1
Current Key Number 3

It was the first row we clicked and keyNumber should have gone 0 -> 1 -> 2 -> 3 after 3 presses of  'Add Row'. Well, the actual result will be:
Deleting Row 1
Current Key Number 0

Why is keyNumber 0? Simply put, when the first row was created we had a function closure while keyNumber was currently at 0 and that state gets stored in the context of that deleteRow function for that specific child. Deleting row 2 and 3 would give us keyNumber 1 and 2 respectively.

What I would actually want is for keyNumber to be 3 for all three instances of that call.

The Solution

The actual solution is very straightforward, although not pretty in the slightest. Introduce useRef. If you want a quick crash course on useState vs useRef, check it out here. The important thing is we still want rerenders and all the niceties that useState brings to our states so let's just combine the two approaches.

export default function Parent() {
    const [childRows, setChildRows] = useState([])
    const [keyNumber, setKeyNumber] = useState(0)
    const keyNumberRef = useRef(keyNumber);

    const addRow = () => {
    	setChildRows([...childRows, <Child keyNumber={keyNumber} deleteSelf={deleteRow}></Child>]);
        setKeyNumber(keyNumber + 1);
    }
    
    const deleteRow = (key: number) => {
    	console.log("Deleting Row", key);
        console.log("Current Key Number", keyNumberRef.current);
        //deletion logic not important
    }
    
    useEffect(() => {
    	keyNumberRef.current = keyNumber;
    }, [keyNumber]);

    return (
    	<div>
            <button onClick={addRow}>Add Row</button>
            {childRows}
        </div>
    );
}

We just changed a few simple things. We added a constant keyNumberRef which we just use to mirror keyNumber. The deleteRow function called from the child now calls that instead and then we add a simple useEffect to make sure that whenever keyNumber changes, so does keyNumberRef.

Now if we try our scenario, 3 add row clicks then delete the first row we get:
Deleting Row 1
Current Key Number 3

Done! To solve having stale state in React we just use a combo of useState and useRef for a quick workaround. I often use this approach because it's a problem I catch late in the development of the components and all my state workflows are already nicely set up.