EduFlow
React Fundamentals
0%

Section 3 · State & Events

useEffect Essentials

19 min4 min readCode playground

Lesson at a glance

Use useEffect to keep React state in sync with systems outside React, write correct dependencies and cleanups, and recognise the many cases where you don’t need an Effect at all.

  1. 1Your first Effect
  2. 2Cleanup
  3. 3Saving the Watchlist to localStorage
  4. 4You might not need an Effect

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

So far, everything in your components has been about React: props in, JSX out, state changes triggering renders. But the Watchlist also needs to touch things React doesn’t manage. The browser tab should say “2 to watch”, and the list should survive a page refresh by being saved to localStorage. Neither of those is rendering. They’re synchronization: keeping something outside React in step with your state. That’s what Effects are for.

Your first Effect

src/App.jsx
import { useEffect, useState } from 'react';

export default function App() {
  const [movies, setMovies] = useState(INITIAL_MOVIES);
  const left = movies.filter((m) => !m.watched).length;

  useEffect(() => {
    document.title = `${left} to watch · Watchlist`;
  }, [left]);

  // …render the app
}

useEffect takes a function and a dependency array. React renders your component, updates the screen, and then runs the function. After later renders, it runs the function again only if something in the dependency array changed since last time. Mark a movie watched, left goes from 2 to 1, and the title updates. Type in the add form, left doesn’t change, and the Effect is skipped.

The dependency array has three forms:

You write The Effect runs
useEffect(fn, [a, b]) After the first render and whenever a or b changes
useEffect(fn, []) After the first render only
useEffect(fn) After every render

The rule for what goes in the array is not a choice: list every value from the component that the Effect reads, such as props, state, and variables derived from them. The exhaustive-deps lint rule in your Vite project (react/exhaustive-deps in Oxlint, react-hooks/exhaustive-deps in ESLint) checks this for you. If you leave something out, the Effect keeps using an old value, which is the bug in the third quiz question.

Cleanup

Some Effects start something that must be stopped: a timer, an event listener, a connection. Return a function that undoes it:

JSX
useEffect(() => {
  function handleKeyDown(e) {
    if (e.key === '/') focusSearch();
  }
  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, []);

React calls the cleanup before running the Effect again and when the component is removed from the screen. Without it, every mount would add another listener, and pressing “/” would fire several times.

After the first render and commit, the Effect runs. When a dependency changes, React re-renders, runs the previous cleanup, then runs the Effect again. When the component unmounts, React runs the cleanup one last time. Render + commit Effect Deps changed: re-render Cleanup, then Effect again Unmount: cleanup repeats on each change
An Effect’s lifecycle

Why it runs twice in development

With <StrictMode> (which your main.jsx has), React does something odd in development: it mounts your component, runs the Effects, immediately runs their cleanups as if unmounting, and then runs the Effects again. It’s a stress test. If your cleanup correctly undoes the Effect, the app behaves the same, and you see nothing but an extra log line. If it doesn’t, for example you forgot to remove a listener, you’ll notice now instead of in production. Don’t try to prevent the double run; fix the cleanup.

Saving the Watchlist to localStorage

Persisting state is the textbook Effect: whenever movies changes, write it to storage.

src/App.jsx
const STORAGE_KEY = 'watchlist.movies';

function loadMovies() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
  } catch {
    return [];
  }
}

export default function App() {
  const [movies, setMovies] = useState(loadMovies);

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(movies));
  }, [movies]);

  // …
}

Two details matter. Loading happens in the initializer, useState(loadMovies), not in an Effect. Passing the function (not calling it) means React reads storage only for the first render, not on every render, so the saved list appears immediately. Loading in an Effect would render an empty list first, and the save Effect could overwrite your stored movies with that empty array. And loadMovies uses try/catch, because storage can be blocked or contain corrupted data, and a broken value shouldn’t crash the whole app.

You might not need an Effect

