Łukasz Makuch

Łukasz Makuch

React hooks... Oops! Part 3 - an effect doesn't run again when its dependencies change

Cogs

In the previous post from the React hooks... Oops! series we talked about a case where an effect is re-applied, even though the dependencies are still "the same". We expected it to run again only if the dependencies changed, but it ended up running multiple times with the same values. Fortunately, it was enough to learn a little bit about how dependencies are compared, and it became clear what made React re-apply it. 🥳

If you're reading this post, you might have already discovered that the array of dependencies is tricky. It seems to be pretty simple on the surface. After all, it just tells React to "re-run this function when one of these values change", right? Well, that's not exactly what happens. The truth is that changing a value in the dependency list is not enough for React to re-run the effect!

How come? 😰

Let's have a look at a very simple example, where everything works as expected:

function App() {
  // Just some local state:
  const [typed, setTyped] = useState("");
  
  // An effect synchronized with that local state:
  useEffect(
    () => console.log("an effect of", typed),
    [typed]
  );

  // The view:
  return (
    <div>
      You typed: {typed}
      <br />
      <input 
        type="text"
        value={typed}
        onChange={(e) => setTyped(e.target.value)}
      />
    </div>
  );
}

A proper synchronization

Looking at this component, it's tempting to say that the () => console.log("an effect of", typed) fragment re-runs when typed changes. But that's a simplification.

Why? 🤔

Before I present you with an example that illustrates the problem, I'd like to show you an alternative version that happens to work, even though there's already a bug waiting to surface.

Here we still use state to control the input field, but the effect is synchronized with a value read directly from a reference to the DOM element.

function App() {
  // Nothing changed here,
  // it's still just some local state:
  const [typed, setTyped] = useState("");

  // A ref to the input element:
  const inputRef = useRef();
  // The value read directly from the DOM node:
  const inputValue = inputRef.current
    ? inputRef.current.value 
    : "";
  // An effect synchronized with the value
  // read from the DOM node:
  useEffect(
    () => console.log("an effect of", inputValue),
    [inputValue]
  );

  return (
    <div>
      You typed: {typed}
      <br />
      <input
        type="text"
        {/* The value of the input comes from the state: */}
        value={typed}
        {/* Typing updates the state: */}
        onChange={e => setTyped(e.target.value)}
        {/* We also set the ref: */}
        ref={inputRef}
      />
    </div>
  );
}

The same result

Everything "works", but that's just a happy accident!

What's the problem with this code?

Imagine we don't want to that funny You typed: ... message. It seems like we can just remove it together with the state used to hold its value, right?

function App() {
  // This fragment stays the same:
  const inputRef = useRef();
  const inputValue = inputRef.current ? inputRef.current.value : "";
  useEffect(
    () => console.log("an effect of", inputValue),
    [inputValue]
  );

  // No more local state:
  return <input type="text" ref={inputRef} />;
}

The effect is not synchronized

Whoa, the effect doesn't work anymore, even though we haven't changed any code that'd be directly related to it! 😲

Why did it work before, but doesn't work now? It's because putting values in the dependency list doesn't turn them into any sort of observables. React won't be notified when they get mutated. Thus even when the value changes, the effect is not re-applied. Previously it worked only because the component was re-rendered due to the local state update.

To make it even more clear, let's try to bring back the You typed: ... message, but this time it won't be done correctly:

function App() {
  // This fragment stays the same:
  const inputRef = useRef();
  const inputValue = inputRef.current ? inputRef.current.value : "";
  useEffect(
    () => console.log("an effect of", inputValue), 
    [inputValue]
  );

  return (
    <div>
      {/* We try to render the current value of the input: */}
      You typed: {inputValue}
      <br />
      <input type="text" ref={inputRef} />
    </div>
  );
}

Nothing is synchronized

Nothing works. It's now clear that React is unaware of changes that happen outside of its lifecycle and neither the DOM nor the effect is synchronized with them.

Of course, the example shown above is rather simple. In real life, bugs like that may span over more lines. Judging from my experience, they tend to arise around refs and other mutable bags of data that make it easy to forget that if we want React to react to a change, that change must happen within React.

You may find the snippets used in the writing of this post here:

That's all for today, I hope you enjoyed it! 🙂

Stay tuned for the next post!