Routing & State
Client-side routing, server state and caching, global stores, and forms — where data lives once an app outgrows one component tree.
Not All State Is the Same
Sort every value in your app into one of four buckets before choosing a library. The bucket decides the tool, and the wrong bucket is why so many applications end up with a thousand-line store nobody understands.
| Kind | Examples | Belongs in |
|---|---|---|
| Local | Is this dropdown open | useState, in the component |
| Shared | Theme, signed-in user, locale | Context, or a small store |
| Server | Anything fetched from an API | A caching data layer |
| URL | Current page, filters, search terms | The URL itself |
The last one is the most commonly missed. If a value should survive a refresh or be shareable as a link — the current tab, a filter, a page number — it belongs in the URL, not in a store.
Client-Side Routing
A router maps the URL to a component and swaps it without a full page load. It keeps the address bar honest, so back, forward, refresh and sharing all work as users expect.
The URL is a state store you get for free — and the only one that survives a refresh, can be bookmarked, and can be pasted to a colleague.
| Part | Holds | Example |
|---|---|---|
| Path | What you are looking at | /orders/42 |
| Route params | Which record | 42 |
| Query string | Filters, sort, page, search | ?status=open&page=2 |
| Hash | A position within the page | #delivery |
Two things clients frequently get wrong: scroll position, which should reset on a new page but be restored on back; and the server config, which must serve index.html for unknown paths or a deep link refreshed gives a 404.
Server State & Caching
Data from an API is not really your state — it is a cached copy of state that lives somewhere else and can change without telling you. That framing is what the dedicated libraries are built around.
Hand-rolling it means writing the same four pieces every time: loading, error, the data, and the refetch. Then the harder ones — deduplicating concurrent requests, invalidating after a mutation, not showing a spinner for a cached value you already have.
| Problem | What a data layer gives you |
|---|---|
| Two components request the same thing | One request, shared result |
| Data goes stale | Background refetch on focus or interval |
| Spinner flashing on every visit | Serve the cache, revalidate behind it |
| A mutation changes a list | Invalidate the affected keys |
| Slow, obviously-correct updates | Optimistic update with rollback on failure |
Every request needs a stable key — usually the endpoint plus its parameters. It is what deduplication, caching and invalidation all key off, and getting it wrong produces the confusing bugs.
Global State Stores
Once server data and URL state are handled properly, genuinely global client state is a much smaller problem than it first appears — often a theme, a signed-in user, and the contents of a cart.
| Reach for | When | Cost |
|---|---|---|
| Lifting state up | Two or three nearby components | None — do this first |
| Context | Global, rarely changes | Every consumer re-renders on change |
| A small store | Shared, changes often | A dependency, subscribe-by-slice |
| A full flux store | Complex flows needing an audit trail | Significant boilerplate |
Whatever you choose, keep the store normalised and minimal: store ids, derive the rest. Denormalised copies of the same entity in three places is how a store becomes unmaintainable.
Forms & Validation
Forms are the densest state in most applications: a value, whether it has been touched, whether it is valid, why not, and whether the whole thing is submitting.
Start from the platform. Native required, type and pattern handle a surprising amount, work before JavaScript loads, and are announced correctly for free.
| Decision | Sensible default |
|---|---|
| When to validate | On blur, then on every change once it has errored |
| Where errors appear | Beside the field, tied with aria-describedby |
| Marking invalid | aria-invalid, plus text — never colour alone |
| On submit failure | Move focus to the first invalid field |
| While submitting | Disable the button; keep values on failure |
Client validation is a courtesy, never a control. The server must validate everything regardless, because anything can post to your endpoint.
Interview Questions
How do you decide where a piece of state belongs?
Sort it: local to one component, shared across a few, owned by the server, or belonging in the URL. Anything that should survive a refresh or be shareable as a link goes in the URL. Anything fetched belongs in a cache, not a store.
Why treat server data differently from client state?
It is a cached copy of something you do not own and that can change without telling you. That brings problems client state does not have — staleness, deduplication, invalidation after mutations — which is exactly what a data layer solves.
What breaks when a single-page app is deployed without server config?
Deep links. The client router handles in-app navigation, but refreshing /orders/42 asks the server for a file that does not exist. The server must serve index.html for unknown paths.
What is an optimistic update?
Applying the expected result immediately, before the server confirms, then rolling back if it fails. It makes an interface feel instant, and it needs the rollback path to actually work.
What does client routing break for accessibility?
Focus and announcement. The URL changes and content swaps, but focus stays where it was and nothing is announced. Move focus to the new page heading on navigation.
Context or a store?
Context for global values that rarely change — theme, user, locale — since every consumer re-renders when the value changes. A store with per-slice subscriptions for shared state that changes often.