Week 2: State & Effects — useState and useEffect

These two hooks show up in almost every component you'll ever write, and they're conceptually very different: useState is data your component owns and re-renders on; useEffect is how a component reaches outside itself — to the DOM, a timer, a subscription, or a network request. Confusing the two is where most early React bugs come from, so this week is entirely about building sharp intuition for both.

Module 2 of 13 Week 2 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Update state correctly, including when the new value depends on the old one
  • Write effects with correct dependency arrays and cleanup functions
  • Recognize and fix stale closures and effect-triggered infinite loops

1. useState in Depth

useState(initial) returns a pair: the current value, and a setter function that schedules a re-render with a new value. The part that's easy to miss from Week 1: state updates are asynchronous and can be batched — calling the setter doesn't change the variable immediately in the same line of code.

stale-read.tsx
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // still the OLD value -- setCount hasn't re-rendered yet
  }

  return <button onClick={handleClick}>{count}</button>;
}

This gets genuinely dangerous when you call the setter more than once based on the same stale count:

the bug
function handleClick() {
  setCount(count + 1); // count is 0 here -> sets to 1
  setCount(count + 1); // count is STILL 0 here -> sets to 1 again, not 2
}

The fix is the functional update form: pass a function to the setter, and React guarantees it receives the most recent state, even across multiple queued updates in the same event:

the fix
function handleClick() {
  setCount(prev => prev + 1); // 0 -> 1
  setCount(prev => prev + 1); // 1 -> 2, correctly
}
Rule of thumb

If your new state depends on the previous state in any way, use the functional form: setX(prev => ...). If it's a completely new value unrelated to the old one, setX(newValue) is perfectly fine.

State is also treated as immutable — never mutate an object or array in place and pass it back to the setter; React compares state by reference, so a mutated-in-place object looks identical to React and won't trigger a re-render.

immutable updates
const [user, setUser] = useState({ name: 'Ada', age: 30 });

// Wrong -- mutates the existing object, React sees the same reference
user.age = 31;
setUser(user);

// Right -- creates a new object, React sees a new reference and re-renders
setUser({ ...user, age: 31 });

const [items, setItems] = useState([1, 2, 3]);

// Wrong
items.push(4);
setItems(items);

// Right
setItems([...items, 4]);

2. useEffect: Synchronizing with the Outside World

Everything inside a component's function body should be about computing what to render. The moment you need to touch something outside React — the browser DOM directly, document.title, a timer, a WebSocket, or (starting Week 8) a network request — that's an effect.

DocumentTitle.tsx
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.title = `Clicked ${count} times`;
  }, [count]);

  return <button onClick={() => setCount(c => c + 1)}>Click me</button>;
}

useEffect takes a function to run after React has committed the render to the DOM, plus a second argument — the dependency array — that controls when it re-runs. Here, the effect re-runs only when count changes, keeping the document title in sync without running on every unrelated render.

Ask "is this a side effect?" before reaching for useEffect

Computing a value from props/state (like a filtered list, or a total) belongs directly in the render body, not in an effect — see Week 3's useMemo for when that computation is expensive enough to matter. useEffect is specifically for synchronizing with something React doesn't manage.

3. Dependency Arrays — What They Actually Compare

The dependency array tells React: "re-run this effect only if one of these values changed since the last render." React compares each entry with Object.is — the same reference-equality check state updates use. Three shapes, three very different behaviors:

the three shapes
useEffect(() => {
  console.log('runs after EVERY render');
}); // no array at all

useEffect(() => {
  console.log('runs once, after the first render only');
}, []); // empty array -- nothing to depend on

useEffect(() => {
  console.log('runs after the first render, and again whenever userId changes');
}, [userId]); // runs when a listed value changes

A common trap: including an object or array literal created fresh on every render as a dependency. Because it's a new reference every time, the effect thinks something changed and re-runs on every single render — even if the object's contents look identical.

the trap
function SearchResults({ query }: { query: string }) {
  const options = { caseSensitive: false }; // NEW object every render

  useEffect(() => {
    search(query, options);
  }, [query, options]); // options never "equals" its previous value -> runs every render
}

The fix here is usually to depend only on the primitive values the effect actually reads (query, options.caseSensitive), or to move the object construction inside the effect itself where it isn't a dependency at all.

4. Cleanup Functions

