Skip to content

The useEffect Hook

useState stores data inside a component. useEffect lets a component reach outside itself — to save data, start a timer, subscribe to an event, or fetch from an API. These are side effects: actions that affect something other than what the component renders.

import { useEffect } from 'react';
useEffect(() => {
// runs after every render by default
document.title = `ZeroBudget — ${month}`;
});

React runs the callback after the component renders and after every subsequent re-render. Putting everything in here without a dependency array re-runs on every render — usually not what you want.

The second argument is the dependency array. React re-runs the effect only when the values in that array change:

useEffect(() => {
document.title = `ZeroBudget — ${month}`;
}, [month]); // re-runs only when month changes

Empty array — runs once, after the first render only:

useEffect(() => {
console.log('Component mounted');
}, []);

Specific values — runs after the first render and whenever those values change:

useEffect(() => {
localStorage.setItem('zb-categories', JSON.stringify(categories));
}, [categories]);

No array — runs after every render. Rarely the right choice.

Include every reactive value the effect reads from. If your effect uses categories, categories goes in the array. The React linter (eslint-plugin-react-hooks) enforces this automatically.

Missing a dependency causes stale closure bugs: the effect runs with an outdated copy of the value from when the component last re-rendered with that dependency included.

Some effects need to clean up after themselves — clearing a timer, canceling a fetch, removing an event listener. Return a cleanup function from the effect:

useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id); // cleanup when the component unmounts or before the effect re-runs
}, []);

React calls the cleanup function before running the effect again (due to a dependency change) and when the component unmounts.

useEffect is for synchronizing with external systems. Do not use it to:

  • Transform data for rendering — compute that during render instead (derived state)
  • Update state based on other state — compute the value directly or use event handlers
  • Respond to user events — use event handlers, not effects

If you find yourself writing useEffect(() => { setX(computeFromY(y)); }, [y]), you probably want const x = computeFromY(y) as a regular variable during render.

  1. Add a useEffect to App.jsx that logs 'Render' to the console on every render. Observe when it fires.
  2. Change it to a [] dependency array. Confirm it now only fires once.
  3. Add a useEffect that saves the incomeSources array to localStorage under the key 'zb-income' whenever incomeSources changes.
  4. Reload the page and confirm the value is in localStorage (check the Application tab in DevTools).
  • useEffect runs side effects after renders — saving to storage, timers, subscriptions, API calls.
  • The dependency array controls when it re-runs: empty for once-only, specific values to watch for changes.
  • Always include every reactive value the effect reads — missing dependencies cause stale closure bugs.
  • Return a cleanup function to cancel timers, abort fetches, or remove event listeners.
  • Do not use useEffect to derive values for rendering — compute them directly during render.