Frontend Data Layer — Reading Server State
Design a trustworthy read path with shared query identity, freshness budgets, race-safe caching, and precise invalidation.
Fetching is not merely “call an endpoint and put the response in state.” The moment two views read the same server entity, the frontend needs adata layer: a shared answer to what the data is, who owns it, how long it can be trusted, and what happens when the server changes.
The read path needs four separate decisions
Earlier lessons covered the amount of data to request through pagination, its client-side shape through normalization, and the transport of changes through polling, SSE, and WebSockets. One decision remained: whenshould a view read, reuse, or refresh server data?
A production read path separates observers from query identity, read policy, and transport. That separation lets a board, a detail panel, and a counter ask for related data without inventing three unrelated caching systems inside three components.
Not everything in the browser belongs in the data layer
A drawer being open and issue #42 being closed are both “state,” but they have different owners and lifecycles. Treating them alike creates bloated global stores or local copies of server truth.
| Owner | Typical examples | |
|---|---|---|
| Local UI | Component | Modal open, active tab, draft input |
| Location | URL | Page, sort order, shareable filters |
| Server state | Server + query cache | Issues, users, counts, permissions |
| Persisted local | Browser storage | Unsaved draft, preference, offline hint |
Imagine a project board fetched issue #42 while it was open. A detail panel later fetches the same issue after it closed. Both responses were correct when they arrived, yet the page now contradicts itself. The bug is not the endpoint; it is the absence of a shared identity and cache.
A shared cache gives both observers one place to read the current server representation. It can also deduplicate simultaneous requests and notify every observer when that representation changes.
Equivalent reads need the same stable key
“Issues” is not a complete identity. Project, status, pagination cursor, sort order, and every input that can change the response belong in the key. If equivalent reads produce equivalent keys, the data layer can safely share work.
const queryKey = [
"issues",
{ project: "alpha", status: "open" },
] as const;
useQuery({
queryKey,
queryFn: () => getIssues({ project: "alpha", status: "open" }),
});Stale is a policy state, not a synonym for incorrect
Cached data moves through a lifecycle. Before the first read it is missing. After a successful fetch it is fresh. Once its freshness budget expires it becomes stale: still renderable, but now eligible for a background check. With no observers it becomes inactive, then is removed after its retention window.
Choose a freshness budget from product risk
A country list may remain fresh for a day; a trading price may expire in seconds. The right number comes from how quickly the server changes and how costly stale UI would be—not from a library default.
useQuery({
queryKey: ["issue", 42],
queryFn: () => getIssue(42),
staleTime: _000, // trust for 30 seconds
gcTime: 5 * _000, // retain after the last observer leaves
refetchOnWindowFocus: true,
});The latest response is not always the latest intent
A user searches for “re,” then quickly types “react.” The newer request can finish first while the older, broader search arrives last. Writing every response into one results variable lets network timing overwrite user intent.
Keep each result under its own query key and render the active key. The slow “re” response can still be cached—its network cost was already paid—but it must not replace the active “react” view.
const activeKey = ["search", debouncedTerm];
const result = useQuery({
queryKey: activeKey,
queryFn: ({ signal }) => search(debouncedTerm, { signal }),
});
// "re" and "react" populate separate cache entries.
// The view observes only activeKey.A single write can invalidate several reads
Closing issue #42 changes its detail, removes it from the open list, and changes the project count. It may not change the assigned user record. The data layer must model that blast radius.
| Failure | Consequence | |
|---|---|---|
| Under-invalidation | Too few related queries refreshed | Contradictory or stale UI survives |
| Over-invalidation | Broad prefixes refreshed every time | Extra traffic, loading, and server work |
| Precise invalidation | Known affected identities marked stale | Correct UI with bounded work |
Signals should converge through the shared cache
A polling response, SSE event, or WebSocket message does not replace the data layer. It supplies evidence that data changed. The cache maps that signal to query identities, checks policy, and updates every observer.
socket.on("issue.changed", ({ id }) => {
queryClient.invalidateQueries({ queryKey: ["issue", id] });
queryClient.invalidateQueries({ queryKey: ["issues"] });
queryClient.invalidateQueries({ queryKey: ["project", "issue-count"] });
});A small notification keeps the event schema light and reuses proven HTTP reads, at the cost of another round trip. A canonical snapshot avoids that read, but needs validation and merge handlers that update every affected cache entry consistently.
Check your understanding
Design challenge
Design the read path for an issue tracker with a board, detail drawer, project counts, search, offline reconnection, and WebSocket change notifications. Write down:
- The canonical query key for every read, including filters and pagination inputs.
- The freshness budget and retention time for detail, lists, counts, and search.
- Which views observe the same cache entry and which need distinct identities.
- How search prevents older responses from overwriting the active term.
- The exact invalidation blast radius of create, close, assign, and delete mutations.
- Whether each real-time event carries a small notification or canonical snapshot.
- →Separate views, query identity, read policy, and transport into four explicit layers.
- →Classify state before storing it: local UI, URL, server-originated, and persisted local state have different owners.
- →A stable query key gives equivalent observers one request, one cache entry, and one lifecycle.
- →Freshness is a product promise; stale data may still render while the client revalidates it.
- →Key responses by request identity so network arrival order cannot overwrite the user's latest intent.
- →Balance under- and over-invalidation by mapping writes and real-time signals to the precise reads they affect.
- →Keep the shared cache between transports and components so every view converges on the same server state.