0 / 91
Week 9 · Day 61 of 91

Effects and external synchronization

React Frontend Fundamentals

Objective

Use effects only for synchronization with external systems.

A React component is a reusable functional block with inputs (props), internal state, and rendered output.

  • render versus effect
  • dependencies
  • cleanup

Why this matters

Everything so far has been self-contained: data lives in a file, the user changes it, React redraws. Real screens get their data from somewhere else. Today you learn useEffect, the one hook that reaches outside React — and, just as importantly, the rules that stop it becoming the tool people reach for by reflex and regret.

By the end of the hour your equipment list loads from a URL and handles all four things that can happen: still loading, loaded with data, loaded with nothing, and failed.

Render versus effect

A component's job during render is to compute UI from props and state and return it. That computation must be pure: same inputs, same output, no observable side effects. React relies on this — it may call your function more than once, and it decides when.

So these are not allowed during render:

function EquipmentList() {
  fetch("/equipment.json");          // wrong: fires on every render
  document.title = "Equipment";      // wrong: touches something outside React
  setCount(count + 1);               // wrong: infinite loop
  return <ul>...</ul>;
}

Most side effects belong in event handlers — the user clicked, so send the request. That covers more cases than beginners expect. But some work is not caused by any event; it is caused by the component being on screen at all. "While this list is displayed, it should show what the server has." That is synchronization with an external system, and it is what effects are for.

An external system means anything React does not control: the network, document.title, a setInterval timer, localStorage, a browser API, a non-React library.

useEffect(() => {
  document.title = `${items.length} items`;
}, [items.length]);

React runs that function after it has rendered and committed the result to the DOM. Two arguments: the setup function, and the dependency array.

Combinational logic and the output driver

Render is combinational logic: inputs in, outputs out, no memory of being evaluated and safe to evaluate as often as you like. An effect is the output driver stage that actually energizes something external — a relay, a display, a comms link — once the logic has settled. You do not put the relay coil in the middle of the logic. You settle the logic first, then drive the output.

Dependencies

The second argument tells React when to run the setup again. There are three cases, and they are easy to mix up:

You write React runs the effect
useEffect(fn) after every render
useEffect(fn, []) after the first render only
useEffect(fn, [a, b]) after the first render, and whenever a or b changed

"Changed" means React compared the value with the previous render's using Object.is — the same comparison as state. Which is why a dependency that is a new object or array literal every render counts as changed every render, and turns [options] into no dependency array at all.

The dependency list is not a preference; it is a claim that the effect uses exactly these values from the component. If you read equipmentId inside the effect, it must be in the list, or your effect will keep using an old id after the prop changes. React's ESLint rule for hooks checks this for you — do not silence it, fix the code.

The infinite loop

An effect that sets state which is also in its own dependency list re-triggers itself forever: render → effect → set state → render → effect. The same happens with useEffect(fn) and no array if fn sets state. Symptoms: the dev server pegs a CPU core and the tab freezes. Fix it by narrowing the dependencies, or by asking whether the value should be state at all — most of these are Day 59's derived values in disguise.

Cleanup

An effect can return a function. React calls it before running the setup again, and once more when the component is removed from the screen. That is cleanup, and it is what makes effects safe to repeat.

useEffect(() => {
  const id = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(id);
}, []);

Without the clearInterval, every mount leaves a timer running forever. The pattern is universal: whatever the setup starts — a timer, a subscription, a socket, a listener — the cleanup stops.

React helps you notice a missing cleanup. In development, <StrictMode> (which the Vite template put in main.tsx on Day 57) deliberately mounts each component, unmounts it, and mounts it again. So you will see effects run twice and requests fire twice in the console. That is not a bug and it does not happen in production builds; it is React proving that your effect survives being run twice. If it does not, you have a real bug that would have shown up later.

Leaving the tap running

Every time you open the tap you must close it. If the setup runs again while the first tap is still open, water goes everywhere and nobody can tell which tap is responsible. Cleanup is closing the tap you personally opened, before anyone opens another.

Stale responses

Network requests come back out of order. Suppose an effect refetches whenever equipmentId changes: the user clicks item 1, then quickly item 2. Request 2 may answer first, then request 1 arrives and overwrites the screen with the wrong item's data. Nothing threw an error; the UI is just wrong.

The fix uses cleanup. A local flag says "this effect run is no longer current", and cleanup sets it:

useEffect(() => {
  let ignore = false;

  async function load() {
    const data = await getEquipment(equipmentId);
    if (!ignore) setItems(data);
  }
  load();

  return () => { ignore = true; };
}, [equipmentId]);

When equipmentId changes, React runs the cleanup for the old run first, setting its ignore to true. That run's response is then discarded whenever it lands. Each effect run has its own ignore variable, which is why this works.

Note the shape: the effect's setup function itself is not async. It must return either nothing or a cleanup function, and an async function always returns a promise. So you declare an async function inside and call it.

Walkthrough

Give yourself something to fetch. Create public/equipment.json in your project — Vite serves everything in public/ from the site root, so it is reachable at /equipment.json:

[
  { "id": "PUMP-3", "name": "Coolant pump 3", "location": "Bay A", "status": "needs-service" },
  { "id": "MTR-1", "name": "Conveyor motor 1", "location": "Bay B", "status": "ok" }
]

Now load it in src/components/EquipmentScreen.tsx:

import { useEffect, useState } from "react";
import type { Equipment } from "../types";

export function EquipmentScreen() {
  const [items, setItems] = useState<Equipment[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;

    async function load() {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch("/equipment.json");
        if (!response.ok) {
          throw new Error(`Request failed with status ${response.status}`);
        }
        const data: Equipment[] = await response.json();
        if (!ignore) setItems(data);
      } catch (caught) {
        if (!ignore) setError(caught instanceof Error ? caught.message : "Unknown error");
      } finally {
        if (!ignore) setIsLoading(false);
      }
    }

    load();
    return () => { ignore = true; };
  }, []);

  if (isLoading) return <p role="status">Loading equipment…</p>;
  if (error) return <EmptyState message={`Could not load equipment. ${error}`} />;
  if (items.length === 0) return <EmptyState message="No equipment recorded yet." />;
  return <EquipmentList items={items} />;
}

Three details that are easy to get wrong. fetch only rejects when the request could not be made at all — a 404 or a 500 arrives as a perfectly successful promise, which is why you must check response.ok yourself (Day 36's status codes, still true). caught instanceof Error is Day 32 narrowing: a caught value is typed unknown, so you cannot read .message off it directly. And role="status" makes the loading line an announcement, so a screen reader user hears that something is happening.

Now break it on purpose: change the URL to /equipmnt.json and reload. Vite's dev server answers a missing path with the app's HTML, so response.ok is true and response.json() throws while parsing. You will see a message like:

Could not load equipment. Unexpected token '<', "<!doctype "... is not valid JSON

That is a real, useful failure surface — the user sees a sentence instead of a blank screen.

Checkpoint

With DevTools open on the Network tab (Day 6), reload and watch one request for equipment.json. In development you may see it twice because of StrictMode. Say out loud why that is expected and why it will not happen in the production build.

Your turn

Deliverable: a fetch flow that does not loop and cancels or ignores stale work appropriately.

  1. Create public/equipment.json with at least four items matching your Equipment type.
  2. Build EquipmentScreen as in the walkthrough, and render it from App.tsx.
  3. Confirm all four states by forcing each one:
    • loading — in DevTools Network, set throttling to "Slow 3G" and reload;
    • success — normal reload;
    • empty — temporarily change the JSON file to [];
    • error — temporarily point fetch at a wrong path. Record what the screen said in each case.
  4. Add a "Reload" button that re-runs the fetch. Do this by adding a const [reloadKey, setReloadKey] = useState(0), putting reloadKey in the dependency array, and having the button call setReloadKey((n) => n + 1).
  5. Prove the dependency rule: temporarily remove reloadKey from the array and confirm the button stops working. Put it back.
  6. Audit every useEffect you now have. For each, write one sentence naming the external system it synchronizes with. Any effect that cannot answer should be a derived value or an event handler instead.

You are done when

All four states are reachable and readable, the Network tab shows one request per load rather than a stream of them, and every effect can name its external system.

Reviewer mode — after all four states work

Today's mode is reviewer: bring working code and ask for specific, actionable findings backed by evidence from your files — not general praise, and not a complete rewrite.

"Review every useEffect. Ask whether it synchronizes with an external system or hides derived state."

Check each finding against the code yourself before touching anything. An applied review you did not judge is just a rewrite you did not read.

Common pitfalls

  • Making the effect function async. useEffect(async () => {...}) returns a promise where React expects a cleanup function. Declare an inner async function and call it.
  • Assuming fetch throws on 404. It resolves. Without a response.ok check you try to parse an error page as JSON and get a confusing syntax error instead of a status.
  • Fetching in an effect with no dependency array. Every response sets state, which renders, which fetches again. The tab locks up.
  • Blaming StrictMode. Doubled effects in development are intentional. If doubling breaks something, the cleanup is missing — fix that rather than removing StrictMode.

Verify it yourself

Open today's reference, React's Learn section, and read Synchronizing with Effects and You Might Not Need an Effect.

  1. You Might Not Need an Effect lists cases where people use an effect wrongly. Find two, and write what to do instead. At least one should already apply to code you wrote this week.
  2. Find React's own explanation of why development remounts components. Compare its wording with this lesson's claim that it is there to expose missing cleanup.

Write both into notes/day-61.md. Effects are the hook that most often gets overused; knowing when not to reach for one is the actual skill.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Load equipment from a mock endpoint and handle loading, success, empty, and error states.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A fetch flow that does not loop and cancels or ignores stale work appropriately.

Working with AI today

AI as skeptical reviewer

Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.

Review every useEffect. Ask whether it synchronizes with an external system or hides derived state.

References

End-of-day quiz

Q1 What is a good use for an effect?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.