- Published on
Don't Stuff Server State into a Client Store: First Principles of Frontend State Layering
- Authors

- Name
- Jack Qin
The chaos in frontend state management almost always starts with the same move: taking a piece of data fetched from an API and, "while we're at it," storing it in a Context or Redux store. The move looks harmless — the data's already in hand, store it so we can reuse it next time, save a request. But what it plants is the most classic class of frontend bug: the same data now has two copies, one on the server and one in the client store, and their update cadences aren't in sync, so they'll eventually diverge.
To avoid this class of bug at the root, you first have to answer a more basic question: which tool should manage a piece of state depends not on "what kind of data it is" but on "where its source of truth is." Data whose truth lives on the server (readings, reports, user lists) has its authoritative copy forever on the server, and what the frontend holds is just a cache — and a cache should be managed by a cache tool (TanStack Query), not stored in a store as if it were the frontend's own state. Data whose truth lives on the client (which site is currently selected, whether the sidebar is open) is the state the frontend truly "owns."
This post uses the frontend of an environmental-monitoring platform as a worked example (a React 19 + Vite + TanStack Query single-page app), but the point is to unpack the judgments behind this structure: why state is split into three layers by "source of truth," why native fetch instead of axios, why errors and types should be contract-style, and why, in a time-series system, the timezone must converge to a single entry point.
What Problem This Frontend Solves
It's a data-dense dashboard SPA: dust readings, water flow, tank levels, heatmaps, weekly reports, lots of charts and maps. The corresponding backend is a modular .NET API. Three constraints:
- The backend uses HttpOnly Cookie auth, and the frontend holds no token — this directly determines the HTTP client choice;
- All data is stored in UTC, displayed in site-local timezone (typically AWST, UTC+8, no DST) — timezone handling for time-series data is a first-order concern;
- Type safety must span frontend and backend: DTOs can't be written separately on each side and slowly drift.
Overall it's "feature-oriented directories + layered state + a centralized API client." The Provider stack nesting order:
flowchart TD
PW[ProvidersWrapper]
PW --> QC[QueryClientProvider - TanStack Query]
QC --> Auth[AuthProvider - auth context]
Auth --> GF[GlobalFiltersProvider - site + date range + timezone]
GF --> Layout[LayoutProvider - theme/layout]
Layout --> Toast[Toaster - toast notifications]
Toast --> Preline[Preline auto-init]
State Split into Three Layers by "Source of Truth"
This is the single most important architectural decision in the whole frontend. Three kinds of state, three tools, with the layering basis being exactly that question above — where the truth lives:
| Concern | Tool | Source of truth |
|---|---|---|
| Server state | TanStack React Query | The server (the frontend is just a cache) |
| Client state | React Context (auth, filters, layout) | The client itself |
| URL state | localStorage persistence + routing | URL / persistence layer |
Core idea: don't copy server state into a client store. All server data goes through React Query, enjoying its caching, refetching, and invalidation; only client state (which site is selected, whether the sidebar is open) uses Context.
Why this boundary, once blurred, produces bugs is worth spelling out: React Query's entire value is that it knows what it holds is a cache, so it provides staleTime, invalidation, and background refetch — the whole machinery of "realigning the cache with its source of truth." The moment you copy server data into Context, you declare "this data is mine now" — but you don't have that realignment machinery, so when the server updates, the copy in Context doesn't move, and the two silently diverge. The problem isn't "you stored a copy"; it's that, in copying into Context, you lost the ability to invalidate that copy. Client state needs none of this machinery (because it is the truth, with no upstream to realign to), so a simple Context suffices — no Redux/Zustand needed.
Why not bring in Redux/Zustand: because the client state in this app is simple enough (site selection, layout toggles) that Context is more than enough. Introducing a heavy store is paying tax on complexity that doesn't exist.
Native fetch Instead of axios: the Choice Is Dictated by the Auth Method
| Decision | Reasoning |
|---|---|
Native fetch + a thin wrapper (credentials: 'include') | Zero dependencies; the Cookie is attached automatically |
This choice isn't "I prefer native" — it's dictated by the auth method. One of axios's core selling points is interceptors — commonly used to manually inject an Authorization token into each request. But this system's auth is Cookie-driven; the frontend holds no token, so there's no token to inject. As long as each request carries credentials: "include", the browser automatically sends the session Cookie. Axios's single biggest use here vanishes outright, and the remaining cross-cutting logic can be covered by one centralized thin wrapper — why pull in a dependency.
A single centralized apiClient wraps all the cross-cutting logic:
| Capability | Detail |
|---|---|
| Credentials | credentials: "include" on every request |
| CSRF | Extracts from the XSRF-TOKEN Cookie automatically, injects into the X-XSRF-TOKEN header on mutating requests |
| Content-Type | Plain objects get application/json automatically; FormData/Blob pass through |
| Params | Query params auto-filtered for null and stringified |
| 401 handling | Global onUnauthorized callback: clear Query cache + redirect to login |
This is a great counter-example for teaching: a technology choice is often not comparing two libraries' feature lists, but seeing which of those features your constraints render unnecessary. Cookie auth zeros out "auto-inject token," and with it axios's cost-benefit collapses.
Contract-Style Errors: the Frontend Doesn't Parse the Backend's Free Text
The backend returns ProblemDetails (RFC 9457), with errorCode + traceId + fieldErrors. The frontend converts it into a typed ApiError and handles it by code:
ApiError.code | Default handling |
|---|---|
validation + fieldErrors | No toast; the form renders errors next to fields |
unauthorized | Clear cache + redirect to login |
forbidden | Redirect to no-permission page or toast |
not_found | In-page empty state |
conflict | Toast (form can show inline) |
server / network | Toast |
| Silent refetch failure | No toast, just console.warn |
The key is that the frontend decides by the structured code, not by parsing the backend's text. Why this matters: if the frontend branches via string matching like if (message.includes("not found")), then the moment the backend tweaks its wording — or adds i18n — the frontend logic silently fails, because the frontend and backend are tied together by the most fragile coupling there is (human-readable text). Branch by code instead, and the frontend is unaffected by however the backend's wording changes. i18n also maps the backend's errorCode (e.g., invalid_credentials, account_locked) to a translation key, rather than displaying the backend string directly.
This is a transferable principle: a contract between two systems should rest on stable structured fields, not on text that exists for human display and changes at any moment. Text is for people; it shifts with product, language, and copy polish. Structured codes are for machines; they should be stable.
OpenAPI-Generated Types: Mechanically Preventing Drift
Frontend–backend type safety relies on generating from the OpenAPI schema:
{
"api:fetch-schema": "curl -o .../openapi-schema.json http://localhost:5000/openapi/v1.json",
"api:generate": "openapi-typescript .../openapi-schema.json -o src/lib/api/generated.ts",
}
The generated generated.ts has only types, no runtime code; feature modules' api/ directories import these DTO types directly. In CI the backend exports the schema as an artifact, and the frontend CI checks freshness — any schema drift raises an alarm.
Why this is far more reliable than "maintaining an interface on each side": hand-writing two interfaces essentially lets two sources of truth describe the same DTO, kept in sync by human discipline, and human discipline rots — the backend adds a field and forgets to tell the frontend, and the frontend's interface quietly goes stale until it blows up at runtime. OpenAPI generation makes the backend schema the single source of truth, with the frontend types as its derivative, and drift turns red at the CI stage. This is the same principle as "permissions have only one authoritative source" in the auth post, applied differently: anything that must stay consistent in two places is correctly solved not by trying harder to sync manually, but by deriving one place from the other.
TanStack Query Retries Should Respect the retryable Flag
queries: {
staleTime: 2 * 60 * 1000, // default 2 minutes
retry: (failureCount, error) => {
if (isApiError(error) && !error.retryable) return false; // non-retryable: give up immediately
return failureCount < 2;
},
refetchOnWindowFocus: false,
},
mutations: { retry: false }, // mutations never auto-retry
Retry isn't a brainless retry — errors like 4xx where "retrying won't help" are marked non-retryable and abandoned immediately; only retryable ones (network jitter, 5xx) retry up to 2 times. Mutations never auto-retry, for the sake of side effects: a failed "create" request auto-retrying might really create two — retry: false is preventing "duplicate side effects," not saving requests. This is an often-overlooked asymmetry: reads are idempotent and safe to retry; writes have side effects and are dangerous to retry. Setting query and mutation retry policies separately at the global level is precisely a response to that asymmetry.
Implementation Details
The Standard Pattern for Feature Modules
All 20+ feature modules follow the same self-contained structure, which newcomers can just copy:
src/features/[feature-name]/
├── api/ # API fetch functions + Query keys
├── hooks/ # Custom hooks wrapping TanStack Query (useXQuery / useXMutation)
├── components/ # Feature-specific components
├── types/ # TypeScript interfaces
├── services/ # Business logic/transforms (optional)
└── index.ts # Public barrel export (optional)
The three-part pattern — the API layer (a thin wrapper that only makes requests):
export const siteComparisonApi = {
getComparison: (siteIds, from, to, options?) =>
apiClient.get<DustLevelComparisonDto[]>('/api/v1/dust-levels/comparison', {
params: { siteIds: siteIds.join(','), from, to },
signal: options?.signal,
}),
}
Query keys (hierarchical, type-safe, stable):
const siteComparisonKeys = {
all: ['dust-levels', 'comparison'] as const,
byRange: (siteIds, from, to) => [...siteComparisonKeys.all, ...siteIds, from, to] as const,
}
Query hooks (the component's single data entry point):
export function useSiteComparisonQuery(siteIds, from, to) {
return useQuery({
queryKey: siteComparisonKeys.byRange(siteIds, from, to),
queryFn: ({ signal }) => siteComparisonApi.getComparison(siteIds, from, to, { signal }),
enabled: siteIds.length > 0,
staleTime: 5 * 60 * 1000,
})
}
The iron rule: components never call apiClient directly, only hooks. The point of this rule isn't stylistic uniformity but where the invalidation logic is encapsulated: query key, invalidation, and enabled conditions are all sealed inside the hook, and the component cares only about three things — "data, loading, error." The moment some component bypasses the hook and calls apiClient directly, it bypasses this key-and-invalidation logic — the data it gets doesn't enter the cache and won't be invalidated, and the cache's consistency develops a hole.
To keep query keys stable when the filter object's key order changes, there's a normalizeFilters utility: drop undefined/null, convert Dates to ISO strings, sort arrays. Otherwise {a,b} and {b,a} would be treated as two different keys, needlessly invalidating the cache — the query key is the fingerprint of cache identity, and an unstable fingerprint makes the cache worthless.
The Global Filters Context
Site selection, date range, and timezone are centralized in one GlobalFiltersContext:
interface GlobalFiltersState {
selectedSiteId: string | null
filteredSites: MineSite[] // filtered by feature flags (e.g., dust_level_enabled)
siteTimezone: string // from selectedSite.timeZone or the default timezone
dateRange: DateRange
dateRangePreset: DateRangePreset // today / last_7_days / custom ...
}
Behavior: load the sites the user has permission for from the API, filtered by feature flags; persist the selected site and date range to localStorage; auto-select the first when none is selected; recompute the date range when the site timezone changes (for non-custom presets).
Timezone: a Single Entry Point, Because It's the Most Fragile Link in a Time-Series System
In a time-series data system, get the timezone slightly wrong and the whole curve shifts — and it throws no error, it just plots the data at the wrong time points, which is extremely hard to spot. Precisely because it's fragile and silent, the rules must be very hard:
| Stage | Rule |
|---|---|
| Database storage | Always UTC |
| API response | UTC timestamps (ISO 8601), never a pre-formatted local string |
| Display | The frontend converts UTC to the site timezone |
| User input | Convert the site timezone to UTC before sending to the API |
| Chart axes / date pickers | Site-local time |
All timezone logic is centralized in lib/timezone.ts, with DEFAULT_TIMEZONE = "Australia/Perth". Formatting always uses date-fns-tz's formatInTimeZone(date, tz, format), never toLocaleString() — the latter follows the browser's timezone, silently going wrong in cross-timezone scenarios.
Core idea: the API always gives UTC, and the frontend always specifies the timezone conversion explicitly. Why converge to a single entry point: the danger of timezone errors isn't "hard to get right" but "scattered" — as long as one spot uses toLocaleString() or hardcodes a timezone string, the system's time consistency develops a crack, and because the crack throws no error, it may be in production a long time before some cross-timezone user finds it. Gathering all timezone logic into one file shrinks "places that can go wrong" from "scattered across the codebase" to "a single auditable point." This is the same move as all the "single source of truth" arguments above: logic that's error-prone and high-consequence should either be eliminated or converged to one place to guard centrally.
Route Metadata Drives Layout; Exports Are All Client-Side
Each route in the route table declares metadata (requiredModule, showDatePicker, showSiteSelector, etc.); the layout dynamically renders header controls accordingly, and pages inject buttons into the header via HeaderActionsContext — the page declares intent, the layout renders accordingly, and the two are decoupled, with the page never manipulating header DOM directly.
Document export (DOCX/PDF/CSV/ZIP) is done entirely on the frontend — the backend only provides data via the API, and the frontend renders the document. Chart capture has three strategies: DOM-to-JPEG, Canvas extraction (ECharts' getDataURL()), SVG serialization. This is an explicit tradeoff: client-side export is architecturally simpler (no server-side rendering infrastructure needed), at the cost of being bound by browser memory/performance. It pays off at this project's report scale — but note this is a trade of "simplicity" for a "scale ceiling," and once the scale exceeds browser memory it has to be renegotiated.
Where It Applies: This Architecture's Sweet Spot and Failure Points
What it buys is clear: state sources are unambiguous (ruling out "two unsynced copies of data"), 20+ feature modules follow one pattern (easy onboarding), types and errors are both contract-style (drift-resistant), and the timezone has a single entry point (the most error-prone spot is converged). But each benefit corresponds to a failure scenario:
- Client state becomes very complex (lots of cross-component shared derived state, complex undo/redo): Context will buckle, and you should reach for Zustand/Jotai — "Context suffices" presumes simple client state;
- You need SSR/SEO: this is a pure SPA (static hosting); server-side rendering or SEO requires switching to a framework like Next.js;
- Export report scale is enormous: pure client-side export is bound by browser memory, and very large reports require moving rendering back to the server — that "simplicity for scale" trade has come due.
This architecture's sweet spot is: same-root-domain Cookie auth, data-dense but client state not complex, a pure SPA. A monitoring dashboard lands right in this sweet spot.
The transferable layer actually runs through the whole post as one thread: most of the "which tool should I use" / "where should this go" judgments in frontend architecture ultimately come back to "where is this thing's source of truth, who is its authoritative copy." Server state's truth is on the backend, so use a cache tool not a store; DTO types' truth is in the backend schema, so generate not hand-write; the timezone conversion's truth is UTC + site timezone, so converge to one entry point. Think clearly about the source of truth, and the layering and tool choices mostly surface on their own.