Data — Cheat Sheet
React · 8 topics. Download the PDF or the Instagram carousel and share it.
Fetching Data — And Why It Is Harder Than It Looks
The three-line version in an effect is a demo. The production version handles loading, errors, races, cancellation, refetching and caching — which is why a library usually wins.
- ✓fetch does not reject on 4xx or 5xx — check res.ok explicitly or errors pass silently
- ✓A correct manual fetch needs abort, race protection, and a status for each outcome
- ✓Render all four states: loading, error, empty and success — a missing empty state looks like a bug
- ✓A skeleton that matches the final layout avoids the layout shift a spinner causes
- ✓Keep fetch calls in a per-resource api module, and parallelise independent requests to avoid waterfalls
function useProblems(topic) {
const [state, setState] = useState({ status: 'loading' })
useEffect(() => {
const controller = new AbortController()
setState({ status: 'loading' })
fetch(`/api/problems?topic=${encodeURIComponent(topic)}`, {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new ApiError(res.status) // fetch does NOT reject on 4xx
return res.json()
})
.then(data => setState({ status: 'success', data }))
.catch(err => {
if (err.name === 'AbortError') return // superseded, not a failure
setState({ status: 'error', error: err })
})
return () => controller.abort()
}, [topic])
return state
}
// That is the minimum for ONE endpoint. Now add: retry, caching
// across components, refetch on window focus, invalidation after a
// mutation, and pagination.Designing the Loading, Error and Empty States
The states around the data are most of the perceived quality of an app. They are also where juniors are most visibly junior in a code review.
- ✓A skeleton matching the final layout prevents the layout shift a centred spinner guarantees
- ✓Delay the loading indicator by ~200ms so fast responses do not flash
- ✓Distinguish the first load from a background refresh — the second should not blank the screen
- ✓Map error causes to different responses: sign in, upgrade, not found, or retry
- ✓"Nothing yet", "nothing matched" and "nothing left" are three different empty states with different copy
// A skeleton reserves the final layout, so nothing jumps when data lands
function ProblemsSkeleton() {
return (
<ul aria-busy="true" aria-label="Loading problems">
{Array.from({ length: 6 }, (_, i) => (
<li key={i} className="h-16 rounded animate-pulse bg-muted" />
))}
</ul>
)
}
// A spinner in the middle of an empty page tells the user nothing
// about what is arriving, and guarantees a layout shift.
// Avoid the flash: if the response takes 80ms, showing a skeleton
// for 80ms looks like a glitch. Delay it.
const showSkeleton = useDelayedFlag(isLoading, 200)
// Distinguish first load from a background refresh:
// isLoading -> no data yet, show the skeleton
// isFetching -> data on screen, show a subtle indicator instead
{isFetching && <span className="text-xs">Updating…</span>}TanStack Query — Queries, Keys and Caching
Server data is not application state. A query library treats it as a cache of someone else's data, which is what removes the loading flags, the races and the duplicate requests.
- ✓Server data is a cache of someone else's state, not application state — that distinction is the whole library
- ✓The query key is the cache identity; every input the query function uses must appear in it
- ✓staleTime decides when a refetch happens, gcTime decides when an unused entry is deleted
- ✓Stale data stays on screen while it refetches in the background, so the user rarely sees a blank state
- ✓enabled sequences dependent queries, and prefetching on hover makes navigation feel instant
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: ['problems', { topic, page }],
queryFn: ({ signal }) => listProblems({ topic, page, signal }),
staleTime: 60_000, // treat as fresh for a minute
})
if (isLoading) return <ProblemsSkeleton />
if (isError) return <ErrorState error={error} onRetry={refetch} />
return <ProblemList problems={data} />
// What you no longer write: the loading flag, the error state, the
// abort controller, the race guard, the mount check, and the
// deduplication when three components ask for the same thing.
// The key IS the cache identity. Anything the queryFn depends on
// must be in it, or you will show one topic's data under another.
queryKey: ['problems', { topic, page }] // correct
queryKey: ['problems'] // wrong — topic ignored
// Changing the key is how you refetch: no manual trigger needed.Mutations and Optimistic Updates
A mutation changes server data and then has to make every affected view agree. Invalidation is the safe default; an optimistic update is what makes an interface feel instant.
- ✓After a successful write, invalidate every query key whose data that write affected
- ✓isPending gives you the disabled state that prevents double submission
- ✓An optimistic update must cancel in-flight queries, snapshot, apply, and roll back on error
- ✓Use optimistic updates for cheap reversible actions, never for payments or submissions
- ✓Retrying a POST is unsafe without an idempotency key, and a failed submit must never clear the user's input
const queryClient = useQueryClient()
const submit = useMutation({
mutationFn: (payload) => submitSolution(problemId, payload),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['submissions', problemId] })
queryClient.invalidateQueries({ queryKey: ['progress'] })
toast.success('Submitted')
},
onError: (err) => toast.error(describe(err)),
})
<button onClick={() => submit.mutate({ code })} disabled={submit.isPending}>
{submit.isPending ? 'Running…' : 'Submit'}
</button>
// isPending is the disabled state you were tracking by hand, and it
// is what stops a double-click creating two submissions.
// The discipline: after every write, list what is now stale.
// A stale sidebar counter is the classic missed invalidation.Pagination, Infinite Scroll and Search
Offset pagination is simple and drifts under writes; cursor pagination is stable and cannot jump to page seven. Search adds debouncing and cancellation on top.
- ✓Offset pagination can repeat or skip rows when the underlying data changes; cursor pagination is stable
- ✓keepPreviousData holds the current page on screen so changing pages does not flicker to a skeleton
- ✓Page, filter and search state belong in the URL so links, refresh and the back button all work
- ✓Infinite scroll uses an IntersectionObserver sentinel with rootMargin, and still needs a "Load more" button for accessibility
- ✓Debounce the value the query depends on, not the input itself, so typing stays responsive
const { data, isPlaceholderData } = useQuery({
queryKey: ['problems', { topic, page }],
queryFn: () => listProblems({ topic, page }),
placeholderData: keepPreviousData, // hold the old page during the fetch
})
<ProblemList problems={data.items} dimmed={isPlaceholderData} />
<Pagination
page={page}
total={data.totalPages}
onChange={setPage}
disabled={isPlaceholderData}
/>
// Without keepPreviousData the list unmounts on every page change
// and the page jumps to a skeleton — the classic flicker.
// Put the page in the URL, not in state, so back/forward and a
// shared link both work:
const [params, setParams] = useSearchParams()
const page = Number(params.get('page') ?? 1)
// And always reset to page 1 when a filter changes, or the user
// lands on an out-of-range page with no results.Cache Behaviour — Staleness, Refetching and Invalidation
Every cached value is a bet that the server has not changed. Deciding how long to hold that bet, and what proves it wrong, is the whole of cache design.
- ✓Hierarchical keys plus a key factory make invalidation precise and prevent typo mismatches
- ✓A fresh query never refetches; staleness plus a trigger (mount, focus, reconnect, invalidate) causes refetching
- ✓Choose staleTime from how fast the data actually changes, not from a habit
- ✓Refetch-on-focus is good for dashboards and disruptive on pages holding an in-progress edit
- ✓Almost every cache bug is a key that is too coarse or an invalidation that was never written
// Structure keys from general to specific
['problems'] // everything problems-related
['problems', 'list', { topic, page }] // one list
['problems', 'detail', slug] // one item
// Then invalidation can be as broad or narrow as the write requires
queryClient.invalidateQueries({ queryKey: ['problems'] }) // all
queryClient.invalidateQueries({ queryKey: ['problems', 'list'] }) // lists only
queryClient.invalidateQueries({ queryKey: ['problems', 'detail', slug] })
// A key factory keeps them consistent and typo-proof
export const problemKeys = {
all: ['problems'],
lists: () => [...problemKeys.all, 'list'],
list: (filters) => [...problemKeys.lists(), filters],
details: () => [...problemKeys.all, 'detail'],
detail: (slug) => [...problemKeys.details(), slug],
}
// The bug this prevents: one file uses ['problem', slug] and another
// uses ['problems', slug], so the invalidation silently misses.Authentication in a React App
Where the token lives, how the app knows who you are before the first render, and what happens on a 401. The security decisions here outweigh the React ones.
- ✓An httpOnly cookie cannot be read by JavaScript and is the stronger default; localStorage exposes the token to any XSS
- ✓Never trust a client-decoded JWT for authorisation — the server decides what a user may do
- ✓Model auth as checking, signed-in and signed-out, or the app flashes the signed-out UI on every reload
- ✓Concurrent 401s must share a single in-flight refresh rather than each triggering their own
- ✓Sign-out has to clear the query cache, or the next user briefly sees the previous user's data
// Option A — httpOnly cookie set by the server (the stronger default)
// + JavaScript cannot read it, so an XSS cannot steal it
// - needs SameSite and CSRF protection
// - needs credentials: 'include' for cross-origin calls
fetch('/api/me', { credentials: 'include' })
// Option B — access token in memory, refresh token in an httpOnly cookie
// + works cleanly across separate API domains and mobile clients
// + the access token is never persisted, so a reload drops it
// - more moving parts
// Anti-pattern: the token in localStorage. Any script on the page can
// read it, so one XSS — including one inside a dependency — hands over
// every session.
// And never decode a JWT on the client and trust it for authorisation.
// Read it for display (a name, an expiry) only; the server decides
// what the user is allowed to do.Real-Time Data in React
A socket delivers events; your UI needs state. Bridging them means one connection per app rather than per component, and writing updates into the same cache your queries read.
- ✓Open one shared connection near the root rather than one per component
- ✓Write live events into the query cache so fetched data and live data cannot diverge
- ✓Patch the cache when the event carries the full shape; invalidate when it does not
- ✓On reconnect you have missed events — resync by invalidating rather than assuming continuity
- ✓Reconnect with exponential backoff plus jitter, and buffer high-frequency events instead of rendering each one
// A socket per component means N connections and N reconnect loops.
// One provider, near the root, is the shape you want.
function RealtimeProvider({ children }) {
const queryClient = useQueryClient()
useEffect(() => {
const ws = new WebSocket(WS_URL)
ws.onmessage = (e) => {
const event = JSON.parse(e.data)
handle(event, queryClient)
}
ws.onclose = (e) => { if (!e.wasClean) scheduleReconnect() }
return () => ws.close() // cleanup, always
}, [queryClient])
return children
}
// queryClient is stable, so this effect runs once — and because the
// handler writes to the cache rather than to component state, no
// component needs to know the socket exists.
// For one-way updates, prefer SSE: it reconnects by itself and
// needs no heartbeat. See the JavaScript track's streaming concept.