Handling Stale State from Scheduled Functions in React

Avoid creating stale closures with setTimeout() and setInterval() by using hooks to reference your logic
React
State Management
Custom Hooks
JS Pass by Value
TypeScript
Picture of John Wright Stanly, author of this blog article
John Wright Stanly
Aug 8, 2021

-

-

image

The React Hooks API is widely becoming popular among the React community. Hooks enable components to supports features like state without needing to be written as a class.

However, hooks will sometimes act differently than expected. One common problem with useState() hooks is the state not appearing to update in scheduled functions like setTimeout() and setInterval().

Consider the following example with a counter:

function Component() {
  const [count, setCount] = React.useState<number>(5);

  function log() {
    console.log('Count:', count);
  }

  React.useEffect(() => {
    const timeout = setTimeout(log, 2000);
    const interval = setInterval(log, 3000);

    return () => {
      clearTimeout(timeout);
      clearInterval(interval);
    };
  }, []);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={handleClick}>Click here</button>
    </div>
  );
}

Even if the button is clicked multiple times, the console is always going to log Count: 5. Although the console didn't log the update, the React state was indeed updated. The state just wasn't updated inside the log() function. This might seem impossible, but the underlying issue has to do with JavaScript's design.

Fundamentally, JavaScript is pass by value. So when the useState() hook returns a plain variable, it's not a "true" reference to your state.

JavaScript treats functions as closures. Closures are functions that have access to their parent's scope. This is how functions like log() can read the count state without count actually being defined inside log(). When invoking functions like log() directly in your React component, the closure's parent scope is the React component, where state updates are handled and never go stale.

image

But JavaScript treats functions as variables, which can be passed by value too. So when log() is passed as an argument to setTimeout(), the log() function is read by value. The log() closure is now being ran in a different parent scope. Now the log() closure inside of setTimeout() will no longer pick up on state updates. Essentially, the log() is now frozen inside of setTimeout(). This is known as a stale closure.

So when you write a function with React state inside, and pass that function to another function, there's no guarantees the React state will be updated. No need to worry though. There are two ways we can approach fixing "frozen state":

  • Referencing the state
  • Referencing the closure

Fix stale state with references

There's a caveat to JavaScript's pass by value- the items passed by value are in fact a reference to the value (also known as call-by-sharing). This doesn't mean much for primitives like numbers and strings, but this does mean objects can be accessed and mutated by their reference.

Consider this example, which gives the following output:

const num = 1;
const str = '1';
const obj1 = { key: '1' };
const obj2 = { key: '1' };
const obj3 = { key: '1' };
const arr1 = [1];
const arr2 = [1];
const arr3 = [1];

function mutateParameters(num, str, obj1, obj2, obj3, arr1, arr2, arr3) {
  num = 2;
  str = '2';
  obj1.key = '2';
  obj2 = { key: '2' };
  obj3 = 'apple';
  arr1.push(2);
  arr2 = [1, 2];
  arr3 = 'apple';
}

mutateParameters(num, str, obj1, obj2, obj3, arr1, arr2, arr3);

console.log(num);
console.log(str);
console.log(obj1);
console.log(obj2);
console.log(obj3);
console.log(arr1);
console.log(arr2);
console.log(arr3);
1
"1"
Object { key: "2" }
Object { key: "1" }
Object { key: "1" }
Array [1, 2]
Array [1]
Array [1]

Primitive variables are not mutated. Similarly, object variables are not reassigned to a completely different type, like string. However, object variables can be mutated via their reference. Because it's a reference, object variables will always have accurate state, like in the example below:

const obj = { current: 1 };

const log = () => console.log(obj);

setTimeout(log, 1000);
log();

obj.current = 2;
Object { current: 1 }
Object { current: 2 }

This is how React refs work. The reason that React refs are objects with a current attribute is to achieve reference behavior in JavaScript.

Knowing this, we can revisit our original example. This time, we can use React refs for our state. By storing state in a reference, even a closure in a different parent scope will still be able to read the reference.

function Component() {
  const count = React.useRef<number>(5);

  function log() {
    console.log('Count:', count.current);
  }

  React.useEffect(() => {
    const timeout = setTimeout(log, 2000);
    const interval = setInterval(log, 3000);

    return () => {
      clearTimeout(timeout);
      clearInterval(interval);
    };
  }, []);

  function handleClick() {
    count.current += 1;
  }

  return (
    <div>
      <span>Count: {count.current}</span>
      <button onClick={handleClick}>Click here</button>
    </div>
  );
}