Effects are an escape hatch, and the most common Effect bugs come from using one where it isn’t needed. Before writing one, check these three cases.

Calculating something from props or state. Filtered lists, counts and formatted strings are calculated during render. Storing them in state and syncing with an Effect adds an extra render with stale data and more state that can drift.

Responding to a user action. If code should run because the user clicked Add, put it in the submit handler. An Effect that watches movies can’t tell an add from a remove or a reload.

Resetting state when a prop changes. Instead of an Effect that clears a draft when movieId changes, give the component a key: <NotesEditor key={movieId} />. A new key means a fresh component with fresh state.

What’s left are true synchronizations: browser APIs, storage, timers, subscriptions and network connections. That’s a short list, and it’s the right one.

That completes the core of React: components, props, state, events and Effects. In the next section you’ll put every piece together into the finished Watchlist, style it, and ship it.

Key takeaways

  • An Effect runs after React updates the screen and is for syncing with something outside React, such as the document title, localStorage, timers or subscriptions.
  • The dependency array lists every value from the component the Effect reads; React re-runs the Effect when one of them changes.
  • Return a cleanup function to undo what the Effect did; React runs it before the next run and on unmount.
  • In development, StrictMode runs every Effect, its cleanup, and the Effect again, to prove the cleanup works.
  • If a value can be calculated from props or state, or a change is caused by a specific user action, you don’t need an Effect.

Further reading

Check your understanding

4 questions

Question 1 of 4

visibleMovies is kept in state and updated with useEffect(() => setVisibleMovies(movies.filter(…)), [movies, filter]). What’s the better approach?

Question 2 of 4

In development, an Effect that logs “subscribed” and has a cleanup that logs “unsubscribed” prints “subscribed, unsubscribed, subscribed” on page load. What’s going on?

Question 3 of 4

What’s wrong with this Effect?

JSX
useEffect(() => {
  document.title = `${left} to watch`;
}, []);
Question 4 of 4

After adding a movie, you want to show a “Movie added” message. Where should the code that shows it go?

Practice

Code playground

Sync the tab title

Two jobs in Watchlist:

  1. Remove the unnecessary Effect. left is copied into state by an Effect. Calculate it during render instead.
  2. Sync the browser tab title. While the Watchlist is on screen, document.title should read 2 to watch · Watchlist (with the real number), and update when a movie is marked watched. When the Watchlist is removed (click Hide watchlist), the title must go back to what it was before, Playground.
import { useEffect, useState } from 'react';

// The page's own title, before the Watchlist changes it.
document.title = 'Playground';

const INITIAL = [
  { id: 'a1', title: 'Arrival', watched: true },
  { id: 'p2', title: 'Past Lives', watched: false },
  { id: 'd3', title: 'Dune: Part Two', watched: false },
];

function Watchlist() {
  const [movies, setMovies] = useState(INITIAL);
  const [left, setLeft] = useState(0);

  // 1. This Effect only copies derived data into state. Replace it.
  useEffect(() => {
    setLeft(movies.filter((m) => !m.watched).length);
  }, [movies]);

  // 2. Sync document.title, and restore the old title on cleanup.

  function toggle(id) {
    setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));
  }

  return (
    <section>
      <p id="left">{left} to watch</p>
      <ul>
        {movies.map((m) => (
          <li key={m.id}>
            {m.title}
            <button onClick={() => toggle(m.id)}>{m.watched ? 'Watched' : 'Mark watched'}</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default function App() {
  const [show, setShow] = useState(true);
  return (
    <main>
      <button id="hide" onClick={() => setShow(false)}>Hide watchlist</button>
      {show && <Watchlist />}
    </main>
  );
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The page shows “2 to watch”
  • The tab title is “2 to watch · Watchlist”
  • Marking Past Lives watched updates the title to “1 to watch · Watchlist”
  • Hiding the Watchlist restores the title “Playground”