React Application Architecture: State, Data, and Routing

#react application
Sandor Farkas - Co-founder & CTO of Wolf-Tech

Sandor Farkas

Co-founder & CTO

Expert in software development and legacy code optimization

React Application Architecture: State, Data, and Routing

React apps rarely become “hard to change” because of React itself. They become hard to change when three concerns blur together:

  • State that has different lifecycles (server data vs UI toggles vs URL state) gets stored in the same place.
  • Data fetching and mutations happen ad hoc (components calling fetch directly, inconsistent caching, no invalidation discipline).
  • Routing is treated as navigation only, instead of a first-class architecture surface (loading, authorization, boundaries, code-splitting).

A solid React application architecture makes these boundaries explicit. You end up with fewer regressions, more predictable performance, and a codebase teams can scale without rewriting every 12 months.

This guide focuses on a practical baseline for React application architecture around state, data, and routing, and how they fit together.

The baseline mental model: 4 kinds of state (not 1)

If you take only one architectural rule from this article, make it this one:

Do not treat “state” as one thing. Treat it as separate categories with different sources of truth.

The 4 categories you should design for

State categorySource of truthLifetimeExampleWhere it should live (default)
Server stateBackend/APIShared across users and sessions (with caching)Current user profile, list of invoicesA server-state cache (TanStack Query, RTK Query, SWR)
Client UI stateBrowser/runtimeLocal to one sessionSidebar open, active tab, dialog openLocal component state, small client store (Zustand/Jotai/Redux)
URL stateThe route (path + query)Shareable/bookmarkable?page=3&status=open, selected entity IDRouter state (React Router, framework router)
Form stateThe form (usually transient)Until submit/resetValidation errors, dirty fields, field arraysForm library (React Hook Form) + schema validation (Zod)

When these get mixed (for example, putting server state into Redux “because we already have Redux”), you usually pay in duplicated caches, inconsistent loading states, and complex invalidation.

If you want a deeper feature-first module approach, Wolf-Tech’s companion piece on React front end architecture for product teams goes wider on folder boundaries, dependency rules, and scaling patterns.

Routing: treat routes as architecture seams, not just URLs

Routing determines more than “what page shows.” In a production app, the route is where you can enforce:

  • Data dependencies (what must be loaded before render)
  • Authorization boundaries (what permissions/tenant context is required)
  • Error boundaries (what fails gracefully vs crashes the whole shell)
  • Code splitting (what bundles are loaded per area)
  • Caching policy (especially in full-stack frameworks)

Even in a classic SPA (React + React Router), you can design routes as explicit “entry points” that own orchestration.

A practical route responsibility checklist

ConcernShould be decided at the route level?Why it matters
What data is required to renderYesKeeps data dependencies visible and consistent
What happens on missing/invalid paramsYesPrevents scattered defensive code
Permission checks and redirectsYesAvoids security logic living inside random components
Error UI for the sectionYesLimits blast radius (section-level resilience)
Lazy loading for the sectionYesKeeps bundles small and predictable

For SPA routing patterns, React Router’s data APIs (loaders/actions) are worth considering because they encourage route-owned data orchestration.

Data architecture: make server state boring and consistent

Most React apps spend more time waiting on I/O than “rendering.” The highest leverage architecture decisions are often in data access:

  • How you fetch
  • How you cache
  • How you invalidate
  • How you handle errors and retries
  • How you keep types/contracts aligned

Default: adopt a server-state library

If your app talks to an API, you almost always want a dedicated server-state layer. It is not about “state management preference,” it is about having one predictable system for:

  • Deduping requests
  • Caching and stale-while-revalidate behavior
  • Background refetch
  • Mutation and invalidation
  • Retry, backoff, cancellation

Common options:

Wolf-Tech’s broader toolkit recommendations are summarized in React tools: the essential toolkit for production UIs.

Decide your “data contract” strategy early