Reading and writing state to a ref is pretty boof though. First, using refs casually like this is generally considered an anti-pattern in React. Second, ref changes don't trigger rerenders, like how state changes do. To somewhat resolve these concerns, we can write a custom hook.

export default function useRefState<T>(
  initialState: T,
): [React.MutableRefObject<T>, React.Dispatch<React.SetStateAction<T>>] {
  const [internalState, setInternalState] = React.useState<T>(initialState);

  const state = React.useRef<T>(internalState);

  const setState = (newState: React.SetStateAction<T>) => {
    if (newState instanceof Function) {
      state.current = newState(state.current);
      setInternalState(newState);
    } else {
      state.current = newState;
      setInternalState(newState);
    }
  };

  return [state, setState];
}

This hook tries to match the interface and behavior of React.useState() as closely as possible. It uses internalState to force rerenders like useState(). The only functional difference is you must read the state through the current attribute.

function Component() {
  const [count, setCount] = useRefState<number>(5);

  function log() {
    console.log('Count:', count.current);
  }

  React.useEffect(() => {
    const timeout = setTimeout(log, 2000);
    const interval = setInterval(log, 3000);

    return () => {
      clearTimeout(timeout);
      clearInterval(interval);
    };
  }, []);

  function handleClick() {
    setCount(count.current + 1);
    // or setCount(count => count + 1);
  }

  return (
    <div>
      <span>Count: {count.current}</span>
      <button onClick={handleClick}>Click here</button>
    </div>
  );
}

You might be wondering if we could just define useRefState to return the current attribute itself, like return [state.current, setState];. Although in theory that sounds very nice, as we discussed though, that would break the nature of a React ref. Returning the raw value (state.current) instead of the referenceable object (state) would break the reference.

For a more elegant solution, keep reading.

Fix stale closures with references

In the same way we can reference our state, we can also reference entire functions. Remember, functions in JavaScript are variables too. Lets first build an abstract hook that enables us to run a parent function with a child function and other parameters that respect the original lexical scope they were defined in.

export function useClosure<T extends () => void, U extends any[], V>({
  child,
  parent,
  parentArgs,
  clearParent,
}: {
  child: T;
  parent: ((child: T, ...args: U) => V) | ((child: T) => V);
  parentArgs?: U;
  clearParent?: (arg: V) => void;
}) {
  const childRef = React.useRef<T>();
  const parentArgsRef = React.useRef<U>();

  React.useEffect(() => {
    childRef.current = child;
  }, [child]);

  React.useEffect(() => {
    parentArgsRef.current = parentArgs;
  }, [parentArgs]);

  React.useEffect(() => {
    const run = <T>(() => {
      childRef.current();
    });

    const parentId = parentArgsRef.current
      ? parent(run, ...parentArgsRef.current)
      : parent(run);

    return () => clearParent?.(parentId);
  }, [parent, clearParent]);
}

From here, we can build out setTimeout() and setInterval() equivalent hooks.

export function useTimeout(
  callback: () => void,
  msDelay?: number,
  ...callbackArgs: any[]
) {
  return useClosure({
    child: callback,
    parent: setTimeout,
    parentArgs: [msDelay, ...callbackArgs],
    clearParent: clearTimeout,
  });
}

export function useInterval(
  callback: () => void,
  msDelay?: number,
  ...callbackArgs: any[]
) {
  return useClosure({
    child: callback,
    parent: setInterval,
    parentArgs: [msDelay, ...callbackArgs],
    clearParent: clearInterval,
  });
}

We can now run scheduled functions like timeouts and intervals without having "frozen state".

function Component() {
  const [count, setCount] = React.useState<number>(5);

  function log() {
    console.log('Count:', count);
  }

  useTimeout(log, 2000);

  useInterval(log, 3000);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={handleClick}>Click here</button>
    </div>
  );
}

Compared to state being referenced, which requires a lot of overhead with current, these closure based hooks never expose the ref used. It makes for a much cleaner and declarative codebase.

In addition to scheduling functions like setTimeout() and setInterval(), the useClosure() hook can be used for any other function that is passed function arguments by value and subsequently freezes their state. Hopefully these tricks can be used to make React Hooks all the more predicable and enjoyable.

Comments

Be the first to add a comment!

Add Comment

Post