Chuyển đến nội dung chính

The Ultimate Guide to Frontend State Architecture

· 1 phút đọc
Evan Carter
Evan Carter
Senior frontend

TLDR:

The Ultimate Guide to Frontend State Architecture

This in-depth guide explores how to build a scalable and maintainable frontend state architecture using React state, Vue state, modern state management libraries (Redux, Zustand, MobX, Pinia), state patterns, and Feature-Sliced Design, helping teams avoid technical debt and scale confidently in production.

Primary keyword: state management
Secondary keywords: react state, vue state, frontend state
Audience: Mid-level to Senior Frontend Developers, Tech Leads, Architects
Goal: choose the right patterns and structure for scalable, maintainable application state (not just a new library)

State management is where otherwise clean frontends turn into a tangle of global state, inconsistent React state updates, and hard-to-reason-about frontend architecture. Feature-Sliced Design (FSD) on feature-sliced.design provides a modern way to align state with business capabilities, enforce boundaries, and keep a scalable state management strategy. This guide breaks down patterns, libraries, and an architecture-first approach you can apply to React, Vue, and beyond.

Table of contents

Why state management gets hard
A taxonomy of frontend state
Common approaches and trade-offs
Choosing a library (Redux, Zustand, MobX, Pinia)
Architecture principles
Methodologies compared
FSD as a state architecture
Emerging paradigms: atoms, signals, machines
Step-by-step blueprint

Why state management gets hard in large frontend applications

A key principle in software engineering is that complexity grows faster than features unless you actively design boundaries. State is the sharp end of that complexity because it crosses time (async), space (components), and teams (ownership).

Here are the forces that make frontend state and state orchestration hard at scale:

State-space explosion: If you model UI with many independent flags, your possible states grow exponentially. With n boolean flags, the system has 2ⁿ combinations. Ten “simple” flags already implies 2¹⁰ = 1024 possible UI configurations—before you add loading, errors, permissions, and retries.

Mixed concerns in one store: UI state (modals, filters), domain state (cart, auth), and server state (cache, pagination) get shoved into one “app store”. Cohesion drops, coupling rises.

Implicit dependencies: When any module can import any other module’s store, you get “action-at-a-distance”. A small change in one reducer/observable can break distant screens.

Asynchrony and concurrency: Real apps include cancellation, race conditions, optimistic updates, background refetch, offline mode, and real-time streams.

Multiple sources of truth: Routing state, URL params, form state, server cache, and local drafts can all represent “the current value”. Without a policy, bugs feel random.

If you’re fighting prop drilling, “god” contexts, ever-growing Redux slices, or Vuex/Pinia stores that know about UI details, the problem isn’t just choosing “the best state management library”. It’s state architecture.

A practical taxonomy of frontend state

Before you pick patterns or tools, classify what you’re managing. “State management” is really a portfolio of different state types with different failure modes.

UI state vs domain state vs server state

UI state (presentation state)
Examples: open/closed modal, active tab, drag state, client-side sorting, input drafts.
Healthy default: keep it local to the component or widget; derive from props when you can.

Domain state (business state)
Examples: cart items, selected plan, permissions, auth session, feature flags, workflow steps.
Healthy default: model it explicitly, give it a clear owner, and expose a stable public API.

Server state (remote/cache state)
Examples: product list, user profile, paginated search results, GraphQL cache.
Healthy default: use a dedicated server-state cache (queries) with invalidation and deduplication.

A useful mindset: treat these as different systems with different optimizations.

• UI state → locality and render performance
• Domain state → invariants, traceability, correctness
• Server state → caching, synchronization, freshness

Source of truth vs derived state

Source of truth is the minimal state you must store.
Derived state is computed from other state (selectors, computed, memoized values).

Storing derived state creates drift. Prefer:

• Redux selectors / memoization
• MobX computed values
• Vue computed refs
• derived atoms/selectors (atomic state)
• read-only view models

Rule of thumb: If you can recompute it deterministically, don’t store it.

Transient vs persistent state

Transient (ephemeral) state: lives for one interaction (hover, typing).
Persistent state: survives navigation/reload (session, settings), often stored in storage.

Be explicit about persistence. Persistent global stores that also hold transient UI toggles become brittle.

Common approaches to frontend state management and their trade-offs

Most teams climb a ladder of techniques. Each step solves one pain and introduces another.

Local component state

