useEffect Is Not a Lifecycle Hook: The Synchronization Model

Every mental model based on componentDidMount, componentDidUpdate, and componentWillUnmount leads to bugs with useEffect. The correct model is synchronization — effect runs to sync your component with the outside world.

June 7, 20263 min read1 / 3

useEffect is for synchronizing your component with the outside world — fetch calls, timers, subscriptions, analytics events, anything that lives outside React's rendering model.

The most common beginner use case is fetching data when a page loads:

TSX
useEffect(() => { fetch('https://jsonplaceholder.typicode.com/posts') .then(res => res.json()) .then(data => setPosts(data)); }, []);

The hook takes two arguments: a callback containing the code to run, and a dependency array that controls when it runs.

One important thing to know upfront: in development with React Strict Mode enabled, effects run twice. React intentionally mounts, unmounts, and remounts every component to help surface cleanup bugs. In production, effects run once. This is expected behavior — not a bug in your code.

The Dependency Array: What Controls When an Effect Runs

The dependency array is what most developers get wrong. It has three distinct modes.

Empty array [] — the effect runs once, after the first render, and never again. This is the right choice for data fetching on mount, setting up a one-time subscription, or anything that should happen exactly once.

Array with values [stateA, stateB] — the effect re-runs whenever any of those values change between renders. React compares each value from the previous render to the current one, and if anything changed, the effect fires again.

No array at all — the effect runs after every single render, regardless of what changed. This is almost never what you want. Without a dependency array, a fetch inside a useEffect would re-fire every time any state in the component updates — creating an infinite loop if the fetch itself sets state.

TSX
// Runs once on mount useEffect(() => { fetchPosts(); }, []); // Runs whenever showPanel changes useEffect(() => { console.log('panel toggled'); }, [showPanel]); // Runs after every render — almost always wrong useEffect(() => { doSomething(); });

The Dependency Array Is a Correctness Contract

A common instinct is to treat the dependency array as a performance tool — include fewer deps to run less often. That is the wrong mental model and it creates bugs.

The dependency array is a correctness contract. Whatever values your effect reads from the component scope must be in it. Leave one out and the effect will run against a stale snapshot of that value from a previous render — the variable looks current but holds an old value.

The react-hooks/exhaustive-deps ESLint rule exists to catch exactly this. When it flags a missing dependency, the right fix is to add it, not suppress the warning.

Fetching Data and Displaying It

The most practical early use of useEffect is populating a list from an API on mount:

TSX
const [posts, setPosts] = useState([]); useEffect(() => { fetch('https://jsonplaceholder.typicode.com/posts') .then(res => res.json()) .then(data => setPosts(data)); }, []); return ( <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> );

The empty dependency array ensures this fetch runs exactly once — when the component first mounts. When the data arrives, setPosts triggers a re-render and the list populates.

This pattern works for getting started. As your app grows, tools like TanStack Query handle caching, deduplication, background refetching, and error states — things a raw useEffect fetch does not.

Running an Effect When Specific State Changes

To run code every time a particular state changes, put that state in the dependency array:

TSX
const [query, setQuery] = useState(''); useEffect(() => { console.log('query changed:', query); }, [query]);

This effect fires after mount (with the initial value) and after every subsequent render where query is different. It will not fire if other state in the component changes and query stays the same.

This is the reactive model. You declare what your effect depends on, and React ensures it runs whenever those dependencies change.

Practice what you just read.

Dependency Array QuizSpot the Infinite LoopAdd the Cleanup
3 exercises