skip to Main Content

I’m building a React application that asks the user for an input, and if correct (enter "3"), we render a 10×10 grid of buttons, each tied to a click handler.

Code sandbox url

When any button is clicked, it should print to the console the internal representation of the 2d grid array, ie , the state value of grid.

enter image description here

Here is the basic code:

The Grid component, which takes a 2d grid array, and creates a button array. Each cell is { onClick: (...) }

const Grid = ({ grid }) =>
  grid.flat().map((cell, index) => (
    <button key={index} onClick={() => cell.onClick(index)}>
      {index}
    </button>
  ));

Here is the Input component, which uses setInput from parent

const Input = ({ setInput }) => {
  const [_input, _setInput] = React.useState("");
  return (
    <div>
      <input onChange={(e) => _setInput(e.target.value || "")} />
      <button onClick={() => setInput(_input)}>Play</button>
    </div>
  );
};

Here is the main App component. It renders the Input, and based on a useEffect hook, if the input is valid (its 3), it updates the valid state, and also should create the grid state.

const App = () => {
  const [valid, setValid] = React.useState(false);
  const [grid, setGrid] = React.useState([]);
  const [input, setInput] = React.useState(null);

  const reveal = () => {
    console.log("grid is ", grid);
  };

  const createGrid = () => {
    if (input !== "3") return [];

    const arr = Array.from({ length: 10 }, () =>
      Array.from({ length: 10 }, () => ({
        onClick: () => reveal(),
      }))
    );
    console.log("setting grid to ", [...arr]);
    setGrid([...arr]);
  };

  React.useEffect(() => {
    setValid(input == "3");
    createGrid();
  }, [input]);

  return (
    <div>
      <Input setInput={setInput} />
      {valid && <Grid grid={grid} />}
    </div>
  );
};

export default App;

I can see that when the component is initially rendered, and we enter "3", the grid state does get set, but when I click on a button, the reveal function prints that the grid state is []. Why is this happening?

enter image description here

2

Answers


  1. This happens because of stale closure

    A stale closure occurs when an inner function captures and retains an outdated or no longer valid reference from its outer function’s scope.

    The reveal that is stored in the state is not the same as the reveal that is reinitialized on each render inside the body of the component.

    When you store it in the state, it contains the current value of the grid and it does not get updated when you change the value of grid

    Usually the callback functions are not stored in a state if there is no reason to do that.

    const App = () => {
      ...
      const reveal = (cell) => {   // access the cell here
        console.log("grid is ", grid);
      };
    
      const createGrid = () => {
        if (input !== "3") return [];
    
        const arr = Array.from({ length: 10 }, () =>
          Array.from({ length: 10 }, (_, index) => index)
        );
        setGrid(arr);
      };
       ...
    
      return (
        <div>
          <Input setInput={setInput} />
           // pass the onClick callback as second prop
          {valid && <Grid grid={grid} onClick={reveal} />}  
        </div>
      );
    };
    
    
    const Grid = ({ grid, onClick }) =>
       grid.flat().map((cell) => (
         <button key={cell} onClick={() => onClick(cell)}>
           {cell}
         </button>
       ));
    

    Be careful what you use for key, it can lead to unexpected behavior

    Login or Signup to reply.
  2. Let us have a overview of the fundamental working principles applicable in this case:

    About useEffect

    1. useEffect is basically an event by nature.
    2. It is automatically triggered upon each render.
    3. The dependency array determines the scope of the codes in useEffect.

    About snapshot of states

    1. Every render is triggered by a state change, (apart from initial render).
    2. The event handlers see only the snapshot of the states.
    3. The above point means event handlers see the states in the latest render.
    4. The states will remain unchanged during the entire life of events.
    5. The state changes triggered in events are always queued up.
    6. The above point means the state changes are not processed immediately.
    7. Each state change triggers another render.
    8. This new render will start only when the current event is over.
    9. The latest values of the state are arrived during the next render.

    About closures

    1. Closures have access to the variables by lexical scoping.
    2. It means that closure can access to the variable in its enclosing function(s).
    3. Closure do not create copies of the variables it its enclosing functions,
      instead it maintains an access to the scope. It means it sees the latest values of the variables in its scope.

    Now we shall come to your case:

    The flow of the control goes here as below:

    1. The app is ready of input after its initial render and execution of useEffect.

    2. Now we enter 3 in the input.

    3. On clicking the button, the state input will be changed so.

    4. This state change triggers a new render.

    5. After the new render, useEffect will be followed.

    6. Now we are in the event useEffect.

    6.1 We have already stated that useEffect sees only a snapshot of the states. Therefore snapshot of the state grid in the lates render is [].

    6.2. Next coming to the following code.

     const arr = Array.from({ length: 10 }, () =>
          Array.from({ length: 10 }, () => ({
            onClick: () => reveal(),
          }))
        );
    

    6.3 The above code essentially creates an array, and more specifically by initialising the array item with the following closure.

    () => reveal()
    

    6.4 The above closure has access to the state grid since it is in its enclosing function App. However at the rime of creating the closure, the value of grid is [].

    6.5 Though the following code changes the state grid, it gets executed only in the next render. Therefore the value of the state grid during the run of the event useEffect remains the same as []. This is the reason all click events triggered show only [].

    setGrid([...arr]);
    

    6.6 To put an emphasis to the issue, all click events will show the correct result when the input 3 is entered for a second time. The reason for this is that, when the input 3 was entered for the first time, the state grid has been populated with the closure accessing the filled-in array. When the input 3 is entered for a second time, all newly created closures will be accessing the filled-array.

    Solution

    As state is a snapshot, please do not use it to refer in closures. Instead simply access the local variable in the closure. The following code will give the desired result.

    const createGrid = () => {
       ...
       onClick: () => console.log(arr)
       ...
    };
    

    or this code, if you still want to use the helper function as well.

    const reveal = (arr) => {
      console.log('grid is ', arr);
    };
    
    const createGrid = () => {
       ...
       onClick: () => reveal(arr)
    };
    

    App function modified – full listing

    import { useState, useEffect } from 'react';
    
    const App = () => {
      const [valid, setValid] = useState(false);
      const [grid, setGrid] = useState([]);
      const [input, setInput] = useState(null);
    
      const reveal = (arr) => {
        console.log('grid is ', arr);
      };
    
      const createGrid = () => {
        if (input !== '3') return [];
        const arr = Array.from({ length: 10 }, () =>
          Array.from({ length: 10 }, () => ({
            onClick: () => reveal(arr),
          }))
        );
        console.log('setting grid to ', [...arr]);
        setGrid([...arr]);
      };
    
      useEffect(() => {
        setValid(input == '3');
        createGrid();
      }, [input]);
    
      return (
        <div>
          <Input setInput={setInput} />
          {valid && <Grid grid={grid} />}
        </div>
      );
    };
    
    export default App;
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search