If an effect sets something up — a timer, an event listener, a subscription — it needs to tear that thing down, or you'll leak it every time the effect re-runs or the component unmounts. Return a function from the effect, and React calls it right before the effect runs again, and once more when the component unmounts.

Timer.tsx
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(id); // cleanup -- runs on unmount
  }, []); // empty array -- set up the interval once

  return <p>{seconds}s elapsed</p>;
}

Without that clearInterval, navigating away from this component would leave the interval running forever in the background, still calling setSeconds on a component that no longer exists — exactly the kind of leak that's invisible in a five-minute demo and very visible in a real app after an hour of use.

event listener cleanup
useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

5. Common Pitfalls: Stale Closures & Infinite Loops

A stale closure happens when an effect (or any function) captures a variable's value at the time it was created, and that value never gets refreshed because the effect wasn't told to re-run:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // always logs 0 -- this closure captured count=0 forever
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty array -- effect never re-runs, so `count` inside it never updates

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

The interval callback closed over count from the render where the effect first ran, and because the dependency array is empty, that effect (and its closure) is never recreated. Two fixes: add count to the dependency array (the interval resets every time count changes), or use the functional update form inside a ref-based pattern if you specifically need the interval to keep running uninterrupted — the first option is almost always what you want.

The other classic bug is the infinite loop: an effect that updates state it also depends on, without a condition that eventually stops it.

the bug
function BadCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // triggers a re-render...
  }, [count]); // ...which changes count...which reruns the effect...forever

  return <p>{count}</p>;
}

Every render sets state, every state change re-runs the effect, every effect run sets state again. React doesn't stop this for you — it'll happily re-render as fast as it can until the tab hangs. If an effect sets state, double check whether it truly needs to depend on that same state, or whether the update belongs somewhere else entirely (an event handler, or removed altogether if it can be computed directly during render).

6. Hands-on Exercise

Hands-on

Build a stopwatch with lap times, then fix an injected race condition

Practice effects, cleanup, and functional state updates by building something that would break immediately if you got any of them wrong.

Part 1 — Stopwatch:

  1. Build a Stopwatch component with elapsedMs state, and Start/Pause/Reset buttons.
  2. Use setInterval inside a useEffect that only runs while the stopwatch is "running" (make the effect depend on a isRunning boolean), updating elapsedMs with the functional setter form every 10ms.
  3. Properly clean up the interval whenever isRunning flips to false or the component unmounts — no leaked intervals.
  4. Add a "Lap" button that appends the current elapsedMs to a laps: number[] state array, rendered as a list, most recent first.

Part 2 — Fix the race condition:

UserProfile.tsx — has a bug
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    fetchUser(userId).then(data => setUser(data)); // BUG: no cleanup
  }, [userId]);

  return <p>{user?.name ?? 'Loading...'}</p>;
}

If userId changes quickly (e.g. clicking through a list fast), an older, slower request can resolve after a newer one and overwrite it with stale data. Add a cleanup-based "ignore" flag so a request only calls setUser if its effect is still the current one when it resolves.

Hint

Declare let ignore = false; at the top of the effect, set ignore = true in the cleanup function, and check if (!ignore) before calling setUser inside the .then(). You'll see this exact pattern generalized by a library in Week 8, so it's worth understanding by hand first.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does calling setCount(count + 1) twice in a row not add 2 to the count?

Both calls close over the same stale count value from the current render — neither call sees the effect of the other, since state doesn't update until the next render. Using the functional form, setCount(prev => prev + 1), fixes it because React guarantees each functional update sees the result of the previous queued update.

Q2

What's the practical difference between an empty dependency array [] and no array at all?

[] tells React there's nothing to compare, so the effect runs exactly once, after the first render. Omitting the array entirely means React has nothing to check against at all, so the effect runs after every single render — almost never what you actually want.

Q3

What does the function returned from a useEffect callback do, and when does React call it?

It's the cleanup function, used to undo whatever the effect set up (clear a timer, remove a listener, cancel a subscription). React calls it right before running the effect again on a subsequent render, and one final time when the component unmounts — without it, anything the effect set up keeps running or listening forever, even after the component is gone.

Q4

Why does an effect that calls setCount(count + 1) while depending on [count] cause an infinite loop?

Each run of the effect changes count, which is exactly what the dependency array is watching, so the changed value re-triggers the same effect — which changes count again, forever. React has no built-in guard against this; breaking the cycle requires either removing the dependency, adding a stopping condition, or moving the update to something that isn't itself watching the value it changes.