Blog
Jun 27, 2021 - 18 MIN READ
Frontend Systems: Routing, State, Forms, and the “Boring Stack” That Scales

Frontend Systems: Routing, State, Forms, and the “Boring Stack” That Scales

The month I stop treating “frontend architecture” as component trivia and start treating it as a system: URLs as contracts, state as truth, forms as transactions, and a boring stack you can operate.

Axel Domingues

Axel Domingues

If May was about React as an architectural tool, June is about the thing React doesn’t give you:

A system.

Because real frontends don’t fail in the component tree. They fail at the boundaries:

  • a URL that can’t represent reality (deep links, back button, shareable state)
  • a state model that lies (stale data, conflicting writes, “global store” chaos)
  • a form flow that double-submits money or loses user intent
  • a UI that’s “fast in the lab” and janky under real devices + real networks

So this month I’m going to define the boring frontend stack:

Not boring because it’s simple.

Boring because it’s the part you want to stop debating and start standardizing, so your product can scale teams, features, and reliability without re-litigating fundamentals every sprint.

The goal this month

Turn routing, state, and forms into explicit architecture decisions — with checklists you can teach and repeat.

The output

A “boring stack” reference architecture you can adopt, audit, and evolve — without ideology.


The Frontend Is a Distributed System (You Just Don’t Call It That)

The browser is an unreliable runtime:

  • CPU varies wildly (old phones exist; tabs get throttled)
  • the network lies (latency, packet loss, captive portals, offline)
  • the user is adversarial (double clicks, refreshes, goes back, pastes weird input)
  • the page can die at any time (crash, navigation, memory pressure)

So when teams say “the frontend is hard”, what they mean is:

There’s a lot of implicit distributed-systems behavior hiding inside UI interactions.

This article is how you make that behavior explicit.


The Boring Stack: Four Boundaries You Must Get Right

Here’s the mental model I want you to internalize:

Routing boundary

The URL is the public API of your UI. If it lies, everything else becomes hacks.

State boundary

Your app has multiple “truth sources.” If you don’t classify them, you’ll fight ghosts.

Form boundary

A form is a transaction builder. Treat it like one: validation, idempotency, failure recovery.

Data boundary

Server state isn’t “frontend state.” Cache it, invalidate it, and stop copying it into global stores.

If you do nothing else: write these boundaries down and make them reviewable.


Routing: The URL Is Your Product Contract

A good URL is not “a path.” It’s a projection of user intent into a stable contract:

  • “show me order 123”
  • “open search results for query=… and filters=…”
  • “take me to step 3 of onboarding”
  • “show this dashboard scoped to account X”

If you treat routing as an implementation detail, you’ll end up with:

  • pages you can’t deep-link
  • broken back/forward semantics
  • “state stored in memory” that disappears on refresh
  • analytics that can’t attribute flows cleanly
  • support tickets that can’t reproduce issues (“what URL were you on?”)
Architecture rule: anything a user can reasonably want to bookmark or share must be representable in the URL.

What belongs in the URL (and what doesn’t)

The URL is great for:

  • identity: resource IDs, slugs, tenant/account context
  • navigation state: current tab, selected item, page number, sort, filters
  • reproducible UI intent: “show these results”, “open this modal for item X” (yes, modals can be routable)

The URL is not great for:

  • private secrets (tokens, PII)
  • large payloads
  • volatile internal UI state (a partially typed form field)
If the answer to “can support reproduce this?” is “only if they keep the tab open”… you put the wrong thing outside the URL.

Choosing a Router: Not a Library Choice, a Capability Choice

People compare routers like a dependency list.

Architects compare routers by capabilities:


State: Stop Saying “Global State” (Classify Your Truth Sources)

Most frontend architecture fights come from one category error:

treating all state as one thing.

A scalable frontend distinguishes at least these types:

UI state (ephemeral)

Open/closed, hover, selected row, local toggles. Lives near components.

URL state (shareable)

Filters, pagination, active tabs, selected entity. Must survive refresh.

Server state (cached)

Data from APIs. Must handle staleness, retries, invalidation, dedupe.

Client domain state (owned)

Drafts, optimistic updates, complex editors. You own the source of truth.

The “state placement” decision tree

When you’re about to add state, ask these in order:

Can it be derived?

If the value can be computed from props / existing state, don’t store it. Derived state is a common source of bugs.

Should it be shareable or bookmarkable?

If yes, put it in the URL (search params / path params).

Is it server state?

If the data comes from the server, treat it as cached:

  • fetch + dedupe
  • retry + backoff
  • invalidate + refetch
  • stale-while-revalidate behavior

Is it truly cross-cutting client state?

Only then consider a store (context/store library). Keep the surface area small.

The most expensive frontend anti-pattern is “copy server state into a global store.”