When it shines
• cohesive UI interactions
• fast iteration, minimal surface area
• great for useState/useReducer, Vue ref/reactive

Common pitfalls
• duplicated logic across components
• hard to share across siblings
• “shadow copies” of derived values

A robust methodology for large apps keeps local state local. Don’t centralize by default.

Lifting state up and prop drilling

Move state to the nearest common parent and pass props down (unidirectional data flow).

Pros
• explicit data flow
• easy to test as pure props
• no extra dependency

Cons
• “plumbing” overwhelms business logic
• fragile component APIs
• refactors become expensive

Prop drilling isn’t a failure; it’s feedback that your boundaries don’t match your state scope.

Context and dependency injection

React Context and Vue provide/inject are excellent for stable dependencies:

• theme, i18n, router, analytics, feature flags
• service instances, environment adapters, query clients

They are less ideal as a universal store:

• ownership becomes unclear
• “god context” emerges
• updates may trigger broad re-renders if not carefully segmented

A pragmatic rule:

• Context for configuration and infrastructure
• Stores for mutable domain state

External stores: centralized vs modular

At some scale, you need a state container:

• Redux Toolkit / NgRx (explicit events, predictable updates)
• MobX (reactive observables, computed derivations)
• Zustand / Pinia (lightweight modular stores)
• Atomic state (Jotai/Recoil-like)
• State machines (XState)
• RxJS streams (event-driven state)

The architectural question is: how do we keep coupling low and cohesion high as the app grows?

Pattern-level decision table

ApproachWorks best when…Watch-outs
Local state + propsstate belongs to one UI subtreeprop drilling, duplicated logic
Context-based shared stateshared config/dependency, rarely changing valuesgod contexts, unclear ownership
External store (feature-scoped)multiple screens share domain behaviorcross-feature imports without boundaries
Server-state cache (queries)data comes from backend and needs cachingmixing server state with domain/UI state
State machine / workflowflows have explicit stages & transitionsupfront modeling effort

Choosing a state management library: Redux vs Zustand vs MobX vs Pinia (and what they don’t replace)

Search intent often looks like “best state management library”. In practice, library selection should follow architecture decisions: What must be shared? Who owns it? How is it accessed via a public API?

Leading architects suggest evaluating these criteria:

Predictability: can you reason about updates and invariants?
Debuggability: devtools, logging, traceability, time-travel (where applicable)
Scalability: can multiple teams work without stepping on each other?
Performance: minimal re-renders, efficient derived state
Testability: can you test state logic without UI?
Integration: SSR, hydration, persistence, TypeScript types, tooling

Library comparison (pragmatic, architecture-aware)

LibraryBest fitTypical trade-offs
Redux Toolkitlarge teams, explicit actions/reducers, strong ecosystem, great TypeScript storymore ceremony than minimal stores; risk of mega-slices without boundaries
Zustandsmall API, feature-level stores, easy compositionwithout architectural rules, “import-any-store” becomes de facto global state
MobXhighly reactive UIs, powerful computed stateimplicit dependencies can hide coupling; data flow audits are harder
Pinia (Vue)Vue-first store, Composition API ergonomicsstill needs ownership design; avoid turning Pinia into a dumping ground

What about Redux Toolkit Query, TanStack Query, SWR, Apollo?

These tools focus on server state management:

• caching and deduplication
• background refetch and invalidation
• optimistic updates
• request status and error handling

They complement domain stores rather than replacing them. Use query caches for remote data, and keep domain workflows (drafts, wizards, permissions, cross-step flows) in your domain layer.

A quick rule for React state vs Vue state

• If it’s UI-only and scoped: keep it local (useState, ref)
• If it’s remote/cache: prefer a query cache
• If it’s business behavior shared across screens: use a store, but scope it to a feature/entity/process

Architecture principles for scalable frontend state

A robust state architecture is less about where values live and more about how modules communicate.

Principle 1: Boundaries first, then state containers

State becomes spaghetti when any module can:

• import internal store details
• call actions directly across unrelated features
• read/write without constraints

Instead, define module boundaries and expose a public API:

• reads via selectors/queries
• writes via commands/actions
• events emitted for cross-module reactions
• types exported from one entry point

This reduces coupling and makes refactoring safe.

Principle 2: Separate reads from writes (and keep writes boring)

Writes should be easy to audit.

Practical tactics:

• name writes as business commands: applyCoupon, loginSucceeded, submitOrder
• keep update functions pure where possible
• keep side effects in a dedicated layer (effects, thunks, services)
• avoid “write anywhere” patterns

