State Management Patterns
State Management Patterns
Four paradigms for managing frontend application state — Flux/Redux, Signals, Atomic stores, and Observable/RxJS streams — each with distinct reactivity models, ecosystem implementations, and team-size thresholds.
Intent
Frontend applications accumulate state across multiple concerns: UI interaction state, shared business data, async request lifecycle, and URL/navigation state. As components multiply and teams grow, ad-hoc approaches (prop drilling, shared service singletons, global variables) break under the weight of untracked mutations and implicit dependencies.
This note covers four genuinely distinct paradigms for managing that state, not four competing libraries. Each paradigm represents a different philosophical answer to three questions: Where does state live? How do changes propagate? Who is responsible for derivation?
The four paradigms are:
- Flux/Redux — unidirectional data flow through a single centralised immutable store
- Signals — synchronous fine-grained reactive primitives with automatic dependency tracking
- Atomic Stores — state decomposed into independent composable atoms
- Observable/RxJS Streams — state modelled as asynchronous event sequences
TypeScript-only examples are used throughout this note — state management is a frontend concern with no Java equivalent, consistent with the Micro-Frontends precedent in this vault. All examples draw from both React and Angular ecosystems in the same note.
When NOT to Use
Do not reach for a state management library until the following alternatives have been ruled out:
- Component-local state suffices — if state is used only inside a single component, use React
useStateor Angular component fields. A library adds indirection with no benefit. - Server state (HTTP responses) — data fetched from an API is not client state. Use TanStack Query (React) or Angular's
httpResource/resource()API (Angular 17+) instead. State libraries were not designed for cache invalidation, stale-while-revalidate, or mutation synchronisation. - URL state — if the user can bookmark or share the state (filters, pagination, selected tab), it belongs in the URL. Use
nuqs(React) or Angular Router params/query params instead. - Form state — form input, validation errors, and submission lifecycle are managed most cleanly by React Hook Form (React) or Angular Reactive Forms. Storing form state in a global store creates unnecessary coupling between the form and the rest of the application.
- Prototype or MVP stage — adding a state architecture before domain boundaries are understood creates premature coupling. Establish the product first. Migrate state to a library when pain becomes concrete, not hypothetical.
When to Use
A state management library is justified when at least one of these conditions is true:
- Shared client state — state that must be read and written by multiple unrelated components without a direct parent-child relationship
- Complex UI interactions — undo/redo, optimistic updates, multi-step wizards, or state that must survive between user interactions in a session
- Route navigation survival — state must persist across route changes (e.g., shopping cart, user preferences, draft content) and is not appropriate for URL encoding
- Event-driven feature coordination — actions in one feature must trigger reactions in another feature with no direct component coupling
Paradigm Comparison Table
| Paradigm | Data Flow | Reactivity | Key Abstraction | React Ecosystem | Angular Ecosystem |
|---|---|---|---|---|---|
| Flux/Redux | Unidirectional (action → reducer → store → view) | Coarse: selector-level | Actions + Reducers + Single Store | RTK 2.x (@reduxjs/toolkit) | NgRx Store 21.x (@ngrx/store) |
| Signals | Push-based, graph-driven | Fine-grained: cell-level, automatic | signal(), computed(), effect() | No native; Jotai approximates | Angular Signals (built into @angular/core) |
| Atomic Stores | Pull-based (on read) + push (on write) | Medium-fine: per-atom subscriptions | Atoms + Derived Atoms | Zustand 5.x, Jotai 2.x | NgRx SignalStore 21.x, NgRx Signal State |
| Observable/RxJS | Push-based streams, declarative composition | Coarse: stream-level | Observable, Subject, operators | RxJS usable, no first-class integration | RxJS 7.x (built into Angular), NgRx Effects |
Suitability Threshold Matrix
| Dimension | Flux/Redux (RTK / NgRx Store) | Signals (Angular Signals) | Atomic Stores (Zustand / Jotai / NgRx SignalStore) | Observable Streams (RxJS / NgRx Effects) |
|---|---|---|---|---|
| Team size | 5+ developers sharing state slices | Any size | 1–10; scales with discipline | Any size; mandatory for async pipelines |
| App complexity | High: multiple disconnected domains, complex business rules | Low–Medium: reactive UI, component-level shared state | Low–High: composable per feature-slice | Medium–High: async operations, event streams |
| Reactivity granularity | Coarse: slice-level selectors | Fine: cell-level, automatic propagation | Medium-Fine: per-atom | Coarse: stream-level |
| Debugging needs | Redux DevTools, time-travel, action replay | Angular DevTools signals panel | Zustand devtools middleware; Jotai DevTools | RxJS marbles; Redux DevTools (via NgRx) |
| Boilerplate | Medium (RTK reduces legacy Redux 70%); High (NgRx Store) | Very Low | Very Low (Zustand) to Low (Jotai) | Low (RxJS standalone) to High (NgRx Effects) |
| SSR compatibility | Yes (RTK + Next.js hydration) | Yes (Angular SSR) | Yes (Zustand + Next.js; Jotai provider-based) | Yes (Angular SSR) |
Paradigm Relationships
classDiagram
class StateManagementParadigm {
<<abstract>>
+describe() string
}
class FluxRedux {
+store: SingleStore
+actions: Action[]
+reducers: PureFn[]
+dispatch(action) void
+getState() State
}
class Signals {
+signal~T~(initial: T) WritableSignal
+computed~T~(fn: ()=>T) Signal
+effect(fn: ()=>void) void
}
class AtomicStore {
+atom~T~(initial: T) Atom
+derived(atoms, fn) DerivedAtom
+useAtom~T~(atom: Atom~T~) [T, Setter]
}
class ObservableStreams {
+subject~T~: BehaviorSubject~T~
+observable$: Observable~T~
+pipe(operators) Observable
+subscribe(fn) Subscription
}
StateManagementParadigm <|-- FluxRedux
StateManagementParadigm <|-- Signals
StateManagementParadigm <|-- AtomicStore
StateManagementParadigm <|-- ObservableStreams
class RTK {
createSlice()
configureStore()
createApi()
}
class NgRxStore {
createAction()
createReducer()
createEffect()
}
class AngularSignals {
signal()
computed()
effect()
toSignal()
}
class NgRxSignalStore {
signalStore()
withState()
withComputed()
withMethods()
}
class Zustand {
create()
useStore()
middleware
}
class Jotai {
atom()
useAtom()
atomFamily()
}
class NgRxSignalState {
signalState()
patchState()
}
class RxJSService {
BehaviorSubject
Observable
Operators
}
class NgRxEffects {
createEffect()
ofType()
Actions
}
FluxRedux <|-- RTK : React impl
FluxRedux <|-- NgRxStore : Angular impl
Signals <|-- AngularSignals : Angular impl
AtomicStore <|-- Zustand : React impl
AtomicStore <|-- Jotai : React impl
AtomicStore <|-- NgRxSignalStore : Angular impl
AtomicStore <|-- NgRxSignalState : Angular (lightweight)
ObservableStreams <|-- RxJSService : Angular impl
ObservableStreams <|-- NgRxEffects : Angular impl (side effects)
Paradigm 1 — Flux / Redux
Philosophy: All application state lives in a single centralised store. State changes are described by action objects — plain data structures that record what happened and what payload it carries. Reducers are pure functions that compute the next state from the current state and an incoming action. State is immutable: reducers never mutate; they return new state trees (or use Immer's structural sharing internally). This makes every state change traceable, replayable, and testable in isolation.
Strengths: Predictable state transitions, time-travel debugging via Redux DevTools, strict architectural enforcement across large teams, clear audit trail of mutations.
Weaknesses: High conceptual overhead (actions, reducers, selectors, effects all as separate artefacts). NgRx Store in particular has very high boilerplate. Not appropriate for small-to-medium single-developer apps.
Team-size threshold: Justified at 5+ developers sharing overlapping state slices. Below that threshold, Signals or Atomic Stores are more productive.
React — RTK 2.x
// Source: redux-toolkit.js.org/introduction/getting-started
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
interface CartState { items: string[]; total: number; }
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 } as CartState,
reducers: {
addItem: (state, action: PayloadAction<string>) => {
state.items.push(action.payload); // Immer allows mutation syntax inside reducers
state.total += 1;
},
clearCart: (state) => {
state.items = [];
state.total = 0;
},
},
});
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export const { addItem, clearCart } = cartSlice.actions;
export type RootState = ReturnType<typeof store.getState>;
// Selector (use with useSelector in components)
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) => state.cart.total;Angular — NgRx Store 21.x
// Source: ngrx.io/guide/store
// Note: NgRx Store API is stable across v19–v21; examples use v21.x (current) syntax.
import { createAction, createReducer, on, props } from '@ngrx/store';
export const addItem = createAction('[Cart] Add Item', props<{ item: string }>());
export const clearCart = createAction('[Cart] Clear');
interface CartState { items: string[]; }
const initialState: CartState = { items: [] };
export const cartReducer = createReducer(
initialState,
on(addItem, (state, { item }) => ({ ...state, items: [...state.items, item] })),
on(clearCart, (state) => ({ ...state, items: [] }))
);Paradigm 2 — Signals
Philosophy: Reactive primitives that automatically track which computations depend on which values. When a signal's value changes, only its direct dependents re-execute — no diffing, no manual subscription management, no selector boilerplate. Signals are synchronous. The runtime builds a dependency graph at read time and invalidates only the affected leaf computations on write. This achieves fine-grained reactivity at cell-level granularity: a component that reads count() only re-renders when count changes, not when any sibling signal changes.
Strengths: Zero boilerplate, automatic cleanup, fine-grained performance without memoisation ceremony, first-class in Angular (enables zoneless apps).
Weaknesses: Signals are not the right tool for asynchronous event pipelines — use Observable streams there. effect() has strict rules (no signal writes inside effects by default) that surprise developers coming from MobX.
Angular — Angular Signals (built in)
// Source: angular.dev/guide/signals (verified 2026-03-26)
import { signal, computed, effect } from '@angular/core';
const count = signal(0); // WritableSignal<number>
const doubleCount = computed(() => count() * 2); // Signal<number> — lazily evaluated, memoised
effect(() => {
// Runs whenever count changes; cleanup is automatic (tied to injection context)
console.log(`count is: ${count()}, double: ${doubleCount()}`);
});
count.set(5); // doubleCount() === 10
count.update(v => v + 1); // doubleCount() === 12
// In a component — signals integrate with Angular's rendering without zone.js
// The template reads count() and doubleCount() as function callsReact does not have native Signals. The TC39 Signals proposal (Stage 1 as of 2025) describes a common primitives layer for JavaScript engines, but there is no timeline for React adoption.
Within the React ecosystem, Jotai atoms approximate signal-style granularity: each atom is an independent reactive cell, derived atoms compose without a global store shape, and components subscribe only to the atoms they read. The key difference is that Jotai atoms are not synchronous push — they use React's render cycle as the propagation mechanism.
For canonical Signals, use Angular Signals or SolidJS (SolidJS is out of scope for this vault — React and Angular only).
Paradigm 3 — Atomic Stores
Philosophy: State is decomposed into independent atoms — the smallest units of shared state. Atoms live outside the component tree and are imported directly where needed. No Provider, no top-level store shape required. Derived atoms compose from other atoms: a derived atom is a pure function of one or more source atoms, memoised automatically. Components subscribe only to the atoms they read; unrelated atom updates do not trigger re-renders.
Strengths: Minimal setup, no global store shape to design upfront, composable per feature-slice, excellent for form-heavy or fine-grained per-component shared state.
Weaknesses: Without discipline, atoms proliferate and become hard to trace. Not appropriate for strict architectural enforcement (no single source of truth, no action trail). Team discipline is more important than in Flux/Redux.
React — Zustand 5.x
// Source: github.com/pmndrs/zustand (verified 2026-03-26)
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CartStore {
items: string[];
addItem: (item: string) => void;
clearCart: () => void;
}
const useCartStore = create<CartStore>()(
devtools(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
}),
{ name: 'CartStore' }
)
);
// Always select only what you need — prevents re-render on unrelated changes
const items = useCartStore((state) => state.items);
const addItem = useCartStore((state) => state.addItem);In Zustand v5, the shallow equality helper moved to a separate import: import { shallow } from 'zustand/shallow'. Use it when selecting multiple fields at once: useCartStore(state => ({ items: state.items, total: state.total }), shallow).
React — Jotai 2.x
// Source: jotai.org/docs/core/atom (verified 2026-03-26)
import { atom, useAtom } from 'jotai';
// Primitive atom — writable
const itemsAtom = atom<string[]>([]);
// Derived atom — read-only, recomputes when itemsAtom changes
const countAtom = atom((get) => get(itemsAtom).length);
// Write-only atom — encapsulates mutation logic
const addItemAtom = atom(null, (get, set, item: string) => {
set(itemsAtom, [...get(itemsAtom), item]);
});
function CartCounter() {
const [count] = useAtom(countAtom); // re-renders only when itemsAtom changes
return <span>Items: {count}</span>;
}Angular — NgRx SignalStore
// Source: stefanos-lignos.dev/posts/ngrx-signals-store (verified 2026-03-26)
// NgRx 21.x — recommended for medium-to-large Angular apps
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
export const CartStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] as string[], loading: false }),
withComputed(({ items }) => ({
itemCount: computed(() => items().length),
})),
withMethods((store) => ({
addItem(item: string): void {
patchState(store, { items: [...store.items(), item] });
},
clearCart(): void {
patchState(store, { items: [], loading: false });
},
}))
);
// Component usage
@Component({ providers: [CartStore] })
class CartComponent {
readonly cart = inject(CartStore);
// cart.items() — Signal<string[]>
// cart.itemCount() — Signal<number>
}Angular — NgRx Signal State (lightweight)
// Source: ngrx.io/guide/signals (verified 2026-03-26)
// NgRx Signal State — lightweight alternative for small Angular apps or feature-level state
import { signalState, patchState } from '@ngrx/signals';
const cartState = signalState({ items: [] as string[] });
// Read — Signal getter
console.log(cartState.items()); // string[]
// Write — immutable patch (structural sharing under the hood)
patchState(cartState, { items: [...cartState.items(), 'new-item'] });
// Use within a service or component — no signalStore overheadParadigm 4 — Observable / RxJS Streams
Philosophy: State modelled as sequences of values emitted over time. Operators compose, filter, transform, and combine streams declaratively. Observable streams are the correct model for inherently asynchronous sequences: HTTP requests with cancellation, WebSocket messages, user input with debounce, retry logic with exponential backoff, and parallel request coordination. Unlike Signals and Atomic stores (which model synchronous or point-in-time state), Observable streams model the history and timing of events.
Strengths: Expressive async composition, cancellation built in (switchMap cancels previous inner observable), first-class in Angular (HttpClient returns Observables), NgRx Effects integrates seamlessly.
Weaknesses: Higher learning curve. Manual subscription management causes memory leaks when cleanup is omitted. Not appropriate for simple synchronous shared state — use Signals instead. The full power of RxJS is often overkill for state that simply needs to be read by multiple components.
Do not use RxJS streams for general-purpose state. The paradigm's strength is async event pipelines. For synchronous shared state in Angular, use Signals.
Angular — RxJS BehaviorSubject service
// Source: rxjs.dev/guide/subject + Angular 16+ rxjs-interop
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Component } from '@angular/core';
@Injectable({ providedIn: 'root' })
class CartService {
private state$ = new BehaviorSubject<{ items: string[] }>({ items: [] });
readonly items$: Observable<string[]> = this.state$.pipe(map(s => s.items));
readonly count$: Observable<number> = this.state$.pipe(map(s => s.items.length));
addItem(item: string): void {
const current = this.state$.getValue();
this.state$.next({ items: [...current.items, item] });
}
}
@Component({ selector: 'app-cart', template: '' })
class CartComponent {
items: string[] = [];
constructor(private cartService: CartService) {
this.cartService.items$
.pipe(takeUntilDestroyed()) // Angular 16+ — auto-cleanup on component destroy
.subscribe(items => this.items = items);
}
}Angular — NgRx Effects
// Source: ngrx.io/guide/effects
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@Injectable()
export class CartEffects {
loadCart$ = createEffect(() =>
this.actions$.pipe(
ofType(loadCartAction),
switchMap(() => // cancels previous load if a new one fires
this.cartService.getCart().pipe(
map(cart => loadCartSuccess({ cart })),
catchError(err => of(loadCartFailure({ error: err.message })))
)
)
)
);
constructor(
private actions$: Actions,
private cartService: CartService
) {}
}RxJS is usable in React but has no first-class integration. React developers typically use TanStack Query for async server state (HTTP caching, loading states, mutation coordination) rather than raw RxJS streams. For event-driven coordination in React, consider custom event buses or Zustand actions with side effects.
Common Pitfalls
Pitfall 1: Using a State Library for Server State
What goes wrong: Developer stores an API response in Redux/Zustand/Signals. Adds isLoading, isError, data, and lastFetched fields manually. Re-implements cache invalidation incorrectly. Stale data is displayed after a mutation because the cache is not invalidated.
Why it happens: State library tutorials use shopping cart examples (pure client state). Developers cargo-cult the pattern to all data including server responses.
How to avoid: Server state (data from HTTP) is fundamentally different from client state (UI state, user input). Server state has a remote source of truth, needs staleness/freshness modelling, and requires background refetching. Use TanStack Query (React) or Angular's httpResource/resource() API (Angular 17+) for server state. Reserve state libraries for client-only state.
Warning signs: A Redux slice or Zustand store with isLoading, isError, data, and lastFetched fields sitting alongside UI state.
Pitfall 2: RxJS Memory Leaks — Uncleaned Subscriptions
What goes wrong: An Angular component subscribes to an Observable in ngOnInit. The component is destroyed (user navigates away). The subscription continues running, holding a reference to the destroyed component's DOM and instance methods. Memory usage grows with every navigation.
Why it happens: RxJS subscriptions are not automatically cleaned up when the subscriber is garbage-collected. Unlike Signals (which use Angular's injection context for cleanup), Observable subscriptions are explicit and must be explicitly terminated.
How to avoid:
// Source: rxjs.dev/guide/subscription + Angular 16+ takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-example', template: '' })
class MyComponent {
constructor(private dataService: DataService) {
this.dataService.data$
.pipe(takeUntilDestroyed()) // Angular 16+ — auto-cleanup on component destroy
.subscribe(data => this.handle(data));
}
private handle(data: unknown): void { /* ... */ }
}Warning signs: Memory usage climbing with navigation. takeUntil(destroy$) or takeUntilDestroyed() missing from component subscriptions. No ngOnDestroy for older Angular code.
Pitfall 3: Zustand Without Selectors — Over-Rendering
What goes wrong: A component calls useCartStore() with no selector argument. The hook returns the entire store object. Every time any property in the store changes (including properties the component does not use), the component re-renders.
Why it happens: Zustand's hook accepts an optional selector; calling it without one returns the full store slice.
How to avoid:
// Source: github.com/pmndrs/zustand — selector pattern
// Wrong — re-renders on any store update, including unrelated fields
const store = useCartStore();
// Correct — only re-renders when items changes
const items = useCartStore((state) => state.items);Warning signs: Components re-rendering unexpectedly. React DevTools Profiler showing renders triggered by state changes the component does not visually depend on.
Pitfall 4: Choosing NgRx Store for a Single-Developer Angular App
What goes wrong: A solo developer adds NgRx Store to a medium-complexity Angular app. They spend 30% of development time writing actions, reducers, effects, and selectors for state that could be managed in 10 lines of Angular Signals. Feature velocity slows to a fraction of what is achievable.
Why it happens: NgRx Store appears at the top of "Angular state management" search results and dominates older tutorials. Developers reach for it without evaluating the team-size threshold.
How to avoid: Apply the nx.dev Angular state management progression:
- Start with
signal()+ injectable services - Graduate to NgRx Signal State for small-to-medium apps with light shared state
- Graduate to NgRx Signal Store when feature-level encapsulation is needed
- Adopt NgRx Store (Flux-style) only when team size or architectural discipline requirements justify it
Warning signs: More lines in action/effect files than in component files. A single developer or team of two maintaining NgRx boilerplate for a 3-page app.
Pitfall 5: Mutating State Directly in RTK Outside Immer Context
What goes wrong: A developer uses createSlice but accidentally mutates state inside a selector, a component, or a service function. Redux detects the mutation in development mode and throws a runtime error. In production, state becomes inconsistent because Redux's reference equality checks fail.
Why it happens: RTK's Immer middleware allows mutation-style syntax inside createSlice.reducers. Developers assume this works everywhere in the app.
How to avoid: Mutation-style syntax (state.items.push(...)) is only safe inside createSlice.reducers. Everywhere else — in selectors, components, services — always return new object references: return { ...state, field: newValue }. Never call .push(), .splice(), or property assignment on state objects outside reducers.
Warning signs: Redux DevTools showing "A state mutation was detected" warnings. state.items.push() appearing outside a createSlice reducer function.
Pitfall 6: effect() Causing Infinite Loops in Angular Signals
What goes wrong: An effect() writes to a signal that the effect also reads. Angular's runtime detects the cycle and throws error NG0600: "Writing to signals in effects is not allowed by default."
Why it happens: Effects are designed for side effects (DOM manipulation, analytics, logging, external API calls triggered by state). Developers attempt to use them for state-to-state derivation — the wrong tool for the job.
How to avoid: Use computed() for state-to-state derivation. computed() is the correct reactive primitive for deriving new state from existing signals. If you genuinely must write to a signal inside an effect (rare), pass allowSignalWrites: true in the effect options — but treat this as a code smell indicating the logic belongs in computed().
// Source: angular.dev/guide/signals#effects
// Wrong — throws NG0600
const doubled = signal(0);
effect(() => {
doubled.set(count() * 2); // writes inside effect that reads count
});
// Correct — use computed() for state derivation
const doubled = computed(() => count() * 2);Warning signs: NG0600 errors in the Angular console. effect() bodies that both read and write to signals.
Related Concepts
| Concept | Relationship | Link |
|---|---|---|
| Observer Pattern | Foundation — Signals and RxJS Observable streams are both Observer pattern variants with different reactivity models | Observer-Pattern |
| Micro-Frontends | State isolation — each micro-frontend owns its state; cross-micro-frontend state sharing is an architectural decision requiring explicit contracts | Micro-Frontends |
| Decorator Pattern | HOC (Higher-Order Component) in React is the Decorator pattern applied to components — connect() in React-Redux wraps components with store access | Decorator-Pattern |
| CQRS | Flux/Redux is CQRS applied to frontend state: actions are commands, selectors are queries, the store is the read model | CQRS-Pattern |
| Event Sourcing | The Redux action log is an event-sourced ledger — the current state can be replayed from the full action history, which is why time-travel debugging is possible | Event-Sourcing-Pattern |
Sources
- angular.dev/guide/signals — Angular Signals API:
signal(),computed(),effect(),toSignal()— verified 2026-03-26 - redux-toolkit.js.org/introduction/getting-started — RTK 2.x
createSlice,configureStore,createAsyncThunk— doc updated 2026-03-07 - jotai.org/docs/core/atom — Jotai 2.x atom API, derived atoms, write-only atoms,
useAtom— verified 2026-03-26 - github.com/pmndrs/zustand — Zustand v5
create()API, devtools middleware, selector pattern — verified 2026-03-26 - npm registry —
@reduxjs/toolkit@2.11.2,zustand@5.0.12,jotai@2.19.0,@ngrx/signals@21.1.0,@ngrx/store@21.1.0,rxjs@7.8.2— verified 2026-03-26