Today’s goal
Stop treating all state as the same kind of problem.
We will connect:
- local, shared, domain, server, URL, form, persistent, and derived state;
- ownership, reducers, stores, and state machines;
- routing as application state architecture;
- paths, parameters, history, loading, and navigation UX;
- controlled, uncontrolled, and hybrid forms;
- validation, dirty state, dynamic fields, and multistep workflows;
- state placement in a routed administrative catalogue.
By the end of today you can
- classify a value before choosing a state tool;
- keep state close to its practical owner;
- explain why server data is not ordinary global state;
- model URL state as a serializable public view;
- design history and back-button behavior intentionally;
- separate form drafts from domain models;
- distinguish touched, dirty, validation, and submission state;
- use reducers and state machines for explicit transitions;
- choose local state, context, or a store proportionately;
- review a feature for duplicated or misplaced state.
The central principle
State architecture is the deliberate placement of values according to ownership, lifetime, sharing, persistence, and transition rules.
The right question is not “which state library should we use?”
The better question is “what kind of state is this, and who is responsible for it?”
Local UI state
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(0);Local state is usually:
- interaction-specific;
- short-lived;
- not useful to unrelated routes;
- safe to discard when the component leaves the tree.
Keep it local unless another owner genuinely needs it.
Domain state
type ApprovalState =
| { status: "draft" }
| { status: "submitted"; submittedAt: string }
| { status: "approved"; approvedBy: string }
| { status: "rejected"; reason: string };Domain state represents business meaning and rules.
It should not be confused with whether a modal is open or a request is currently loading.
Derived state should usually be calculated
const visibleProducts = products
.filter(matchesQuery)
.sort(compareProducts);If visibleProducts is directly calculable from source state, storing it separately creates another value that can become stale.
Use memoization only when repeated computation is measurably expensive.
Global state has a cost
Global state can create:
- invisible dependencies;
- broad update scope;
- unclear ownership;
- difficult isolated tests;
- stale values that survive too long;
- accidental coupling between features.
Make a value global because its lifetime and sharing demand it, not because global access is convenient.
Reducers make transitions explicit
type Action =
| { type: "searchChanged"; query: string }
| { type: "submitted" }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "searchChanged": return { ...state, query: action.query };
case "submitted": return { ...state, status: "submitted" };
case "reset": return initialState;
}
}The transition vocabulary makes state changes inspectable and testable.
Derive validation where practical
const emailError = email.length === 0
? "Email is required"
: isEmail(email) ? undefined : "Email is invalid";Do not store every validation message if it can be derived from the current draft and interaction state.
Store server results and asynchronous validation when they are not directly calculable.
Administrative catalogue: classify before building
URL: query, filters, sort, page
server: products, categories
local UI: modal, focused row
form: draft product, touched, dirty, errors
derived: visible rows, result count
persistent: table density preferenceThe map explains where each value should live.
Common state smells
Watch for:
- state copied from props without a reset rule;
- derived values stored as state;
- everything placed in a global store;
- one value duplicated in URL and component state;
- effects used to synchronize local copies;
- server data copied into forms continuously.
Each smell suggests competing owners.
Routing smells
Warning signs:
- route reflects implementation rather than domain;
- important view state disappears on refresh;
- back button behaves surprisingly;
- child navigation destroys useful parent context;
- a rarely used route inflates the initial bundle;
- invalid parameters reach data-fetching code unparsed.
A complex edit form model
type EditState = {
draft: ProductDraft;
initial: ProductDraft;
touched: Set<string>;
errors: Record<string, string>;
status: "idle" | "saving" | "saved" | "error";
};The model separates current values, comparison baseline, interaction history, validation, and submission lifecycle.
A form reducer makes operations visible
type FormAction =
| { type: "fieldChanged"; name: string; value: string }
| { type: "fieldBlurred"; name: string }
| { type: "submitted" }
| { type: "reset" };Explicit actions make it possible to test dirty state, touched state, validation, and reset behavior as transitions.
State ownership and testing
Clear ownership makes focused tests possible:
- parser tests for URL state;
- reducer tests for transitions;
- form tests for validation and dirty behavior;
- route tests for history and redirects;
- server-state tests for loading and stale responses.
If every test needs the whole application, ownership may be too global.
State ownership and team scale
As a team grows, implicit ownership becomes expensive.
Document:
- which module owns a value;
- which API changes it;
- what is public URL state;
- what is cached server data;
- what can be persisted;
- which transitions are legal.
Architecture reduces coordination cost when boundaries are explicit.
Practical lab: Routed Administrative Catalogue
Build a catalogue whose filters, sorting, pagination, and selected view are represented in the URL when they should survive reload, sharing, and history navigation.
Then add a routed edit form with explicit ownership for draft, validation, server state, and workflow transitions.
Troubleshooting guide (Part 2)
| Symptom | Likely cause |
|---|---|
| Form loses work on navigation | Dirty policy is undefined |
| Validation flickers | Draft, touched, and errors are conflated |
| Store contains everything | State categories were never classified |
| Child route feels like a full reset | Parent context or layout is not preserved |
Completion checklist
- every important value has a category and owner;
- derived values are not duplicated unnecessarily;
- server state has freshness and failure semantics;
- URL state is serializable, validated, and shareable;
- history semantics are intentional;
- form drafts are separate from accepted domain commands;
- dirty, touched, and validation state have distinct meanings;
- reducers or state machines model meaningful transitions;
- persistence is versioned and safe;
- route and form behavior is tested from the user’s perspective.
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
| State management means choosing a library | First classify ownership and lifetime |
| All shared state belongs in a global store | Share only across real consumers |
| Server data becomes ordinary client state | It has freshness, cache, and synchronization rules |
| The URL is just routing | It is public, serializable view state |
| Every option belongs in the URL | Ephemeral and sensitive values have other owners |
| Forms are collections of controlled inputs | Forms are workflows with drafts and transitions |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| Controlled forms are always better | Choose based on coordination needs |
| Draft and domain models must match | Input representation can differ from accepted data |
| Dirty and touched mean the same thing | They record different interaction facts |
| A reducer is only for global state | Local complex transitions benefit too |
| Back/forward is only the router’s problem | History behavior is product architecture |