Ranveer KumarEngineering Essays
Frontend Architecture19 min read

Frontend State Architecture: Local State, Server State, URL State, Global State, and Workflow State

Design frontend state architecture by classifying local, server, URL, global, form, workflow, derived, and stale state ownership.

Updated May 23, 2026

A senior frontend engineer does not ask which state library should I use first. They ask what type of state this is, who owns it, how long it lives, who needs to share it, where it should persist, and what can make it stale.

State architecture is one of the fastest ways to separate component thinking from system thinking. A component can hold a value. A frontend system must understand whether that value is local UI state, server/cache state, URL state, global application state, form state, workflow state, derived state, or stale duplicated state that should not exist.

This is part 6 of the . Part 5 covered performance architecture. This article focuses on state ownership and lifecycle: local UI state, server/cache state, URL state, global state, form state, workflow/process state, derived state, stale state, state machines, and query key discipline.

State architecture is not choosing Redux, Zustand, React Query, or URL params. It is deciding which data belongs to which owner and how it changes over time.

Why This Matters for Senior Frontend Roles

Most frontend bugs are state bugs wearing different clothes. A stale cache looks like a data bug. A duplicated derived value looks like a rendering bug. A global store used as a convenience layer looks like an architecture bug. A form that overwrites server changes looks like a workflow bug. A URL that cannot restore the screen looks like a product quality bug.

Senior frontend engineers are expected to prevent these classes of bugs by naming state ownership early.

They ask:

  • Is this value created by the user, the server, the URL, the app shell, or a workflow?
  • Does it survive navigation, refresh, login, or tab restore?
  • Is it shareable?
  • Can it be stale?
  • Does it need optimistic updates?
  • Does it require validation?
  • Is it derived from another source and therefore should not be stored?
  • Does it belong in a state machine because transitions matter?

State libraries are useful after those answers exist. They are dangerous before.

Problem Framing and Constraints

Imagine a multi-step approval workflow. The page has server data for the request, local UI state for expanded sections, URL state for selected tab and filters, form state for comments, global application state for current user and feature flags, and workflow state for draft, submitted, approved, rejected, or needs changes.

If all of this goes into one global store, every change becomes harder to reason about. If all of it stays local, the workflow becomes hard to restore, share, observe, and coordinate across routes.

Good state architecture separates:

  • Owner: browser, URL, server, app shell, feature, workflow, or derived computation.
  • Lifetime: render, component, route, session, persisted, server canonical.
  • Persistence: none, URL, memory cache, storage, server.
  • Shareability: local only, route shareable, cross-route, cross-tab, cross-user.
  • Sync need: never synced, refetched, invalidated, optimistic, real time.
State classification matrixA matrix classifies local, server, URL, global, form, workflow, and derived state by owner, lifetime, persistence, shareability, and sync need.TypeOwnerLifetimePersistenceShareableSync needlocal UIcomponentshortmemorynononeserverbackendcanonicalcache/serverby contractinvalidateURLroutenavigationaddress baryesparseglobalapp shellsessionmemory/storagecross-routeexplicitworkflowprocessstep basedserver + UIrole basedtransitionderivedsource datacomputedavoid storingdependsrecompute
State Classification MatrixClassify state by owner, lifetime, persistence, shareability, and sync need before choosing storage or libraries.

Architecture Mental Model

Use the narrowest state boundary that preserves the user experience.

Local state belongs inside a component or feature when it is purely presentational: open/closed, hover-like intent, focused row, temporary disclosure, local tab within a panel. It should not leak into global stores just because a sibling needs it once. Lift it only as far as needed.

Server state belongs to the backend. The frontend may cache it, display it, optimistically update it, or invalidate it, but it does not own truth. This distinction matters because server state can become stale.

URL state belongs in the route. Filters, sort order, selected tab, search query, and pagination cursor often belong here because users expect refresh, sharing, back/forward, and deep links to work.

Global state belongs to the app shell only when it is truly cross-cutting: authenticated user summary, theme, locale, feature flag snapshot, global navigation, or active organization. If every feature puts local choices into global state, the app becomes a maze.

Form state belongs to the form lifecycle. It may start from server data, but editing creates a draft. That draft needs validation, dirty tracking, reset behavior, and conflict rules.

Workflow state belongs to the process. It is not just a current screen. It defines allowed transitions, roles, side effects, and recovery paths.

Derived state should usually be computed from sources. Storing derived state creates drift unless you have a clear invalidation rule.

State Classification Code

A simple classification object helps teams discuss state before choosing a tool.

export enum FrontendStateKind {
  LocalUi = "local-ui",
  Server = "server",
  Url = "url",
  GlobalApp = "global-app",
  FormDraft = "form-draft",
  Workflow = "workflow",
  Derived = "derived"
}

