DEV Community

ABDELAAZIZ OUAKALA
ABDELAAZIZ OUAKALA

Posted on

Clean State Management in Angular: Signals, Services, and Boundaries

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

  1. The real question: ownership
  2. The complexity spectrum
  3. Who owns this state?
  4. The native primitives and their jobs
  5. The clean state service pattern
  6. Private writable state, public readonly state
  7. Read vs write: queries and commands
  8. Immutable updates
  9. Selectors with computed
  10. Using the state in a component
  11. effect: use it for external systems only
  12. linkedSignal: writable state that follows another signal
  13. Feature-scoped state
  14. Application-level state
  15. Async state
  16. Signals and RxJS
  17. Persistence as infrastructure
  18. Error handling
  19. Testing a signal-based service
  20. Performance: what to claim and what not to
  21. 8 state management mistakes
  22. The god state service
  23. When a dedicated library makes sense
  24. Decision framework
  25. Architecture checklist
  26. 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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([]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Every decision, explained:

  • _items is 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 cannot set() or update().
  • readonly CartItem[] plus update() 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 addItem express 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]);
Enter fullscreen mode Exit fullscreen mode
// ✅ Controlled mutation boundary
export class CartState {
  private readonly _items = signal<readonly CartItem[]>([]);
  readonly items = this._items.asReadonly();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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),
);
Enter fullscreen mode Exit fullscreen mode

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(),
}));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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());
  });
}
Enter fullscreen mode Exit fullscreen mode
// ✅ Declarative derivation
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
Enter fullscreen mode Exit fullscreen mode

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()})`;
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
  },
];
Enter fullscreen mode Exit fullscreen mode
// app.routes.ts
export const routes: Routes = [
  {
    path: 'checkout',
    loadChildren: () =>
      import('./checkout/checkout.routes').then(m => m.checkoutRoutes),
  },
];
Enter fullscreen mode Exit fullscreen mode

You can also provide at component level for narrower scope:

@Component({
  selector: 'app-product-filters',
  providers: [ProductListState],
  /* ... */
})
export class ProductFiltersComponent {}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

The conceptual model is simply:

state = { data, loading, error }
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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 */
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [{ provide: CartStorage, useExisting: LocalCartStorage }],
};
Enter fullscreen mode Exit fullscreen mode
// 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 */
}
Enter fullscreen mode Exit fullscreen mode

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);
    }),
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)