skip to Main Content

My goal is to perform an action when keyboard button is pressed, using current value of variable that I declared with useState.

I reproduced my problem in code example below:

import React from 'react';
import { HotKeys } from 'react-hotkeys';
import { useCallback, useEffect, useMemo, useState } from 'react';

const keyMap = {
    MOVE_DOWN: 'a'
};

export function App(props) {
  const [test, setTest] = useState(0);

  const focusNextRow = useCallback(() => {
      console.log("test", test);
      setTest(test => test + 1);
      console.log("test2", test);
  }, [test]);

  useEffect(() => {
      console.log("TEST IS NOW ", test);
  }, [test]);

  const handlers = {
      MOVE_DOWN: focusNextRow
  };

  return (
    <div className='App'>
      <HotKeys keyMap={keyMap} handlers={handlers}>
        <div>test</div>
      </HotKeys>
    </div>
  );
}

Result looks like this:

enter image description here

As you can see: test value in component is updated, but method that is called on keyboard press has always default value of this state (which is 0). How can I fix that?

3

Answers


  1. Your unexpected behaviour seems to be coming from the usage of useCallback
    Apparently, it is called Clusure Issue referenced in this question.

    TL;DR

    The Closure Issue The behavior you’re seeing is related to JavaScript
    closures and how React’s hooks capture values. When useCallback
    creates a memoized callback, it captures the value of state at the
    time the callback is created. Even though you included state in the
    dependency array, the cleanup function from useEffect runs with the
    original closure that was created during the initial render. The
    Timeline

    Initial render: state is 0, callback is created capturing state = 0
    useEffect runs: calls setState(1) Component re-renders: state is now 1
    When component unmounts: cleanup function runs using the original
    callback

    Login or Signup to reply.
  2. It appears that the HotKeys component does a bit of configuration memoization where it doesn’t "listen" to updates to certain props.

    You can use the allowChanges prop to let the HotKeys component react to the handlers changes/updates. See Component Props API for details.

    /**
     * Whether the keyMap or handlers are permitted to change after the
     * component mounts. If false, changes to the keyMap and handlers
     * props will be ignored
     *
     * Optional.
     */
    allowChanges={false}
    
    import { useCallback, useEffect, useMemo, useState } from "react";
    import { HotKeys } from "react-hotkeys";
    import "./styles.css";
    
    const keyMap = {
      MOVE_DOWN: "a",
    };
    
    export default function App() {
      const [test, setTest] = useState(0);
    
      const focusNextRow = useCallback(() => {
        console.log("test", test);
        setTest((test) => test + 1);
        console.log("test2", test);
      }, [test]);
    
      useEffect(() => {
        console.log("TEST IS NOW ", test);
      }, [test]);
    
      const handlers = {
        MOVE_DOWN: focusNextRow,
      };
    
      return (
        <div className="App">
          <HotKeys keyMap={keyMap} allowChanges handlers={handlers}>
            <div>test</div>
          </HotKeys>
        </div>
      );
    }
    

    That said, this still leaves a basic stale Javascript closure problem where you are attempting to log the React state after enqueueing a state update. React state updates are processed asynchronously, so you can’t ever log the state immediately after it’s enqueued anyway. See The useState set method is not reflecting a change immediately for full details.

    The useEffect hook is the correct method for logging state updates.

    If you wanted, you could log the "before" and "after" values in the state updater function

    const focusNextRow = useCallback(() => {
      setTest((test) => {
        console.log("test", test);
        const nextTest = test + 1;
        console.log("test2", nextTest);
        return nextTest;
      });
    }, []);
    

    but since the state updater functions are to be considered pure functions this should generally be avoided. But it could be useful/handy as a debugging step.

    Login or Signup to reply.
  3. In useCallback, do not add test as a dependency of the callback.

    no:

      , [test]);
    

    yes:

      , []);
    

    I also suggest using a name like prevTest for the parameter of the lambda function you pass to setTest so that you are reminded that it is not the current value of test and does not use the same name as the useState hook. (test, and setTest are already defined in the enclosing scope. It may be confusing to use test again here as the argument name.)

    no:

       setTest(test => test + 1); // Careful! You already have a var named `test` in enclosing scope
    

    yes:

       setTest(prevTest => prevTest + 1);
    

    And finally, be careful about what you are logging. It doesn’t make sense to log the value of test after calling setTest because of the way react state hooks work. See the documentation of useState. At this point I suggest just removing your log messages.

    Final version:

      const focusNextRow = useCallback(() => {
        setTest(prevTest => prevTest + 1);
      }, []);
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search