Even with MobX or signals, you can centralize mutations into explicit methods.

Principle 3: Model invariants close to the data

A common failure mode is scattering business rules across UI handlers.

Prefer domain-centric state:

• normalization (IDs + dictionaries) for large collections
• invariants enforced in reducers/actions/methods
• derived selectors for views

Example of normalized state:

cart = {
itemsById: { "p1": { id: "p1", qty: 2, price: 10 } },
itemIds: ["p1"],
coupon: "WELCOME"
// totals are derived, not stored
}

Principle 4: Make async and error states explicit

Async is where bugs hide: stale data, race conditions, and confusing spinners.

Track async explicitly:

• request status: idle | loading | success | error
• typed error payload and ownership
• cancellation and retry policy
• optimistic update policy
• invalidation strategy for cached data

Principle 5: Compose multiple focused stores instead of one mega-store

A scalable approach usually looks like:

feature stores for user actions
entity stores for reusable business entities
process/workflow state for multi-step flows
app-level state for true globals (rare)

This matches team ownership and keeps app state understandable.

How architectural methodologies influence state: MVC, MVP, Atomic Design, DDD, and FSD

Different methodologies shape where state ends up and how it’s accessed.

MethodologyWhat it optimizesState-related friction at scale
MVC / MVPclear UI layering (Model/View/Controller or Presenter)often unclear module boundaries; state leaks across screens
Atomic DesignUI composition and design systemsstrong for UI structure, weaker guidance for domain/feature state ownership
Domain-Driven Design (DDD)domain boundaries, aggregates, invariantsexcellent for domain state, but needs frontend-friendly module layout and integration
Feature-Sliced Design (FSD)modularity around features and business valuerequires discipline in public APIs, but supports predictable growth

Notice the gap: UI-focused methodologies rarely answer “where does checkout state live, who owns it, and how do pages interact safely?” That’s where FSD is especially practical.

Feature-Sliced Design as a state architecture

Feature-Sliced Design (FSD) is a methodology for frontend projects focused on modularity, explicit boundaries, and predictable growth. It doesn’t replace Redux, Zustand, MobX, or Pinia; it makes state management sustainable by telling you where state should live and how it should be exposed.

The core idea: align state with ownership

FSD organizes code by layers and slices, guided by dependency direction. Typical layers:

app – app initialization, providers, global config
processes – cross-page flows (checkout, onboarding)
pages – route-level composition
widgets – large UI blocks used on pages
features – user actions with business meaning (add-to-cart, login)
entities – business entities (user, product, cart)
shared – reusable primitives (ui-kit, lib, api)

State lives where it belongs:

• UI-local state → pages / widgets UI
• Feature behavior state → features/<feature>/model
• Entity state and rules → entities/<entity>/model
• Workflow state → processes/<process>/model
• True global → app (keep small)

Public API is non-negotiable

Each slice exposes a public API (usually an index.ts), and other modules import only from it.

Typical slice structure:

features/
add-to-cart/
index.ts
model/
store.ts
selectors.ts
events.ts
ui/
AddToCartButton.tsx
lib/
mapProductToCartItem.ts

Public API example:

// features/add-to-cart/index.ts
export { AddToCartButton } from "./ui/AddToCartButton"
export { useAddToCart } from "./model/store"
export type { AddToCartError } from "./model/events"

This prevents accidental dependency cycles and makes state ownership obvious.

Example: React + FSD + Zustand (feature and entity boundaries)

entities/
cart/
index.ts
model/
store.ts
selectors.ts
lib/
totals.ts

features/
add-to-cart/
index.ts
model/
useAddToCart.ts

Entity store owns invariants:

// entities/cart/model/store.ts
createStore(() => ({
itemsById: {},
itemIds: [],
addItem: (item) => { /* enforce invariants */ },
removeItem: (id) => { /* ... */ }
}))

Feature orchestrates the user action:

// features/add-to-cart/model/useAddToCart.ts
const useAddToCart = () => {
const addItem = cartStore(state => state.addItem)
return (product) => addItem(mapProductToCartItem(product))
}

Result: cohesive stores, explicit public APIs, and fewer cross-cutting dependencies.

Example: Vue + FSD + Pinia (clean separation of concerns)

entities/
user/
index.ts
model/
user.store.ts
selectors.ts

features/
login/
index.ts
model/
login.store.ts
ui/
LoginForm.vue

