A Complete Guide to useEffect: Mastering React’s Effect Hook

You’ve built components using React Hooks, maybe even a small application, and you’re feeling relatively comfortable. The API makes sense, and you’ve picked up a few tricks. You might have even created custom Hooks to handle repetitive logic, impressing your colleagues.

However, sometimes using useEffect feels like something’s not quite right. It seems similar to class lifecycles, but is it really? Questions like these might arise:

  • 🤔 How can I replicate componentDidMount with useEffect?
  • 🤔 What’s the correct way to fetch data inside useEffect, and what does [] signify?
  • 🤔 Should functions be specified as effect dependencies?
  • 🤔 Why does an infinite refetching loop sometimes occur?
  • 🤔 Why do I occasionally get outdated state or prop values within my effect?

Many developers face these questions when starting with Hooks. This comprehensive guide aims to provide clear answers and a deeper understanding of useEffect. We’ll move past simple recipes and truly “grok” the Hook.

To truly understand useEffect, we need to change our perspective. It’s essential to stop viewing it through the lens of familiar class lifecycle methods. Only then will everything fall into place.

“Unlearn what you have learned.” — Yoda

Yoda, offering unconventional wisdom.

This guide assumes you have some familiarity with the useEffect API.

Understanding Renderings: Each Render Has Its Own Props and State

Before diving into effects, it’s essential to understand React’s rendering process.

Consider a simple counter component:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

The count variable isn’t magically “watching” for state changes. Instead, it’s simply a number.

During the initial render, count is 0. When setCount(1) is called, React re-renders the component. This time, count is 1, and so on.

// During first render
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// After a click, our function is called again
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

Each time the state updates, React calls the component again. Each render result sees its own count state value, which remains constant within that render.

This line simply embeds a number value into the render output. React provides this number, and when setCount is called, React re-renders the component with a different count value, updating the DOM accordingly.

Event Handlers: Capturing State at the Time of the Event

Now, let’s consider event handlers.

This example displays an alert with the count value three seconds after a button click:

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

If the counter is incremented to 3, the “Show alert” button is pressed, and then the counter is incremented to 5 before the timeout fires, the alert will display 3.

The alert “captures” the state at the time the button was clicked.

Each render provides its own “version” of handleAlertClick, each “remembering” its own count value:

// During first render
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
  <button onClick={handleAlertClick} />; // The one with 0 inside
  // ...
}

Event handlers “belong” to a particular render and use the counter state from that render.

Effects: Synchronization After Rendering

Effects are similar to event handlers in that they “see” the props and state from a specific render.

Consider this example from the React documentation:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

The effect reads the latest count state because the effect function itself is different on every render.

Each version “sees” the count value from its corresponding render:

// During first render
function Counter() {
  // ...
  useEffect(
    // Effect function from first render
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}

React runs the effect function after updating the DOM and allowing the browser to paint the screen.

Conceptually, effects are part of the render result, “belonging” to a specific render just like event handlers.

The Power of Closures: Each Render Has Its Own… Everything

Every function inside a component render, including event handlers, effects, timeouts, and API calls, captures the props and state of the render call that defined it.

Consider this code:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

If you click several times with a small delay, you’ll see a sequence of logs, each one with its own count value.

The source of the confusion in this example is the mutation (React mutates this.state in classes to point to the latest state) and not closures themselves.

Swimming Against the Tide: Accessing Future Props and State

It’s important to explicitly state that every function within a component render captures the props and state of that render call.

It doesn’t matter whether you read from props or state “early” inside your component; they won’t change within the scope of a single render.

However, sometimes you need to read the latest value inside a callback defined in an effect. You can achieve this using refs.

Cleaning Up: Undoing Effects

Some effects have a cleanup phase, designed to “undo” the effect, such as unsubscribing from subscriptions.

The previous effect is cleaned up after the re-render with new props:

  1. React renders UI with new data.
  2. The browser paints.
  3. React cleans up the previous effect.
  4. React runs the new effect.

The effect cleanup doesn’t read the “latest” props but rather the props that belong to the render in which it’s defined.

Synchronization, Not Lifecycle: React’s Mental Model

React synchronizes the DOM based on the current props and state. There’s no distinction between a “mount” or an “update” during rendering.

You should think of effects in a similar way. useEffect allows you to synchronize things outside the React tree with the props and state.

If you’re writing an effect that behaves differently based on whether the component renders for the first time, you’re working against React’s intended design.

Diffing Effects: Optimizing Performance

React only updates the parts of the DOM that have changed.

Similarly, you can use a dependency array to avoid re-running effects unnecessarily:

useEffect(() => {
  document.title = 'Hello, ' + name;
}, [name]); // Our deps

By providing a dependency array, you tell React that the effect only depends on the specified values. If those values haven’t changed since the last time the effect ran, React can skip re-running the effect.

Don’t Lie to React: Honesty About Dependencies

If you specify dependencies, all values from inside your component that are used by the effect must be included in the dependency array. This includes props, state, and functions.

Omitting dependencies can lead to bugs and unexpected behavior.

Being Honest: Two Strategies

There are two strategies for being honest about dependencies:

  1. Include all the values inside the component that are used inside the effect in the dependency array.
  2. Change your effect code so that it doesn’t need a value that changes more often than you want.

Start with the first strategy and apply the second if needed.

Self-Sufficiency: Removing Dependencies

Ask yourself: what are we using a particular value for? If you only use a value for the setState call, you don’t actually need it in the scope at all. When you want to update the state based on the previous state, you can use the functional updater form of setState.

Functional Updates: Google Docs and React

It helps to send only the minimal necessary information from inside the effects into a component. The updater form conveys strictly less information because it isn’t “tainted” by the current count. It only expresses the action.

Encoding the intent is similar to how Google Docs solves collaborative editing.

Actions: Decoupling Updates from What Happened

When setting a state variable depends on the current value of another state variable, you might want to try replacing them both with useReducer.

A reducer lets you decouple expressing the “actions” that happened in your component from how the state updates in response to them.

React guarantees that the dispatch function will be constant throughout the component’s lifetime.

Cheat Mode: The Power of useReducer

You can put the reducer itself inside your component to read props. Even in that case, the dispatch identity is still guaranteed to be stable between re-renders.

This allows you to decouple the update logic from describing what happened, removing unnecessary dependencies from your effects.

Moving Functions: Inside Effects

If you only use some functions inside an effect, move them directly into that effect.

By doing so, you no longer have to think about the “transitive dependencies.” Your dependencies array isn’t lying anymore: you truly aren’t using anything from the outer scope of the component in your effect.

Not Inside? Hoisting and useCallback

If you can’t move a function inside an effect, there are generally better solutions than skipping the function in the effect dependencies.

If a function doesn’t use anything from the component scope, you can hoist it outside the component and then freely use it inside your effects.

Alternatively, you can wrap it in the useCallback Hook. useCallback is essentially like adding another layer of dependency checks, making the function itself only change when necessary.

Functions: Part of the Data Flow?

With classes, function props by themselves aren’t truly a part of the data flow. Therefore, even when we only want a function, we have to pass a bunch of other data around to “diff” it.

With useCallback, functions can fully participate in the data flow.

Race Conditions: Preventing Errors

Effects don’t magically solve the problem of race conditions, in which asynchronous requests may complete in an unexpected order.

If your async approach supports cancellation, you can cancel the async request right in your cleanup function.

Alternatively, the easiest stopgap approach is to track it with a boolean:

function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();
    return () => {
      didCancel = true;
    };
  }, [id]);

  // ...
}

Raising the Bar: Synchronizing Effects

With the mindset of useEffect, things are synchronized by default, and side effects become part of the React data flow.

However, the upfront cost of getting it right is higher, as writing synchronization code that handles edge cases well is more difficult than firing one-off side effects that aren’t consistent with rendering.

In Closing: Mastering useEffect

By understanding the principles outlined in this guide, you can effectively use useEffect to manage side effects in your React applications. Embrace the synchronization mindset, and you’ll be well on your way to building more robust and predictable components.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *