React / Coordinated application behavior / Lesson 10
Data loading and race control
Represent asynchronous outcomes explicitly and prevent obsolete requests from overwriting a newer user intent.
On this page
Lesson goals
- Choose the loading boundary
- Ignore results from obsolete synchronization
- Test ordering and recovery deliberately
Choose the loading boundary
A framework route loader or a purpose-built data layer can coordinate caching, parallel loading, server rendering, and navigation better than many isolated Effects. React itself does not automatically provide all those policies. When teaching a direct Effect, make its limitations explicit: it can illustrate synchronization but may create waterfalls, repeated fetches, and missing cache behavior in a real application. Determine who owns the request, which identity selects the data, and whether the result may be reused. Keep transport, parsing, domain validation, and rendering separate enough that an HTTP success does not silently imply valid authorized data.
Model pending, success, and failure
Represent loading states coherently rather than combining unrelated booleans that permit impossible combinations. Decide whether stale successful data remains visible while refreshing, and communicate that distinction honestly. Errors need a useful recovery path, while an empty successful result is different from failure. Request identity can include a record id, query, authorization scope, and other meaningful inputs; omitting a dependency can show another query’s result. Do not store secrets in client-visible component state under the assumption that React hides them. All browser code and delivered data must be considered visible to the person controlling that browser.
Ignore results from obsolete synchronization
The first example uses a bounded synthetic loader and an Effect cleanup flag. Choosing Beta before Alpha finishes cleans up Alpha’s synchronization; its eventual result is ignored, so it cannot overwrite Beta. The flag prevents stale publication but does not cancel the underlying operation. A real fetch can use AbortController where appropriate, while server work may already have happened despite client cancellation. Handle errors as well as success under the same ownership rule. Fixed local delays create a controlled race for learning and testing, not a recommendation to rely on elapsed-time guesses in production.
import { useEffect, useState } from "react";
function load(id) { return new Promise(resolve => setTimeout(() => resolve(id === "a" ? "Alpha" : "Beta"), id === "a" ? 40 : 5)); }
export default function App() { const [id, setId] = useState("a"); const [result, setResult] = useState("Loading"); useEffect(() => { let ignore = false; setResult("Loading"); load(id).then(value => { if (!ignore) setResult(value); }); return () => { ignore = true; }; }, [id]); return <main><h1>Current record</h1><button type="button" onClick={() => setId("b")}>Choose Beta</button><p>{result}</p></main>; }
Choose Beta before Alpha resolves: final result Beta.
Cleanup invalidates Alpha’s publication when the id changes. Its bounded promise still completes, but the current result remains Beta.
Separate an event request from an Effect request
The second example starts an explicit local check from a button and catches its synthetic failure. The user action owns that request, so there is no need to toggle state merely to make an Effect issue it indirectly. Pending and error text reflect the owned attempt. A larger workflow also needs duplicate-click, unmount, and uncertain-outcome policies; this small example disables the button while its bounded promise is pending. That is not a substitute for server idempotency. Keep a durable operation key when retrying a real write could duplicate effects, and never infer remote rollback from a browser timeout alone.
import { useState } from "react";
export default function App() { const [status, setStatus] = useState("Idle"); async function check() { setStatus("Pending"); try { await Promise.reject(new Error("Synthetic unavailable")); setStatus("Ready"); } catch { setStatus("Check unavailable; try again"); } } return <main><h1>Local check</h1><button type="button" disabled={status === "Pending"} onClick={check}>Check</button><p>{status}</p></main>; }
Initial: Idle. After Check: Check unavailable; try again.
The event owns and observes the rejected promise. No request is detached and no success is announced for the failed check.
Test ordering and recovery deliberately
Control the loader in tests so older and newer attempts can resolve in the opposite order. Assert that only the current request publishes data and that expected failures lead to a recoverable message. Also test an empty result, repeated query, changed user scope, and leaving the screen before completion. Browser event timing alone is weak evidence; resolve controlled promises or use a documented test scheduler. Integration tests should verify transport and authorization separately against isolated approved fixtures. A correct loading spinner does not prove that caching, freshness, or access control meets the application’s contract.
Common errors and fixes
An old response replaces the selected record
- Why it happens
- Completion order was mistaken for current intent.
- How to fix it
- Guard publication by request ownership or use a suitable data layer.
- Prevent it next time
- Test out-of-order completion.
Cleanup is assumed to undo a remote write
- Why it happens
- Client cancellation and server effects were conflated.
- How to fix it
- Reconcile uncertain outcomes with operation identity.
- Prevent it next time
- Design write idempotency independently.
A successful HTTP status renders malformed data
- Why it happens
- Transport success bypassed domain validation.
- How to fix it
- Validate the expected data contract before publication.
- Prevent it next time
- Test malformed and unauthorized responses.
Check your understanding
1. How can a test prove a stale-result guard works without depending on real network latency?
Show worked solution
Provide a controllable loader, start A then B, resolve B first and A afterward, and assert that B remains visible. Observe both promises and verify cleanup on id change. This tests the ownership rule directly rather than hoping a slow service creates the right schedule.
2. Why should an empty list and a failed request use different UI states?
Show worked solution
The empty list is a successful answer to the query; failure means the answer is unavailable or invalid. Combining them can mislead users into believing no records exist. Provide truthful status and a retry or recovery route for failure, with access checks enforced outside the presentation.
Key takeaways
- Every request needs an owner and identity.
- Stale-result protection differs from cancellation.
- Loading UI does not establish data trust.