skip to Main Content

I wonder what the fundamental (or any kind, really) difference is between using a variable as a dependency in a useCallback()’d function and passing it to the function as a parameter.

a)

const s = useState(0);
const f = useCallback(() => {
 console.log(s);
}, [s]);
f(); // logs current value of s

b)

const s = useState(0);
const f = useCallback((s) => {
 console.log(s);
}, []);
f(s); // logs current value of s

I just can’t think of an example where they would be functionally different. (Except that in case b) the function reference doesn’t change even if s does, so that would be preferable…?)

In what case would you need a useCallback() with a non-empty dependency list at all then?

2

Answers


  1. I wonder what the fundamental (or any kind, really) difference is
    between using a variable as a dependency in a useCallback()’d function
    and passing it to the function as a parameter.

    The primary difference between the two is when the variable is known and accessible as compared to when the function is called.

    • Version A

      The dependency values are all known ahead of time and can be simply closed over in callback scope, to be referenced later when the function is called. These values will be specified in the useCallback hook’s dependency array.

    • Version B

      The "dependency" values might not be known until the function is called and passed them. These values will be passed as function arguments to the memoized callback function returned by the useCallback hook.

    In your very trivial example, yes, there is not much difference between the two and the result is identical. In version A the s dependency value is known and currently accessible in scope, so its value is closed over in the callback scope and used when the callback function is called. In version B the value of s might not be known until the callback is called and the argument passed to it.

    I just can’t think of an example where they would be functionally
    different. (Except that in case b) the function reference doesn’t
    change even if s does, so that would be preferable…?)

    When used like in your example, again, not much difference. The primary purpose of the useCallback hook is to memoize and provide a stable callback function reference. This is important when that callback function is used in the dependency array of another React hook, e.g. useEffect, where you don’t want to unnecessarily trigger that hook more often than needed, or passed as props to children components and trigger unnecessary component re-renders.

    In what case would you need a useCallback() with a non-empty
    dependency list at all then?

    Any time there is an external dependency.

    Example:

    • Auth client is known ahead of time and is closed over in callback scope
    • User name and password are not known until callback is called
    const authClient = useAuthClient();
    
    const login = useCallback(async (username, password) => {
      try {
        setLoading(true);
        await authClient.login(username, password);
        navigate("/dashboard");
      } catch(error) {
        toast("There was a login error: " + error);
      } finally {
        setLoading(false);
      }
    }, [authClient]);
    
    • Used as a stable reference to useEffect hook call:

      const [username, setUsername] = useState("");
      const [password, setPassword] = useState("");
      
      useEffect(() => {
        login(username, password);
      }, [login, password, username]);
      
    • Used as a stable callback passed as props:

      <Login login={login} />
      
      const Login = ({ login }) => {
        const [username, setUsername] = useState("");
        const [password, setPassword] = useState("");
      
        const handleLogin = (e) => {
          e.preventDefault();
          login(username, password);
        };
      
        return (
          <form onSubmit={handleLogin}>
            {/* input fields, etc */}
      
            <button type="submit">
              Log in
            </button>
          </form>
        );
      }
      
    Login or Signup to reply.
  2. You can directly go to the section conclusion below and read the summary. Still sequential reading may be more useful.

    There may be two distinct things we need to discuss over here:

    1. Arguments in a function
    2. Lexically scoped variables in a function

    Arguments in a function

    As we know, these are the variables formally declared in a function definition. And the actual values will be passed into the function by calling sites. Formal parameters and actual parameters – the two terms which you might have already come across, are applicable here.

    Lexically scoped variables in a function

    Every function has access to the variables in its enclosing context. This is quite relevant when it comes to nested functions. It means that a nested function has accessed to the variables in its enclosing function. A nested function object along with a scope of variables from its enclosing function is known by the term closure which you would be familiar, already.

    Now let us inspect the below sample code and its output

    App.js

    import { useCallback, useState } from 'react';
    
    export default function App() {
      const [stateA, setStateA] = useState('A');
      const [stateB, setStateB] = useState('B');
    
      const cachedFn = useCallback((stateA) => stateA + stateB, [stateB]);
    
      return (
        <>
          <Child cachedFn={cachedFn} />
        </>
      );
    }
    
    function Child({ cachedFn }) {
      return <>Child component printed this : {cachedFn('C')}</>;
    }
    

    Test run results

    On loading of the app, it displayed as below:

    enter image description here

    Observations

    We may ask here, why it did not print AB, instead it printed CB ? This question is quite relevant since the expression the function returns is this : stateA + stateB.

    It printed C first because the lexically scoped variable stateA now is hidden here by the formal argument stateA, which is with the same name. Therefore stateA inside the nested function received the actual value C passed by the calling site, Child component.

    Then it printed B second, it did so since stateB is a lexically scoped variable in this nested function. Therefore it printed B as its value. Please note B was the value of stateB in the enclosing function when this nested function was initially defined by useCallback in the initial render.

    Now let us inspect another sample code with its output.

    App.js

    import { useCallback, useState } from 'react';
    
    export default function App() {
      const [stateA, setStateA] = useState('A');
      const [stateB, setStateB] = useState('B');
    
      const cachedFn = useCallback((stateA) => stateA + stateB, [stateB]);
    
      return (
        <>
          <Child cachedFn={cachedFn} />
          <br />
          <button onClick={() => setStateB('BB')}>Change stateB to BB</button>
        </>
      );
    }
    
    function Child({ cachedFn }) {
      return <>Child component printed this : {cachedFn('C')}</>;
    }
    

    Test run result

    On loading of the app, it displayed the same as above:

    enter image description here

    However, on clicking the button, it displayed as below:

    enter image description here

    Observations

    As we can see in the code, there is now a button added to do a state change. Now on clicking the button, it printed CBB. Please note that it had printed CB on load of the same app. What does it mean may be self explanatory. On clicking the button, there is a state change occurred in stateB, therefore when the child component rendered second time, it could print the latest state. This is quite expected. However here may be an important aspect to emphasise, during the second render followed by the state change, useCallback returned a newer definition of the nested function. It did so since stateB has been specified as its dependency. It did so only because of this non-empty dependency setting. Let us see below if this statement is true or not.

    Let us see the below code which ignores the linter advice and removed the dependency to make it empty.

    App.js

    import { useCallback, useState } from 'react';
    
    export default function App() {
      const [stateA, setStateA] = useState('A');
      const [stateB, setStateB] = useState('B');
    
      // code changed here.
      const cachedFn = useCallback((stateA) => stateA + stateB, []);
    
      return (
        <>
          <Child cachedFn={cachedFn} />
          <br />
          <button onClick={() => setStateB('BB')}>Change stateB to BB</button>
        </>
      );
    }
    
    function Child({ cachedFn }) {
      return <>Child component printed this : {cachedFn('C')}</>;
    }
    

    Test run results

    On loading the app, it remains the same as in previous two cases.

    On clicking the button this time, it still prints initial stateB.

    enter image description here

    Observations

    What could have been happened here ? A simple guess would tell us that the stateB in the nested function has not been found the latest state value. Therefore it still prints its initial value. The reason may also be self explanatory. The useCallback did not redefine the function in the second render since it has not been told to do so through its dependency.

    If we still think that we would have defined the function with two formal arguments stateA and StateB and could have avoided this failure. And we then could have still left the dependency array blank. However, this is not the same compared to accessing lexically scoped variable. Please see the conclusion below.

    Therefore the conclusion may be like this :

    We need to have both – formal arguments and lexically scoped variables, in a nested function to meet the programming requirements. We cannot replace lexically scoped variables with formal arguments. It is because the value of a formal variable is passed in by the calling site. More specifically, the value of a formal variable is passed in when it is invoked. However, through lexical scoping, a nested function can access the value of a variable at the time the function was defined and not at the time it was invoked.

    This may be the most important point :

    In order to get the latest values of lexically scoped variables, the nested function definition has to be redefined with respect to its dependency changes. This is the technical reasoning for specifying a non-empty dependency array.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search