Your UI is only as stable as your contracts. Two pragmatic defaults:

  • Contract-first schemas at the boundary: validate and type responses with a runtime schema (for example, Zod) so API drift fails loudly and early.
  • Typed fetch layer: one shared HTTP client wrapper (timeouts, headers, error mapping), used by all features.

If you like seeing this as a concrete feature slice, Wolf-Tech’s React tutorial: build a production-ready feature slice demonstrates a clean pattern (contracts, hooks, error handling, telemetry, tests) without turning every screen into a framework.

How state, data, and routing should flow together

A scalable approach is to make the route the orchestration point, the server-state cache the source of truth for backend data, and UI state strictly local.

A good architecture tends to look like this:

  1. Route parses URL state (params, querystring).
  2. Route triggers data requirements (loader, prefetch, or route component that mounts queries).
  3. Server-state library owns caching, retries, and invalidation.
  4. UI components render from cached server state plus local UI state.
  5. Mutations write via the server-state layer, then invalidate or update caches.

A simple architecture diagram showing a route box reading URL params, calling a data loader layer, which talks to an API and populates a cache, then UI components render from the cache. Include an error boundary around the route section.

A pragmatic folder and dependency layout (works for most product teams)

React architecture debates often get stuck on folder bikeshedding. Instead, pick a structure that makes the boundaries enforceable.

A common baseline for a React SPA:

  • src/app/ for app shell, routing, providers, and global initialization
  • src/features/<feature>/ for feature modules (screens, local hooks, local UI)
  • src/shared/ for truly shared UI primitives and utilities
  • src/api/ (or src/data/) for the HTTP client, query key factory, and contract schemas

The crucial rule is not the names, it is the direction of dependencies:

  • Features can depend on shared and api.
  • shared should not depend on features.
  • api should not import UI.
  • Routes compose features, not the other way around.

If you need a team-friendly way to roll standards out incrementally, see React development playbook: standards for teams.

State architecture: what should be global (and what should not)

Default rule: keep UI state local until it hurts

Global stores are easy to add and hard to unwind. A simple heuristic:

  • If state is only used in one screen or one feature, keep it in that feature.
  • If state is used across many unrelated features, make it global.
  • If state must be shareable via link, put it in the URL.

Avoid the “single store for everything” trap

A large Redux store that holds:

  • API data
  • UI flags n- router-ish stuff

…often becomes a second application inside your application.

A better split:

  • Server state in TanStack Query (or equivalent)
  • UI state in local component state, and small stores only when needed
  • URL state in the router
  • Form state in a form library

This separation also improves testing: you can test UI without mocking an entire global store, and you can test data hooks with a predictable query client.

Routing patterns that prevent complexity later

Pattern 1: Route-level “section shells” with boundaries

In a real product, you usually have areas with different behavior:

  • A public marketing area
  • An authenticated app area
  • An admin area

Treat each as a section with its own:

  • Layout
  • Error boundary
  • Auth guard
  • Data prefetch (optional)

This keeps “app concerns” from leaking into the wrong areas.

Pattern 2: URL state as the integration point for list views

Lists almost always need:

  • Pagination
  • Sorting
  • Filters
  • A selected row

Make those URL-driven by default so:

  • Users can bookmark and share
  • Back/forward works naturally
  • You can reproduce bugs from a copied URL

A practical approach is to parse query params at the route entry and pass typed values into your query hooks.

Pattern 3: Route-based code splitting

Route-based code splitting is usually the highest ROI form of lazy loading because it aligns with user navigation.

If you use React Router, you can lazy load route elements. If you use a framework (Next.js, Remix), code splitting tends to come “for free” with the route boundary.

If you are evaluating whether you should stay SPA or move to a React framework, Wolf-Tech’s Next.js and React decision guide for CTOs is a practical comparison focused on constraints (performance, security boundaries, operations), not hype.

Data fetching and mutations: the rules you want written down

Most teams need a small “data constitution.” Here is a production-friendly baseline.

1) Standardize query keys and invalidation

Pick a query key pattern that matches your domain. Example conceptually:

  • ['projects', { orgId, filters }]
  • ['project', { projectId }]

