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.
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
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:
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.
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.
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.