Entity store holds user domain truth:

// entities/user/model/user.store.ts
defineStore("user", {
state: () => ({ session: null, roles: [] }),
getters: { isAuthenticated: (s) => !!s.session },
actions: { setSession(session) { /* ... */ } }
})

Feature store handles async lifecycle and errors:

// features/login/model/login.store.ts
defineStore("login", {
state: () => ({ status: "idle", error: null }),
actions: {
async submit(credentials) {
this.status = "loading"
try { /* call api, update user entity */ }
catch (e) { this.error = e; this.status = "error" }
}
}
})

This keeps vue state predictable and makes it easy to evolve authentication without rewriting pages.

Where server state belongs in FSD

Treat server-state tooling as infrastructure:

shared/api – API client + base configuration
entities/<entity>/api – endpoints, query keys, DTO mapping
features/<feature> – orchestrate mutations and optimistic updates
pages/widgets – consume queries and render

This reduces “query key soup” and keeps caching policies consistent.

Common FSD anti-patterns (and how to avoid them)

Business stores in shared: increases fan-in and coupling. Keep shared for primitives.
Cross-feature internal imports: enforce public APIs; use processes for orchestration.
Pages owning business rules: pages compose; invariants live in entities, actions live in features.

As demonstrated by projects using FSD, these constraints improve onboarding and refactoring speed because intent is visible in the folder structure.

New paradigms: atomic state, signals, and state machines (how to use them without chaos)

Trends evolve, but architecture remains the stabilizer.

Atomic state management

Atomic approaches can reduce boilerplate and improve fine-grained updates. The risk: atoms become “global variables”.

Apply the same constraints:

• define atoms inside features or entities
• expose them only through public APIs
• group by domain, not by UI widgets
• avoid ad-hoc cross-slice imports

Signals and reactive primitives

Signals track dependencies precisely and can simplify derived state and performance. Still, signals don’t decide:

• ownership and boundaries
• where invariants live
• how cross-feature coordination works
• how to prevent dependency cycles

Signals are a mechanism; FSD is the organization.

State machines for workflows

For multi-step flows (checkout, onboarding), state machines provide clarity:

• explicit states and transitions
• fewer boolean flags
• better testability for edge cases

In FSD, workflow machines naturally belong in processes/<flow>/model.

Step-by-step: designing a frontend state architecture that scales

If you want a repeatable blueprint, use this sequence.

Step 1: Inventory and classify your state

List concerns and label them:

• UI / domain / server
• scope: leaf / subtree / cross-cutting
• persistence: transient / persistent
• ownership: which domain/team owns it?

This turns “state management is messy” into an actionable map.

Step 2: Define boundaries around business capabilities

Identify your primary slices:

• entities: user, product, cart, order
• features: login, add-to-cart, apply-coupon
• processes: checkout, onboarding

Then place state accordingly.

Step 3: Choose the smallest mechanism per class

• UI-only → local hooks/refs
• remote/cache → query cache
• domain behavior → feature/entity store
• complex flows → process store or state machine

This avoids “everything in Redux” or “everything in context”.

Step 4: Enforce public APIs and dependency direction

Practical enforcement:

index.ts public APIs
• lint rules for restricted imports
• consistent segments: model, ui, api, lib
• code review checklist: “Is this import allowed?”

Step 5: Measure success with architecture-friendly signals

Useful leading indicators:

• fewer cross-slice imports of internals
• reduced prop drilling depth
• smaller, more cohesive stores
• more state logic covered by unit tests
• faster onboarding (less “where does this live?”)

Conclusion

State management becomes sustainable when you treat it as architecture: classify frontend state, scope it deliberately, and enforce boundaries so teams can evolve features without fear. The most reliable setups keep UI state local, use a server-state cache for remote data, and model domain behavior in focused stores with explicit writes, derived reads, and clear ownership. Feature-Sliced Design (FSD) reinforces these choices with dependency direction and public APIs, making refactoring cheaper and collaboration smoother as the codebase grows. Over time, this is a long-term investment in code quality, predictable delivery, and team productivity.

Ready to build scalable and maintainable frontend projects? Dive into the official Feature-Sliced Design Documentation to get started.
Have questions or want to share your experience? Visit our homepage to learn more!

Disclaimer: The architectural patterns discussed in this article are based on the Feature-Sliced Design methodology. For detailed implementation guides and the latest updates, please refer to the official documentation.