You lose cache semantics and you invent your own invalidation bugs.


The “Boring” State Stack That Scales

This is the pragmatic stack I keep seeing win (and why it’s boring):

  • Local component state for UI details
  • URL state for navigational intent
  • Server-state cache for API data (queries/mutations, invalidation)
  • A small client store for the rare shared client-owned domain state

That’s it.

Everything else is bikeshedding.

What makes this scale is not the libraries. It’s the separation of responsibilities.

What should go into a client store (and what shouldn’t)

Good candidates

  • authenticated user session summary (not the whole profile payload)
  • feature flags / experiments (read-only, cached)
  • cross-page drafts (e.g., “compose email”)
  • UI preferences that aren’t worth a backend roundtrip (theme, density)

Bad candidates

  • lists of server entities (“projects”, “orders”, “messages”)
  • “everything is in Redux because we started in 2017”

Forms: A Form Is a Transaction Builder

A form is where user intent becomes a write.

That’s a transactional boundary. Treat it with the same seriousness you treat a backend write path.

A form is a state machine

When you design forms explicitly, you stop shipping “mystery UX”:

Idle → Editing

Inputs update locally, validation is friendly, UI is responsive.

Editing → Submitting

Disable double submit, show progress, preserve user intent.

Submitting → Success

Route or update cache, show confirmation, clear draft.

Submitting → Failure

Keep inputs, map errors to fields, provide recovery, avoid rage-click loops.

The three validation layers (and why you need all of them)

Your best form UX isn’t “no errors.” It’s errors that land exactly where the user can fix them.

The “Money Button” Pattern: Idempotency + UX

If a form triggers anything expensive or irreversible (payments, subscriptions, transfers, order placement), you need a combined UX + systems design pattern:

  • disable the button on submit
  • show a stable progress state
  • attach an idempotency key to the request
  • treat retries as normal (network failures are normal)
If your system can create duplicate orders when the user refreshes at the wrong time, you don’t have a frontend bug.You have an architecture bug.

A simple model that works well:

  • generate an idempotency key per “intent” (per submit attempt)
  • store it in memory + (optionally) session storage for refresh survival
  • send it with the request
  • backend uses it to dedupe and return the same result

This is where frontend architecture and backend architecture meet — and why I teach them together.


A Reference “Boring Stack” Blueprint

I’m not prescribing an ideology. I’m prescribing roles.

Here’s a stack composition that tends to be stable:

Routing

Nested routes, layout shells, route-level errors, search params as first-class.

Server state

A query cache with invalidation, retries, dedupe, optimistic updates.

Forms

Uncontrolled where possible, schema-driven validation, server error mapping.

Client store (small)

Only for true shared client-owned state: session summary, drafts, flags.

Foldering that supports scale (without over-engineering)

A practical shape (adjust for your framework):

  • routes/ or pages/ — route modules + loaders + route-level error UI
  • features/ — domain slices (billing, search, onboarding)
  • components/ — shared presentational components
  • ui/ — design system wrappers (buttons, inputs, layout primitives)
  • lib/ — api clients, schema, utilities
  • state/ — store + query client configuration
  • forms/ — shared form patterns (field wrappers, error mappers)
Rule of thumb: optimize your structure for change ownership (who edits what), not for aesthetic purity.

The Migration Plan: From “Everything Everywhere” to Boring

If your frontend is already messy, don’t rewrite.

Standardize incrementally:

Step 1: Make URLs honest

Move filters/tabs/pagination into search params. Make deep links work. Fix back button bugs.

Step 2: Stop copying server state into global stores

Introduce a query cache layer. Move “fetch + cache + invalidate” into that layer. Delete duplicated selectors and reducers gradually.

Step 3: Normalize form handling

Pick one form approach for the app. Adopt schema validation. Standardize server error mapping.

Step 4: Document the boundaries

Write down:

  • what belongs in URL state
  • what belongs in server cache
  • what belongs in client store
  • what belongs locally

Then enforce in PR reviews.


Debugging: Why UX Bugs Look Like Backend Bugs

This is the theme of this month:

  • A “random logout” might be a route guard loop.
  • A “slow page” might be a cache miss storm on route transitions.
  • A “double charge” might be a missing idempotency key plus a retry.
  • A “stale UI” might be a query invalidation gap.
  • A “form resets” bug might be a remount caused by route/layout design.

When you classify routing/state/forms as architecture boundaries, these stop being mysteries.


FAQ


What’s Next

June was about building a frontend that can scale features and teams without collapsing into folklore.

Next month, I’m taking the same “boring” mindset to the server side: Backends: Frameworks Don’t Matter Until They Do

Because the frontend stack only stays boring if the backend is predictable: auth, errors, contracts, consistency, and rollout safety.

Axel Domingues - 2026