export type StateClassification = {
  kind: FrontendStateKind;
  owner: "component" | "route" | "backend" | "app-shell" | "workflow";
  lifetime: "render" | "component" | "route" | "session" | "persistent";
  persistence: "none" | "url" | "memory-cache" | "storage" | "server";
  shareability: "local" | "route" | "cross-route" | "cross-tab" | "cross-user";
  staleRisk: "none" | "low" | "medium" | "high";
  syncStrategy: "none" | "derive" | "invalidate" | "optimistic" | "realtime";
};

export const stateClassificationExamples = {
  selectedInvoiceTab: {
    kind: FrontendStateKind.Url,
    owner: "route",
    lifetime: "route",
    persistence: "url",
    shareability: "route",
    staleRisk: "none",
    syncStrategy: "none"
  },
  invoiceDetails: {
    kind: FrontendStateKind.Server,
    owner: "backend",
    lifetime: "session",
    persistence: "memory-cache",
    shareability: "cross-user",
    staleRisk: "medium",
    syncStrategy: "invalidate"
  }
} satisfies Record<string, StateClassification>;

This makes state review concrete. If a value has owner: "backend" and staleRisk: "high", it probably does not belong in a generic global store without invalidation.

State Ownership and Lifecycle

State architecture should make lifecycle visible: creation, read, mutation, sync, invalidation, and disposal.

State ownership and lifecycle diagramA lifecycle flow connects create, read, mutate, sync, invalidate, and dispose with owner responsibilities.createreadmutatesyncdisposeLifecycle bugs happen when mutation or disposal is owned by nobody.
State Ownership and LifecycleEvery state type should have an owner, creation point, mutation path, sync strategy, and disposal rule.

URL State for Filters

URL state is underrated. If a screen can be refreshed, shared, bookmarked, or navigated with back/forward, route-level state should usually live in the URL.

type InvoiceFilterState = {
  status?: "open" | "paid" | "overdue";
  owner?: string;
  page?: string;
  sort?: "created-desc" | "created-asc" | "amount-desc";
};

export function readInvoiceFilters(params: URLSearchParams): InvoiceFilterState {
  return {
    status: parseStatus(params.get("status")),
    owner: params.get("owner") ?? undefined,
    page: params.get("page") ?? undefined,
    sort: parseSort(params.get("sort"))
  };
}

export function writeInvoiceFilters(filters: InvoiceFilterState) {
  const params = new URLSearchParams();

  if (filters.status) params.set("status", filters.status);
  if (filters.owner) params.set("owner", filters.owner);
  if (filters.page) params.set("page", filters.page);
  if (filters.sort) params.set("sort", filters.sort);

  return params.toString();
}

The trade-off is that URL state must be serializable, compact, and stable. Do not put sensitive data, large drafts, or volatile local UI state in the URL.

Server State Query Keys

Server state needs cache identity. Query keys should include every field that changes the server response.

export const invoiceKeys = {
  all: ["invoices"] as const,
  list: (tenantId: string, filters: InvoiceFilterState) =>
    [
      ...invoiceKeys.all,
      "list",
      tenantId,
      {
        status: filters.status ?? "all",
        owner: filters.owner ?? "any",
        page: filters.page ?? "first",
        sort: filters.sort ?? "created-desc"
      }
    ] as const,
  detail: (tenantId: string, invoiceId: string) =>
    [...invoiceKeys.all, "detail", tenantId, invoiceId] as const
};

If a cache key omits tenantId, status, or sort, the user can see data that belongs to the wrong query. Cache bugs are often state classification bugs.

Workflow State Machines

Workflow state is different from UI state. It has allowed transitions, guards, side effects, and role-specific actions. Model it explicitly.

Workflow state machine for approvalA workflow moves from draft to submitted, review, approved, rejected, or changes requested with explicit transitions.draftsubmittedreviewapprovedrejectedchanges requested returns to draft with reason
Approval Workflow State MachineWorkflow state defines allowed transitions and recovery paths for a multi-step approval flow.
type ApprovalState = "draft" | "submitted" | "review" | "approved" | "rejected";
type ApprovalEvent =
  | { type: "submit"; actorRole: "author" }
  | { type: "startReview"; actorRole: "reviewer" }
  | { type: "approve"; actorRole: "reviewer" }
  | { type: "reject"; actorRole: "reviewer"; reason: string }
  | { type: "requestChanges"; actorRole: "reviewer"; reason: string };