Then standardize invalidation rules:

  • Mutating a project invalidates ['project', { projectId }] and any list keys that can include it.
  • Mutating a membership invalidates member lists, not everything.

2) Put mutations behind a feature API

A good pattern is to expose feature-level operations:

  • useInviteMemberMutation()
  • useUpdateProjectMutation()

…and keep raw HTTP calls inside src/api/.

This keeps screens from becoming the place where you “figure out how the backend works.”

3) Normalize error handling

Users do not care if the error came from Axios, fetch, a proxy, or a GraphQL gateway.

Normalize errors into a small set of UI-relevant categories:

  • Unauthorized (needs login/refresh)
  • Forbidden (permission issue)
  • Not found
  • Validation error (field-level)
  • Conflict (stale data)
  • Transient/network (retryable)

Then define where each is handled:

  • Route-level for “cannot enter this page” issues
  • Feature-level for “submit failed” issues
  • Component-level for inline validation

4) Be explicit about loading UX

A common anti-pattern is “every component shows its own spinner.” That creates jitter.

Instead, decide loading behavior at boundaries:

  • Route: skeleton for the page
  • Feature: inline placeholders for a section
  • Component: only for truly small embedded pieces

If you work in Next.js App Router, loading and error boundaries are first-class. Wolf-Tech’s Next.js best practices for scalable apps covers those patterns in detail.

Choosing tools: a decision table that keeps you honest

Tool sprawl is an architecture problem. Here is a pragmatic mapping that works in many teams.

ConcernBest defaultWhen to choose something else
Server stateTanStack Query (or RTK Query)If you have a GraphQL client with normalized cache needs (Apollo, urql)
Client UI stateLocal state first, then Zustand/JotaiRedux if you truly need centralized event logging, complex cross-feature workflows
URL stateRouter params + query parsingA dedicated URL state helper if you have heavy querystring logic
FormsReact Hook Form + schema validationFormik only if you already have deep investment and it is stable
RoutingReact Router (SPA)Next.js/Remix when you need SSR, better caching boundaries, or framework conventions

The point is not the specific libraries, it is keeping the responsibilities separated.

Common failure modes (and the fix)

Failure mode: “Everything is a hook inside components”

Symptoms:

  • Components call fetch directly
  • No consistent retries/timeouts
  • Caching is accidental
  • Loading and error UI is inconsistent

Fix: introduce a thin data layer (api client + queries/mutations), and make features call that.

Failure mode: “We used Redux for server data and now it’s hard”

Symptoms:

  • Duplicated caching logic
  • Manual refetch, manual invalidation
  • Inconsistent stale data behavior

Fix: migrate server state to a server-state library incrementally. Start with one feature and measure reduction in code and bugs.

Failure mode: “Routes are just a list of components”

Symptoms:

  • Auth checks duplicated across screens
  • Querystring parsing duplicated
  • No section-level error boundaries

Fix: introduce route shells (layout + guard + boundary), and push orchestration up to routes.

A lightweight “architecture baseline” you can adopt in 1 sprint

If you need a plan that does not require a rewrite, aim for an incremental baseline:

  • Define state categories (server, UI, URL, form) and write them into your team standards.
  • Add a shared API client wrapper (timeouts, auth headers, error mapping).
  • Pick one server-state library and adopt it in one feature slice.
  • Refactor one route area into a section shell with an error boundary and an auth guard.
  • Standardize query key patterns and invalidation rules for that slice.

Once the baseline exists, scaling becomes repetition, not reinvention.

When to bring in an outside architecture review

If your React app is already shipping, the goal is rarely “new architecture.” It is usually:

  • Reduce regression rate
  • Improve performance predictability
  • Make feature work cheaper
  • Make onboarding faster

Wolf-Tech specializes in full-stack development and architecture work, including code quality consulting and legacy optimization. If you want a second set of eyes on your React application architecture (state boundaries, data layer, routing seams, and change-safety practices), you can explore Wolf-Tech at wolf-tech.io and use the relevant blog guides as a starting point for an evidence-based review.