Skip to content

    Frontend System Design

    Purity, Strict Mode, Refs and Escape Hatches

    Learn why React components must be pure, what Strict Mode's second render and Effect cycle reveal, and when a value belongs in a ref instead of state.

    By Published 9 min read

    • frontend-system-design
    • react
    • strict-mode
    • refs
    Share

    Focused lessonintermediateReact labFrontend System Design

    Before this: Effects, Cleanup and Synchronizing With the Outside World

    What you'll learn: By the end of this lesson you will be able to state what "pure" means for a React component, explain what Strict Mode does in development and why, recognise the four commonest impurities (mutating props, writing to outer variables, side effects in updaters, Effects without cleanup), choose between state and a ref for a value, and use refs for DOM nodes without fighting React.

    A good recipe card gives the same dish every time. A bad card says "use the last jar of sauce and cross it off the pantry list": cook from it twice and the second dish has no sauce.

    React treats your components like the good card. Rendering calculates what the screen should look like from props, state and context, and React assumes that calculation can be repeated, paused, thrown away or run twice. This lesson covers that promise (purity), the development tool that tests it (Strict Mode), and the controlled ways out (refs and Effects).

    The running example is a product page with a "recently viewed" tray, a cart with an undo window and a live ticket counter. The lab runs each small component with Strict Mode off and on, using React 19.3.1 (the latest 19.x in VERSIONS.md at the time of writing).

    The core idea

    1. Rendering must be pure. react.dev says React assumes every component is a pure function: the same props, state and context must give the same JSX, and rendering must not change anything that existed before it started.
    2. Changes belong elsewhere. Side effects usually belong in event handlers, which run when the user acts and not during rendering. An Effect is the last resort, for synchronising with something outside React.
    3. Strict Mode checks the promise. In development it calls your component function twice and runs one extra setup-and-cleanup cycle for every Effect. It does nothing in production.

    Figure 1. What React is allowed to do with your render

    Strict Mode renders twice and keeps one result; effects and handlers run outside renderProps, state, context to Component function; Component function to JSX result (twice in Strict Mode); JSX result to Screen (commit); Event handlers and Effects to Props, state, context (set state)RENDER: NO SIDE EFFECTSProps, state, context: read-only inputsProps, state,contextread-only inputsComponent function: must be pureComponentfunctionmust be pureJSX result: one call is keptJSX resultone call is keptScreen: after commitScreenafter commitEvent handlers and Effects: may change thingsEvent handlersand Effectsmay change thingstwice in Strict Modecommitset state

    How to read this: everything in the zone may run more than once and must leave no trace. Changes happen in the bottom box, and reach the screen only by setting state, which asks for a new render.

    What "pure" rules out

    react.dev's rules page says components and Hooks must be idempotent (same inputs, same result), have no side effects in render, and not mutate non-local values. Props, state and values already passed to JSX are immutable. Local mutation (building an array during render and pushing into it) is fine, because nothing outside the component sees it.

    Illustration 1, the minimal example: mutating a prop

    react.dev's own example is a tray of stories that adds a "Create Story" item. The impure version appends to the array it was given:

    export function StoryTrayImpure({ stories }: { stories: Story[] }) {
      const items = stories; // an alias, not a copy
      items.push({ id: `create-${items.length}`, label: "Create Story" });
      return <ul aria-label="stories">{/* items.map(...) */}</ul>;
    }

    Given one story, the lab saw 2 list items without Strict Mode and 3 with it (the second call appended again). A re-render with the same array grew it to 3 and 5. The fix is a copy, const items = stories.slice();, which keeps the list at 2 and the caller's array untouched.

    A module-level counter behaves the same way: const number = ++nextTicket displayed "Ticket #1" without Strict Mode and "Ticket #2" with it. Passing the number as a prop fixes it.

    Figure 2. Strict Mode makes the hidden write visible

    sequenceDiagram accTitle: Strict Mode calling an impure component twice accDescr: Strict Mode calls TicketImpure twice on mount. The module counter goes from 0 to 1 to 2, and the screen shows the second result, Ticket number 2, so the bug appears immediately. participant S as Strict Mode participant T as TicketImpure participant C as Module counter S->>T: render call 1 T->>C: increment (0 to 1) S->>T: render call 2 T->>C: increment (1 to 2) S-->>S: keep one result Note over S: screen shows Ticket 2, counter is 2

    How to read this: the counter lives outside the component. A pure component would leave it at 0 however often React calls it.

    Strict Mode, precisely

    react.dev lists what Strict Mode does in development:

    • Calls component functions, and functions passed to useState, useMemo, useReducer and set updaters, twice, and uses one result.
    • Runs one extra setup-and-cleanup cycle for every Effect and every ref callback.
    • Changes nothing in the production build.

    Three more bugs then show up at once, each reproduced in the lab.

    • A side effect inside an updater. setItems(prev => { audit.push("added pear"); return [...prev, "pear"]; }) logged once per click without Strict Mode and twice with it. The screen was right, the log was not. Log in the event handler.
    • An Effect without cleanup. useEffect(() => { store.subscribe(...) }, [store]) left 1 listener without Strict Mode and 2 with it, so one event counted twice, and the listener outlived the component. return store.subscribe(...) left 1, then 0 after unmount, in both modes.
    • A ref callback without cleanup. Pushing a node into a registry left 2 entries under Strict Mode and kept them after unmount. A cleanup that removes the node gives 1, then 0.

    Figure 3. The extra Effect cycle

    sequenceDiagram accTitle: Strict Mode Effect cycle with and without cleanup accDescr: On mount React runs the Effect setup, then in development immediately runs its cleanup, then runs setup again. With a cleanup the store ends with one listener. Without one it ends with two. participant R as React (dev, Strict Mode) participant E as Effect participant S as Store R->>E: setup E->>S: subscribe (listener A) R->>E: cleanup Note over E,S: with cleanup: A removed. without: A stays R->>E: setup again E->>S: subscribe (listener B) Note over S: with cleanup 1 listener, without 2

    How to read this: the doubled cycle simulates removing and re-showing a component. If setup and cleanup are symmetric, nothing is left over.

    Refs: the escape hatch, and its rules

    A ref is a plain object { current } that React preserves between renders but does not track. Changing it does not re-render, and you should not read or write ref.current during rendering (react.dev's one exception is one-time lazy initialisation).

    Figure 4. Where should this value live?

    flowchart TD accTitle: Choosing between derived values, state, a ref and an Effect accDescr: If a value can be computed from props and state, compute it during render. If it is shown on screen, use state. If only an event handler needs it, such as a timer id, use a ref. If it must stay in sync with an outside system, use an Effect with cleanup. A{"Can it be computed from
    props and state?"} -->|"yes"| B["Compute during render"] A -->|"no"| C{"Does it change
    what is on screen?"} C -->|"yes"| D["useState"] C -->|"no"| E{"Only handlers need it
    (timer id, DOM node)?"} E -->|"yes"| F["useRef"] E -->|"no"| G["Effect with cleanup,
    to sync with an outside system"]

    How to read this: state is the default for anything on screen. A ref is for the narrow case where React does not need to know.

    Illustration 2, the realistic example: the product page

    The product page has a three-second "Undo" window after "Add to cart", a cart count and a search box focused by a shortcut.

    • The timer id is not drawn, so it belongs in a ref. In a local variable it is re-created every render, so the newer render's Undo handler sees undefined: the lab's version showed "Idle" yet still called onSent after 3 seconds. The ref version never did, in both modes.
    • The count is drawn, so it is state. A component showing ref.current read "Clicks: 0" after the click and "1" only after an unrelated re-render.
    • Focus is a non-destructive DOM use: inputRef.current?.focus() worked in jsdom. react.dev warns against changing DOM nodes React manages (adding or removing children), because it can conflict with React's updates.

    In React 19, ref is an ordinary prop on function components and ref callbacks may return a cleanup.

    Illustration 3, the edge case: creating a ref's contents

    useRef(new Player()) constructs a Player on every render and keeps only the first. The lab counted 3 constructions for three renders, 6 with Strict Mode. The lazy form counted 1 in both modes:

    const player = useRef<Player | null>(null);
    if (player.current === null) player.current = new Player();

    react.dev allows this one write because the result is always the same.

    Trade-offs

    Choice Gets you Costs Use when
    Pure render, copy before changing Safe under repeats and Strict Mode A copy per render Always
    State A re-render on change Re-renders The value is displayed
    Ref No re-render, survives renders Changes unseen by React Timer ids, DOM nodes
    Effect with cleanup Sync with outside systems Runs after commit, extra dev cycle Subscriptions, connections

    Build it

    Goal. See each bug above fail or pass with Strict Mode off and on. The lab examples/frontend/labs/react-purity-strict-mode uses React 19.3.1 and Testing Library under jsdom, a simulated DOM, not a browser: it says nothing about layout or painting.

    cd examples/frontend/labs/react-purity-strict-mode
    ../../node_modules/.bin/vitest run --reporter=verbose   # 31 tests
    ../../node_modules/.bin/tsc -p tsconfig.json            # typecheck

    Summary of a real run on 2026-11-08 (Node 26.12.1, Vitest 5.1.0), test names condensed. The lab has no demo or golden file; the passing tests are the evidence:

    Test Files  2 passed (2)
         Tests  31 passed (31)
    mutating a prop: impure shows 2 items, 3 in Strict Mode; pure stays at 2
    module counter: impure ticket is #1, #2 in Strict Mode; pure shows what it is given
    side effect in an updater: 1 log entry per click, 2 in Strict Mode; in the handler: 1
    Effect without cleanup: 1 listener, 2 in Strict Mode, "Events: 2"; with cleanup: 1, then 0
    ref in render: "Clicks: 0" until a re-render; state: "Clicks: 1" at once
    timer id: local variable cannot be undone; ref can (both modes)
    ref callback: leaky leaves 2 entries; clean registers 1, then 0
    eager useRef: 3 constructions (6 in Strict Mode); lazy: 1

    Break it. In LiveCountClean, drop the return before store.subscribe(...). The cleanup tests fail. Revert it.

    In practice: a support-queue widget that double-counts

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A dashboard widget shows live support tickets. In development it reads 2 for one ticket. The team calls it "a Strict Mode quirk" and turns Strict Mode off. The widget is mounted and unmounted on every route change (illustrative).

    Starting architecture

    The widget subscribes to a ticket store in an Effect that returns nothing. A helper registers each row's DOM node in a list through a ref callback that also returns nothing.

    Options considered

    Option Fixes Cost Risk
    Turn Strict Mode off The doubled count in development Nothing now The leak stays and is now invisible
    De-duplicate in the counter The symptom Extra code Listeners still pile up
    Make setup and cleanup symmetric The cause Two small edits Low

    The decision

    Return the unsubscribe from the Effect, return a node-removing cleanup from the ref callback, and keep Strict Mode on in development and in the test setup (reactStrictMode: true).

    Rollout and verification

    Add the lab's pattern as a test: render under Strict Mode, emit one event, assert one count, unmount, assert zero listeners.

    The 2 a.m. failure

    A later change adds a second subscription whose cleanup unsubscribes a different function. Development shows it, but the test suite renders without Strict Mode and passes. In production, every route change leaves a listener behind and memory grows until an on-call alert fires. First three checks: listener count after unmounting, whether the tests ran under Strict Mode, and whether each cleanup undoes its own setup. Mitigation: fix the cleanup and enable Strict Mode in the test setup. Follow-up: a lint or review rule that every subscribe has its unsubscribe returned.

    Architect's notes

    • Run tests under Strict Mode too. Testing Library's render accepts reactStrictMode (in the installed 16.3.3); set it once in the shared setup.
    • Count your refs. If much of your logic depends on refs, rethink the design, as react.dev says.
    • Strict Mode is a smoke test. An impurity whose two results both look plausible, such as reading the clock in render, may show nothing, so keep reading the rules.

    Common mistakes

    • Silencing Strict Mode instead of fixing the cleanup.
    • Showing ref.current on screen.
    • Keeping a timer id in a local variable inside a component.

    Key takeaways

    • A component must give the same output for the same inputs and change nothing outside itself during render. Local mutation is fine.
    • Strict Mode (development only) calls render and updaters twice, and runs an extra Effect and ref-callback setup and cleanup. It turns hidden impurities into visible bugs.
    • Side effects belong in event handlers, and in Effects only to sync with outside systems, always with a matching cleanup.
    • Displayed values belong in state. Values only handlers need, such as timer ids and DOM nodes, can live in refs, not read or written during render.

    Practice exercises

    Exercise 1 - Starter (30 min): Predict each count the lab's Strict Mode tests assert, write them down, then run the tests.

    Exercise 2 - Intermediate (1 hour): Write a component that reads Date.now() in render, then rewrite it with state and an Effect that cleans up its interval.

    Exercise 3 - Advanced (half day): Add an Effect that fetches data and ignores stale responses, with a test that triggers the second setup while the first response is pending.

    In this course:

    The lessons on how hooks work and on effects are planned for this course and are not yet published.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments