Before adding another state-management library to your Angular application, ask one question:
Does the complexity of the problem actually require it?
Modern Angular ships with native reactive primitives (signal(), computed(), effect(), inject(), linkedSignal(), resource()) that, combined with a well-designed service architecture, give you lightweight, predictable, testable state management.
This is not an argument against NgRx, NGXS, or Akita. It's an argument for matching the tool to the complexity of the state, and for scaling through clear boundaries instead of boilerplate.
The goal isn't to eliminate state-management libraries. The goal is to avoid introducing infrastructure before the problem requires it.
Table of Contents
- The real question: ownership
- The complexity spectrum
- Who owns this state?
- The native primitives and their jobs
- The clean state service pattern
- Private writable state, public readonly state
- Read vs write: queries and commands
- Immutable updates
- Selectors with computed
- Using the state in a component
- effect: use it for external systems only
- linkedSignal: writable state that follows another signal
- Feature-scoped state
- Application-level state
- Async state
- Signals and RxJS
- Persistence as infrastructure
- Error handling
- Testing a signal-based service
- Performance: what to claim and what not to
- 8 state management mistakes
- The god state service
- When a dedicated library makes sense
- Decision framework
- Architecture checklist
- Conclusion
The real question: ownership
State management isn't about choosing the most powerful library. It's about making these answers obvious:
- Who owns the state?
- Who can modify it?
- Who can read it?
- How is derived state calculated?
- How are async operations handled?
- Where should the state live?
- How easy is it to test?
- How easy is it to replace?
If your architecture answers those clearly, a lot of "which library?" debates get smaller. If it doesn't, no library will fix that for you.
Signals are not a state architecture. They are primitives. The architecture comes from ownership, boundaries, mutation rules, scope, and data flow.
The complexity spectrum
State exists on a spectrum, and the tool should match the level:
LOCAL COMPONENT STATE
↓
FEATURE STATE
↓
SHARED FEATURE STATE
↓
APPLICATION STATE
↓
COMPLEX EVENT-DRIVEN GLOBAL STATE
Move to the right only when the problem forces you to. Most state in most screens lives on the left.
Who owns this state?
| State | Owner |
|---|---|
| Loading flag for a dialog | The component |
| Selected tab, modal visibility | The component |
| Shopping cart | A feature/domain service |
| Product listing filters | A feature service |
| Authenticated user | Application-level state |
| A workflow with many independent transitions | Candidate for a dedicated state architecture |
Do not automatically make everything global. Global state has the widest blast radius: any code can depend on it, and removing or changing it is the hardest.
The native primitives and their jobs
signal() holds mutable state owned by a service or component.
computed() derives state from other signals. It is lazy and memoized.
effect() synchronizes with systems outside the signal graph: localStorage, analytics, browser APIs, third-party widgets.
inject() gives clean, functional dependency injection.
linkedSignal() is writable state whose value resets or adapts when a source signal changes.
resource() models asynchronous state (loading, value, error) driven by signals. Check its API status in your target Angular version, because this area has been evolving.
One rule prevents a lot of pain: prefer computed() for derived state, and never use effect() to derive it.
The clean state service pattern
Here is a complete, production-oriented example: a cart.
// cart.models.ts
export interface CartItem {
readonly id: string;
readonly name: string;
readonly price: number;
readonly quantity: number;
readonly active: boolean;
}
// cart.state.ts
import { Injectable, computed, signal } from '@angular/core';
import { CartItem } from './cart.models';
@Injectable({ providedIn: 'root' })
export class CartState {
// 1. Private writable state: the only place mutation can happen
private readonly _items = signal<readonly CartItem[]>([]);
// 2. Public read surface
readonly items = this._items.asReadonly();
// 3. Selectors (derived state)
readonly activeItems = computed(() =>
this._items().filter(item => item.active),
);
readonly hasItems = computed(() => this._items().length > 0);
readonly itemCount = computed(() =>
this._items().reduce((total, item) => total + item.quantity, 0),
);
readonly total = computed(() =>
this._items().reduce(
(total, item) => total + item.price * item.quantity,
0,
),
);
// 4. Commands (intent-based mutation API)
addItem(item: CartItem): void {
this._items.update(items => {
const existing = items.find(i => i.id === item.id);
return existing
? items.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i,
)
: [...items, item];
});
}
removeItem(id: string): void {
this._items.update(items => items.filter(item => item.id !== id));
}
setQuantity(id: string, quantity: number): void {
if (quantity <= 0) {
this.removeItem(id);
return;
}
this._items.update(items =>
items.map(item => (item.id === id ? { ...item, quantity } : item)),
);
}
clear(): void {
this._items.set([]);
}
}
Every decision, explained:
-
_itemsis private, so there is exactly one mutation boundary. Any change can be traced to a method in this file. -
asReadonly()gives consumers a signal they can read and react to but cannotset()orupdate(). -
readonly CartItem[]plusupdate()returning new arrays enforces immutable updates at the type level and at runtime. -
computed()selectors are memoized and recalculate only when their dependencies change. There is no second signal to keep in sync. - Methods like
addItemexpress intent. Business rules (merge duplicates, remove at zero quantity) live here, not in components.
Private writable state, public readonly state
Components that only consume state should never receive a writable signal.
// ❌ Leaks mutation to every consumer
export class CartState {
readonly items = signal<CartItem[]>([]);
}
// somewhere in a component
this.cart.items.update(items => [...items, product]);
// ✅ Controlled mutation boundary
export class CartState {
private readonly _items = signal<readonly CartItem[]>([]);
readonly items = this._items.asReadonly();
}
The component gets items(), but not items.set() or items.update(). Two benefits follow: bugs have exactly one place to hide, and you can change the internal representation (say, a Map instead of an array) without touching a single component.
Read vs write: queries and commands
| Read | Write |
|---|---|
computed() selectors |
Service methods |
| Readonly signals | Commands |
| Intent-based APIs |
// ❌ The component knows how the state is stored
cartState.items.update(items => [...items, product]);
// ✅ The component expresses intent
cartState.addItem(product);
The component says what it wants. The service decides how state changes.
Immutable updates
// ❌ Mutates in place, then re-sets the same reference
const items = this._items();
items.push(item);
this._items.set(items);
Signals compare values by reference by default, so setting the same array back looks like "nothing changed". Dependents may not update, and any other code holding that array sees it mutate underneath it.
// ✅ New reference
this._items.update(items => [...items, item]);
Immutable updates improve:
- Predictability: values don't change behind your back.
- Debugging: you can compare before/after snapshots.
- Testing: assertions are simple equality checks.
- Change detection reasoning: a new reference means "changed".
- State snapshots: capturing a value is safe.
Selectors with computed
readonly activeItems = computed(() =>
this._items().filter(item => item.active),
);
readonly hasItems = computed(() => this._items().length > 0);
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.quantity, 0),
);
computed() is your selector layer. It derives state declaratively. You never write "when items change, also update total", and that is exactly the class of bug it removes.
You can also compose selectors:
readonly isFreeShipping = computed(() => this.total() >= 100);
readonly summary = computed(() => ({
count: this.itemCount(),
total: this.total(),
freeShipping: this.isFreeShipping(),
}));
Using the state in a component
// cart-summary.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartState } from './cart.state';
@Component({
selector: 'app-cart-summary',
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (cart.hasItems()) {
<ul>
@for (item of cart.items(); track item.id) {
<li>
{{ item.name }} × {{ item.quantity }}
<button (click)="cart.removeItem(item.id)">Remove</button>
</li>
}
</ul>
<p>{{ cart.itemCount() }} items · {{ cart.total() | currency }}</p>
<button (click)="cart.clear()">Clear cart</button>
} @else {
<p>Your cart is empty.</p>
}
`,
})
export class CartSummaryComponent {
protected readonly cart = inject(CartState);
}
The component contains no business rules and no state of its own. It reads signals and calls intent methods.
effect: use it for external systems only
effect() is for synchronizing with the outside world. Good uses:
- localStorage / sessionStorage synchronization
- Browser APIs (document title,
matchMedia, etc.) - Analytics
- External widgets or imperative libraries
Avoid using it to derive state.
// ❌ Manual synchronization: a second writable signal that can drift
readonly fullName = signal('');
constructor() {
effect(() => {
this.fullName.set(this.firstName() + ' ' + this.lastName());
});
}
// ✅ Declarative derivation
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
Why the first version is worse: it adds a writable signal anyone could set(), it introduces timing (the value is stale until the effect runs), and it creates a place where the two values can disagree.
A legitimate effect:
constructor() {
effect(() => {
document.title = `Cart (${this.itemCount()})`;
});
}
linkedSignal: writable state that follows another signal
Sometimes you need a value that is writable and resets when something else changes. A classic case: a selected item that should clear when the filter changes.
import { Injectable, linkedSignal, signal } from '@angular/core';
@Injectable()
export class ProductListState {
private readonly _filter = signal<'all' | 'active'>('all');
readonly filter = this._filter.asReadonly();
// Writable, but resets to null whenever the filter changes
private readonly _selectedId = linkedSignal<string | null>({
source: this._filter,
computation: () => null,
});
readonly selectedId = this._selectedId.asReadonly();
setFilter(filter: 'all' | 'active'): void {
this._filter.set(filter);
}
select(id: string): void {
this._selectedId.set(id);
}
}
Use it when a value has a genuine relationship to another signal. It's not a general replacement for computed(): if the value is purely derived and never set directly, use computed().
Feature-scoped state
Not every state service should be providedIn: 'root'. Feature state should live and die with its feature.
// checkout.state.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { CartState } from './cart.state';
type Step = 'address' | 'payment' | 'review';
const STEPS: readonly Step[] = ['address', 'payment', 'review'];
@Injectable() // note: no providedIn
export class CheckoutState {
private readonly cart = inject(CartState);
private readonly _step = signal<Step>('address');
private readonly _address = signal<string>('');
readonly step = this._step.asReadonly();
readonly address = this._address.asReadonly();
readonly canContinue = computed(() => {
switch (this._step()) {
case 'address':
return this._address().trim().length > 0;
case 'payment':
return this.cart.hasItems();
case 'review':
return this.cart.hasItems() && this._address().length > 0;
}
});
setAddress(address: string): void {
this._address.set(address);
}
next(): void {
if (!this.canContinue()) return;
const index = STEPS.indexOf(this._step());
this._step.set(STEPS[Math.min(index + 1, STEPS.length - 1)]);
}
back(): void {
const index = STEPS.indexOf(this._step());
this._step.set(STEPS[Math.max(index - 1, 0)]);
}
}
Provide it at the route boundary:
// checkout.routes.ts
import { Routes } from '@angular/router';
import { CheckoutState } from './checkout.state';
export const checkoutRoutes: Routes = [
{
path: '',
providers: [CheckoutState], // created with the route, destroyed with it
loadComponent: () =>
import('./checkout.page').then(m => m.CheckoutPageComponent),
},
];
// app.routes.ts
export const routes: Routes = [
{
path: 'checkout',
loadChildren: () =>
import('./checkout/checkout.routes').then(m => m.checkoutRoutes),
},
];
You can also provide at component level for narrower scope:
@Component({
selector: 'app-product-filters',
providers: [ProductListState],
/* ... */
})
export class ProductFiltersComponent {}
Benefits:
- State lifecycle matches feature lifecycle. Leave checkout, and the state is gone. No stale data when you re-enter.
- Less global state.
- Easier testing. Each test gets a fresh instance.
- Less accidental coupling. Unrelated features can't reach into it.
- Better encapsulation.
Application-level state
Some state genuinely is global: the authenticated user, global configuration, application-wide preferences.
// auth.state.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
export interface User {
readonly id: string;
readonly name: string;
readonly roles: readonly string[];
}
@Injectable({ providedIn: 'root' })
export class AuthState {
private readonly router = inject(Router);
private readonly _user = signal<User | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
readonly roles = computed(() => this._user()?.roles ?? []);
hasRole(role: string): boolean {
return this.roles().includes(role);
}
signedIn(user: User): void {
this._user.set(user);
}
signedOut(): void {
this._user.set(null);
void this.router.navigateByUrl('/login');
}
/** Called when the API reports an expired session. */
sessionExpired(): void {
this.signedOut();
}
}
Same pattern, wider scope. The scope is global because ownership is global.
Async state
You have two reasonable options, and the simpler one is often enough.
Option 1: service + signals
When you want full control and the lifecycle is simple:
// orders.state.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
export interface Order {
readonly id: string;
readonly total: number;
}
@Injectable()
export class OrdersState {
private readonly http = inject(HttpClient);
private readonly _orders = signal<readonly Order[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
readonly orders = this._orders.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly isEmpty = computed(
() => !this._loading() && !this._error() && this._orders().length === 0,
);
load(): void {
this._loading.set(true);
this._error.set(null);
this.http.get<Order[]>('/api/orders').subscribe({
next: orders => {
this._orders.set(orders);
this._loading.set(false);
},
error: () => {
this._error.set('Could not load orders. Please try again.');
this._loading.set(false);
},
});
}
retry(): void {
this.load();
}
}
The conceptual model is simply:
state = { data, loading, error }
and it stays encapsulated inside the service. Components render; they never re-implement it.
@Component({
selector: 'app-orders',
providers: [OrdersState],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (state.loading()) {
<p>Loading…</p>
} @else if (state.error(); as message) {
<p role="alert">{{ message }}</p>
<button (click)="state.retry()">Retry</button>
} @else if (state.isEmpty()) {
<p>No orders yet.</p>
} @else {
<ul>
@for (order of state.orders(); track order.id) {
<li>{{ order.id }}: {{ order.total }}</li>
}
</ul>
}
`,
})
export class OrdersComponent {
protected readonly state = inject(OrdersState);
constructor() {
this.state.load();
}
}
Option 2: resource()
When state is a function of other signals (a query, an id, filters) and you want the loading/value/error lifecycle, request cancellation, and reload handled for you:
// products.state.ts
import { Injectable, computed, resource, signal } from '@angular/core';
export interface Product {
readonly id: string;
readonly name: string;
}
@Injectable()
export class ProductsState {
private readonly _query = signal('');
readonly query = this._query.asReadonly();
private readonly products = resource({
// Re-runs whenever the query changes; in-flight requests are aborted
params: () => ({ q: this._query() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(
`/api/products?q=${encodeURIComponent(params.q)}`,
{ signal: abortSignal },
);
if (!res.ok) throw new Error('Failed to load products');
return (await res.json()) as Product[];
},
});
readonly data = computed(() =>
this.products.hasValue() ? this.products.value() : [],
);
readonly loading = this.products.isLoading;
readonly error = this.products.error;
readonly isEmpty = computed(
() => this.products.status() === 'resolved' && this.data().length === 0,
);
search(query: string): void {
this._query.set(query);
}
retry(): void {
this.products.reload();
}
}
Note: Angular also offers httpResource() for HTTP-based resources that go through HttpClient and its interceptors. Check the current API status for your Angular version before adopting either in production.
When is the plain service enough? When you have a handful of requests, simple loading/error handling, and no signal-driven re-fetching. When does resource() pay off? When the request depends on signals and you want cancellation of stale requests and a standard status lifecycle without writing it by hand.
Signals and RxJS
Signals and RxJS solve related but different problems. Don't treat Signals as a universal RxJS replacement.
Signals excel at: synchronous state, derived state, UI state, and reactive view consumption.
RxJS excels at: event streams, HTTP pipelines, WebSockets, cancellation, debouncing, throttling, and stream composition.
The hybrid architecture:
HTTP / WebSocket / Events
↓
RxJS
↓
State Service
↓
Signals
↓
Components
A typical example is type-ahead search: the stream logic stays in RxJS, and the result lands in a signal.
// search.state.ts
import { Injectable, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import {
Subject, catchError, debounceTime, distinctUntilChanged,
of, switchMap, tap,
} from 'rxjs';
@Injectable()
export class SearchState {
private readonly http = inject(HttpClient);
private readonly term$ = new Subject<string>();
private readonly _results = signal<readonly string[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
readonly results = this._results.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
constructor() {
this.term$
.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this._loading.set(true);
this._error.set(null);
}),
// switchMap cancels the previous in-flight request
switchMap(term =>
this.http.get<string[]>('/api/search', { params: { q: term } }).pipe(
catchError(() => {
this._error.set('Search failed');
return of([] as string[]);
}),
),
),
takeUntilDestroyed(),
)
.subscribe(results => {
this._results.set(results);
this._loading.set(false);
});
}
search(term: string): void {
this.term$.next(term);
}
}
RxJS handles timing and cancellation. The signal holds the state the UI reads.
You can also bridge in the other direction with toSignal() (observable → signal) and toObservable() (signal → observable) from @angular/core/rxjs-interop when a stream is the natural source of state.
Persistence as infrastructure
Persistence is an infrastructure concern. Don't scatter localStorage calls through components.
State Service → Persistence Adapter → LocalStorage
// cart.storage.ts
import { Injectable } from '@angular/core';
import { CartItem } from './cart.models';
export abstract class CartStorage {
abstract load(): readonly CartItem[];
abstract save(items: readonly CartItem[]): void;
}
@Injectable({ providedIn: 'root' })
export class LocalCartStorage extends CartStorage {
private readonly key = 'cart';
load(): readonly CartItem[] {
try {
return JSON.parse(localStorage.getItem(this.key) ?? '[]');
} catch {
return [];
}
}
save(items: readonly CartItem[]): void {
try {
localStorage.setItem(this.key, JSON.stringify(items));
} catch {
/* storage full or unavailable: fail soft */
}
}
}
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [{ provide: CartStorage, useExisting: LocalCartStorage }],
};
// cart.state.ts (persistent version)
@Injectable({ providedIn: 'root' })
export class CartState {
private readonly storage = inject(CartStorage);
private readonly _items = signal<readonly CartItem[]>(this.storage.load());
readonly items = this._items.asReadonly();
constructor() {
// A legitimate effect: syncing to an external system
effect(() => this.storage.save(this._items()));
}
/* selectors and commands unchanged */
}
Swapping to IndexedDB or a backend means writing a new adapter, not touching components or selectors. In tests, provide a fake. (If you use SSR, guard browser-only APIs.)
Error handling
State services should define predictable behavior for:
- API failure: set an error state with a user-presentable message.
- Empty state: distinguish "no data" from "not loaded yet" from "failed".
-
Retry: expose a
retry()command. - Loading: one source of truth for the flag.
- Partial data: decide whether to keep stale data visible while reloading.
-
Authentication expiration: handle it centrally (an HTTP interceptor calling
AuthState.sessionExpired()), not in every component.
// auth.interceptor.ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthState);
return next(req).pipe(
catchError(error => {
if (error.status === 401) auth.sessionExpired();
return throwError(() => error);
}),
);
};
Do not let every component implement its own version of the same loading/error logic. That's how the same bug gets fixed five different ways.
Testing a signal-based service
Because all mutation goes through methods, tests are short and direct.
// cart.state.spec.ts
import { CartState } from './cart.state';
import { CartItem } from './cart.models';
describe('CartState', () => {
let state: CartState;
const apple: CartItem = {
id: '1', name: 'Apple', price: 2, quantity: 1, active: true,
};
beforeEach(() => {
state = new CartState();
});
it('starts empty', () => {
expect(state.items()).toEqual([]);
expect(state.hasItems()).toBe(false);
expect(state.total()).toBe(0);
});
it('adds an item', () => {
state.addItem(apple);
expect(state.itemCount()).toBe(1);
});
it('merges quantities for duplicate items', () => {
state.addItem(apple);
state.addItem({ ...apple, quantity: 2 });
expect(state.items().length).toBe(1);
expect(state.itemCount()).toBe(3);
expect(state.total()).toBe(6);
});
it('removes an item', () => {
state.addItem(apple);
state.removeItem('1');
expect(state.hasItems()).toBe(false);
});
it('removes an item when quantity is set to zero', () => {
state.addItem(apple);
state.setQuantity('1', 0);
expect(state.hasItems()).toBe(false);
});
it('filters active items', () => {
state.addItem(apple);
state.addItem({ ...apple, id: '2', active: false });
expect(state.activeItems().map(i => i.id)).toEqual(['1']);
});
it('resets state', () => {
state.addItem(apple);
state.clear();
expect(state.itemCount()).toBe(0);
});
});
For loading and error state, use HttpTestingController:
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController, provideHttpClientTesting,
} from '@angular/common/http/testing';
describe('OrdersState', () => {
let state: OrdersState;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [OrdersState, provideHttpClient(), provideHttpClientTesting()],
});
state = TestBed.inject(OrdersState);
http = TestBed.inject(HttpTestingController);
});
it('sets loading, then data', () => {
state.load();
expect(state.loading()).toBe(true);
http.expectOne('/api/orders').flush([{ id: 'o1', total: 10 }]);
expect(state.loading()).toBe(false);
expect(state.orders().length).toBe(1);
expect(state.error()).toBeNull();
});
it('sets error state on failure', () => {
state.load();
http.expectOne('/api/orders').flush('fail', { status: 500, statusText: 'Server Error' });
expect(state.loading()).toBe(false);
expect(state.error()).toContain('Could not load');
});
it('shows empty state', () => {
state.load();
http.expectOne('/api/orders').flush([]);
expect(state.isEmpty()).toBe(true);
});
});
Feature lifecycle is easy to test too: because CheckoutState is provided per feature, each test provides a fresh instance via TestBed and nothing leaks between tests.
If your state contains an effect(), create it through TestBed.inject(...) (effects need an injection context) and use TestBed.tick() to flush effects before asserting.
Performance: what to claim and what not to
Signals provide fine-grained reactive dependencies, which can reduce unnecessary work. That doesn't mean "signals automatically make everything faster".
Real-world performance depends on:
- Component structure
- State granularity
- Template complexity
- Derived computations
- Rendering frequency
- Network behavior
- Data size
- Change detection strategy
A good state architecture improves predictability first, and makes later optimization easier because you know where state lives and what depends on it. Measure before you optimize.
8 state management mistakes
1. Making everything global. Scope state to its owner. Use route or component providers.
2. Exposing writable signals. Keep _state private and expose asReadonly().
3. Using effect() for derived state. Use computed().
4. Putting business logic in components. Components render and dispatch intent; rules live in the state service.
// ❌ Rule in the component
addToCart(p: Product) {
const existing = this.cart.items().find(i => i.id === p.id);
if (existing) { /* merge... */ } else { /* add... */ }
}
// ✅ Rule in the service
addToCart(p: Product) {
this.cart.addItem({ ...p, quantity: 1, active: true });
}
5. Letting a state service become a god object. See the next section.
6. Duplicating the same state across components. One owner, many readers.
7. Mixing API calls, UI state, persistence, and business logic without boundaries. Separate data access, state, and storage adapters.
8. Adopting a library only because "enterprise apps use it". Adopt it because you need explicit actions, effects, tooling, or coordination.
The god state service
A service can grow too large.
// ❌
@Injectable({ providedIn: 'root' })
export class AppStateService {
// authentication
// cart
// orders
// products
// notifications
// preferences
// permissions
// checkout
// analytics
}
Everything depends on it, everything can change it, and every test needs all of it.
// ✅ Cohesive ownership
AuthState // application-level
CartState // feature/domain
CheckoutState // route-scoped
NotificationState // application-level
PreferencesState // application-level
Each service has one reason to change. When two services need to cooperate, one reads the other through its readonly API (as CheckoutState reads CartState) rather than both reaching into a shared bag of state.
When a dedicated library makes sense
NgRx, NGXS, and Akita can be the right choice when complexity justifies things like:
- Explicit actions, reducers, and effects
- Entity management
- Advanced debugging and time-travel-style tooling
- Strict architectural conventions
- Large-team coordination
- Many unrelated features depending on the same state
- Complex, numerous state transitions
- An existing team-wide convention already built around the library
There's also a middle ground: signal-based stores such as NgRx's SignalStore offer more structure than a hand-rolled service while staying signal-native. Whether that's worth it depends on your team and app.
The point is not "don't use NgRx." The point is don't introduce a large state-management architecture before the application needs it.
Decision framework
| Scenario | Recommended starting point | Why |
|---|---|---|
| Local UI state | signal() |
Simple and local |
| Feature state | Signal-based service | Encapsulation |
| Derived state | computed() |
Declarative derivation |
| Async streams | RxJS | Stream composition |
| Async resource state | resource() |
Loading/data/error lifecycle |
| Large complex global state | Dedicated state library | Advanced coordination and tooling |
Local state: modal visibility, selected tab, form UI state, temporary component state.
Feature state: product listing filters, shopping cart, checkout workflow, feature-specific cached data.
Application state: current authenticated user, global configuration, application-wide preferences.
Dedicated library: consider it when many unrelated features share the same state, event flows are complex, transitions are numerous, debugging needs are significant, or your team's conventions already depend on one.
Architecture checklist
- [ ] Who owns this state?
- [ ] Is it local, feature, or application state?
- [ ] Does it need to be global?
- [ ] Can
signal()represent it? - [ ] Is the derived state
computed()? - [ ] Are writable signals private?
- [ ] Are components consuming readonly state?
- [ ] Are mutations controlled through methods?
- [ ] Does async work belong in RxJS or
resource()? - [ ] Is
effect()actually needed? - [ ] Is the service becoming a god object?
- [ ] Does the complexity justify NgRx or another library?
Conclusion
Modern Angular doesn't remove the need for state architecture. It gives you better primitives for building one.
Don't start by asking which state-management library to install. Start by asking where the state belongs, who owns it, and how complex its lifecycle actually is.
Use the smallest state-management solution that keeps complexity under control: not the smallest possible tool, and not the most powerful one.
I'd like to hear how you draw the line: where do you move from a lightweight signal-based state service to a dedicated state-management library? Let me know in the comments.
📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.
Top comments (0)