Frontend Architecture Patterns That Work in Any Stack

William Warne

Software Engineer | Fractional CTO | Founder

Introduction

Frontends get messy when server data and UI state mix, when every component talks to the API, and when there’s no single place the app treats as truth. The result: duplicate fetches, bugs that only show up in one view, and AI or new devs producing inconsistent code. The fix isn’t a particular framework—it’s a set of patterns that work in any stack. This post lays them out in tool-agnostic terms so you can apply them whether you use React, Vue, Svelte, or something else.

Two Kinds of State

Server-backed data — Anything that comes from your API or backend and can change when the server says so. Examples: user profile, list of items, messages, settings. This data should live in one place: a cache (or equivalent) that your app treats as the source of truth. Every screen or component that needs it reads from that cache. You never copy server data into ad-hoc component state or scattered variables; if you do, you get stale or inconsistent UI and bugs that are hard to trace.

UI-only state — Anything that exists only to control the interface: which modal is open, which tab is selected, whether a panel is expanded, the current step in a flow. This state doesn’t need to be re-fetched; it’s local to the session. Put it in scoped containers (e.g. context, store, or composable) that are clearly “UI state only.” Never put server data there. The rule: server data in the cache; UI state in its own place. No overlap.

Single Source of Truth and the API Layer

Single source of truth means: for each logical domain or aggregate (e.g. “messages for this account”), there is one logical source. Derived views (e.g. “messages for this phone number” or “unread count”) read from that source—they don’t trigger a separate fetch that duplicates the same data. That keeps the UI in sync and avoids conflicting copies.

API layer means: all calls to the server go through a dedicated layer (e.g. per domain or feature). Components never call the backend directly. They call hooks, actions, or services that sit in front of the API. That gives you one place to add auth, error mapping, retries, and logging, and it keeps components simple and testable.

Centralised Cache Keys

Whatever you use for caching (query cache, store, or keyed map), the identifiers for that cache should be defined in one place. When you invalidate or update after a mutation, you use those same identifiers. When you add a new screen that needs a subset of the same data, you reuse the same key (or a derived key) so you’re still reading from the same source of truth. Centralising keys makes invalidation predictable and avoids “why didn’t my list update?” bugs.

Stateless, Composable Components

Components work best when they derive what they show from props and from data they get from the cache or UI-state container. They don’t own server data; they receive it (or a loading/error state) from a boundary that does the fetch. That keeps components:

  • Stateless in the sense of “no duplicate server state”
  • Single-responsibility — one clear job per component
  • Composable — easy to combine and reuse
  • Testable — same props in, same output

Put loading and error handling at the boundary where data is first used (e.g. the component that calls the data hook), not scattered in every leaf. That way you have one place to show “loading” or “error” per data dependency.

Optimistic Updates and Realtime

Optimistic updates — When the user does something that hits the server (e.g. send a message, toggle a setting), update the cache immediately so the UI feels instant. When the server responds, either confirm (e.g. replace with the real response) or roll back on failure. That way you get snappy UX without lying to the user when something fails.

Realtime / live data — If you have push, WebSockets, or SSE, feed that data into the same cache (or store) that your normal reads use. Normalise the shape so it matches what the rest of the app expects. Always clean up subscriptions when the component or hook unmounts so you don’t leak listeners or overwrite cache after unmount.

Validation and Error Handling

Validation at the boundary — Validate API inputs and outputs (e.g. request and response shapes) at the edge where data enters your app. Use different shapes for “create” vs “update” where the server expects them. That way invalid or unexpected data never gets into your cache and you avoid runtime surprises.

Error handling — Map server errors to user-facing messages in one place (e.g. in your API layer). Components should see a simple “error” state and a safe message, not raw status codes or stack traces. Prefer showing loading and error at the same boundary where you fetch, so the user knows which part of the screen failed.

File and Feature Organisation

Organise by feature or domain, not by type. Have a clear place for: (1) the API layer and data hooks, (2) cache keys, (3) UI state containers, (4) shared UI components and validation. That makes it obvious where to add a new endpoint, a new screen, or a new piece of UI state, and it keeps the “server state here, UI state there” rule easy to follow.

Conclusion

These patterns—two kinds of state, single source of truth, API layer, centralised cache keys, stateless composable components, optimistic updates and realtime into the same cache, validation at the boundary, and clear organisation—work in any modern frontend. If you use React and Next.js, we’ve written down how we implement them with React Query, React context, and our file structure in React & Next.js frontend architecture. You can also get our standards as an installable pack (Cursor rules, docs, and methodology) so your AI and team follow the same architecture.

Context as Code — Packs and docs for frontend architecture, API design, and documentation. Install a pack, run compile, and keep your stack consistent.