export function transitionApproval(
  state: ApprovalState,
  event: ApprovalEvent
): ApprovalState {
  switch (state) {
    case "draft":
      return event.type === "submit" ? "submitted" : state;
    case "submitted":
      return event.type === "startReview" ? "review" : state;
    case "review":
      if (event.type === "approve") return "approved";
      if (event.type === "reject") return "rejected";
      if (event.type === "requestChanges") return "draft";
      return state;
    default:
      return state;
  }
}

In production, guards, side effects, and server authority matter. The client state machine should guide the UI, but the backend should enforce the transition.

Anti-Patterns

Anti-pattern map for frontend stateThree anti-patterns point to consequences and the corrective architecture rule.duplicatedserver statestale UIglobal storeabusehidden couplingstored derivedstatedriftURL state keptonly locallylost restore
Frontend State Anti-Pattern MapDuplicated state, global store abuse, and stale derived state create bugs because ownership and lifecycle are unclear.

The most common anti-pattern is copying server state into a global client store "so components can access it." That bypasses cache invalidation and turns stale state into product behavior. Another common anti-pattern is storing derived state instead of deriving it. If subtotal, tax, and total are all independently stored, one of them will drift.

Trade-Offs and Decision Matrix

DecisionOption AOption BSenior trade-off
FiltersURL stateLocal component stateURL state supports sharing, refresh, and history. Local state is simpler for ephemeral controls.
Server dataQuery/cache layerGlobal store copyCache layers support invalidation and freshness. Global copies create stale data unless carefully synchronized.
WorkflowState machineBoolean flagsState machines make transitions explicit. Booleans are quick but break as flows grow.
FormsLocal draft stateImmediate server mutationDraft state supports validation and review. Immediate mutation can work for autosave but needs conflict handling.
Derived valuesCompute from sourceStore separatelyComputation prevents drift. Stored derived values need invalidation rules.

Failure Modes and Recovery Design

State architecture failures are predictable:

  • A global store holds server data that never invalidates.
  • A URL cannot restore the user's filtered view after refresh.
  • A form draft overwrites newer server data after the page sits open.
  • Two components store the same selection and disagree.
  • A workflow uses booleans like isSubmitted and isApproved, allowing impossible combinations.
  • Derived totals drift from line items.
  • A cache key omits tenant, filter, or role context.
  • Optimistic state is never reconciled after server rejection.

Recovery starts with ownership. If server state is stale, invalidate or refetch. If form state conflicts, show a merge or reset path. If URL state is invalid, parse safely and fall back. If workflow state reaches an impossible transition, block the UI and ask the server for canonical state.

Performance, Accessibility, Security, and Observability

Performance suffers when state is too broad. Updating a global store for local UI state can re-render unrelated surfaces. Overly large providers can make every route pay for state it does not use. Derived state should be memoized only when expensive and measured.

Accessibility depends on state clarity. Focus state, validation state, expanded/collapsed state, and workflow state all affect what assistive technology users experience. Do not replace focused content unexpectedly because a broad state update re-rendered a subtree.

Security requires treating client state as untrusted. Role flags and permissions can guide the UI, but backend policy must enforce read, write, and transition authority. Do not store sensitive state in URLs or persistent browser storage.

Observability should track invalid query states, cache misses, stale data windows, failed optimistic updates, workflow transition failures, form conflict rates, and route restore failures.

How to Explain This in a Senior Frontend System Design Interview

Start with taxonomy:

Before choosing a state library, I would classify state by owner, lifetime, persistence, shareability, and sync needs. Then I would choose the narrowest storage boundary that preserves the user experience.

Then explain:

  1. Local state for ephemeral UI.
  2. URL state for shareable route context.
  3. Server state in a cache with query keys and invalidation.
  4. Form draft state with validation, dirty tracking, and conflict rules.
  5. Global state only for truly cross-cutting app concerns.
  6. Workflow state as a transition model or state machine.
  7. Derived state computed from canonical sources.

This demonstrates architecture thinking because it separates state ownership from library preference.

Production-Readiness Checklist

  • Every important state value has a named owner.
  • State lifetime is explicit: render, component, route, session, persistent, or server canonical.
  • URL state is used for shareable and restorable route context.
  • Server state stays in a cache/query layer with complete query keys.
  • Global state is limited to cross-cutting app concerns.
  • Form state has dirty tracking, validation, reset, submit, and conflict behavior.
  • Workflow state has explicit transitions and server authority.
  • Derived state is computed unless there is a documented invalidation rule.
  • Optimistic state has reconciliation and rollback.
  • Sensitive state is not stored in URLs or unprotected browser storage.
  • Telemetry captures stale state, failed transitions, conflict rates, and restore failures.

Read the Full Series

Closing

State architecture gets easier when you stop treating state as a place to put values and start treating it as ownership over time.

Choose the owner first. Choose the lifecycle second. Choose the library third.

Related Articles

Continue the thread