logo
2026-04-13 Angular 7 minutes

State Management in Modern Angular: From Services to Signal Stores

State management in Angular has always been a spectrum. On one end, you have a simple service with a BehaviorSubject. On the other, a full NgRx store with actions, reducers, effects, and selectors. For years, there was not much in between. You either kept it simple and hit a wall, or went all-in on boilerplate.

When I re-architected Zuub from a monolithic Angular app into module-federated microfrontends, state management was the hardest problem. Not the routing, not the build system — the state. Which state is local to a micro app? Which state is shared across the platform? Does the user session live in the shell, and if so, how do the child apps react to a token refresh?

That gap between “too simple” and “too complex” is gone now. Signals gave us a reactive primitive that actually works for state. NgRx Signal Store gave us composable, functional state management without the ceremony. And Angular’s DI system ties it all together in ways that are surprisingly powerful once you understand the scoping rules.

Let me walk through the full progression — from hand-rolled stores to the real thing.


The Hand-Rolled Signal Store

The simplest useful pattern: a service with private writable signals and public read-only access. Mutations go through controlled methods.

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();
  readonly totalPrice = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  readonly itemCount = computed(() => this._items().length);

  addItem(item: CartItem) {
    this._items.update(items => [...items, item]);
  }

  removeItem(id: string) {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  clear() {
    this._items.set([]);
  }
}

This is a solid pattern. The writable signal is private — nobody outside the store can call .set() or .update() directly. Components read items() and totalPrice(), and they call methods to mutate. Clean, testable, and easy to reason about.

For a lot of apps, this is enough. But it gets more interesting when you start thinking about where these stores live.


Three DI Scopes That Matter

Angular’s dependency injection is more flexible than most people realize. Where you provide a store fundamentally changes its lifecycle and sharing behavior.

Root scope — app-wide singleton. One instance shared everywhere. This is your authentication state, your user session, your global preferences. In a microfrontend architecture, this is the state that lives in the shell and gets shared downward.

@Injectable({ providedIn: 'root' })
export class AuthStore { ... }

Component scope — a new instance for each component. Destroyed when the component is destroyed. Perfect for local UI state that should not leak.

@Component({
  selector: 'app-property-editor',
  providers: [PropertyEditorStore],
  template: `...`
})
export class PropertyEditorComponent {
  store = inject(PropertyEditorStore);
}

Every PropertyEditorComponent gets its own PropertyEditorStore. Open two editors side by side? Two completely independent stores.

Route scope — feature-scoped. The store lives as long as the route is active and is shared by all components in that route subtree. This is the scope I wish I had understood better when we first split Zuub into microfrontends. A lot of state that we initially put in root scope should have been route-scoped. It only mattered within that micro app’s routes, and keeping it at root just created stale-state bugs when navigating between apps.

const routes: Routes = [
  {
    path: 'listings',
    providers: [ListingsStore],
    component: ListingsPageComponent,
  }
];

Every component under the listings route shares the same ListingsStore instance. Navigate away, and it is gone.


Route-Local Auto Cleanup

There is a subtle problem with route-scoped stores. If you set up effect() calls or resource subscriptions inside the store, they need to be cleaned up when the route is destroyed. Angular now handles this with withExperimentalAutoCleanupInjectors():

import { withExperimentalAutoCleanupInjectors } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withExperimentalAutoCleanupInjectors()),
  ]
};

With this enabled, route-level injectors are automatically destroyed when the route deactivates. Any effects, resources, or subscriptions created inside route-scoped services are cleaned up. No manual DestroyRef tracking needed.


linkedSignal for Writable Local Copies

Sometimes a component needs a local copy of store data that it can modify without affecting the store. Think of a form that edits a property — you want to work on a draft, not mutate the source of truth directly.

linkedSignal creates a writable signal that resets whenever its source changes:

@Component({ ... })
export class PropertyFormComponent {
  private store = inject(PropertyStore);

  // Writable local copy — resets when store.selectedProperty() changes
  draft = linkedSignal(() => this.store.selectedProperty());

  updateTitle(title: string) {
    this.draft.update(d => ({ ...d, title }));
  }

  save() {
    this.store.saveProperty(this.draft());
  }
}

If the user switches to a different property in the store, the draft resets automatically. But local edits via draft.update() do not propagate back to the store. It is a one-way link with local override.


delegatedSignal for Two-Way Reactivity

What if you do want local changes to write back to the store? delegatedSignal provides two-way reactivity:

@Component({ ... })
export class QuickEditComponent {
  private store = inject(SettingsStore);

  // Two-way: reads from store, writes back to store
  theme = delegatedSignal({
    get: () => this.store.theme(),
    set: (value) => this.store.setTheme(value),
  });

  toggleDarkMode() {
    this.theme.set(this.theme() === 'dark' ? 'light' : 'dark');
    // This calls store.setTheme() under the hood
  }
}

The component works with theme as if it were a local signal, but every .set() is delegated to the store. This is a clean pattern for components that need to both read and write shared state without importing the full store API.


Abstract Classes as DI Tokens

Here is a practical DI tip that trips people up. In Angular, you cannot use a TypeScript interface as a DI token. Interfaces are erased at compile time — they do not exist at runtime. There is nothing for the injector to look up.

Use abstract classes instead:

export abstract class NotificationService {
  abstract show(message: string, type: 'success' | 'error'): void;
  abstract dismiss(id: string): void;
}

@Injectable()
export class ToastNotificationService extends NotificationService {
  show(message: string, type: 'success' | 'error') { ... }
  dismiss(id: string) { ... }
}

// In your providers
providers: [
  { provide: NotificationService, useClass: ToastNotificationService }
]

Components inject NotificationService (the abstract class). You can swap the implementation without touching any consumer code. The abstract class acts as both the contract and the DI token.


Provider Functions That Encapsulate DI Config

For complex DI setups, expose a provider function instead of making consumers wire things up manually:

export function providePropertyStore(config: { preload: boolean }) {
  return [
    PropertyStore,
    { provide: PROPERTY_STORE_CONFIG, useValue: config },
    config.preload ? { provide: APP_INITIALIZER, useFactory: ... } : [],
  ].flat();
}

// Usage is clean
providers: [providePropertyStore({ preload: true })]

This pattern keeps DI details hidden behind a simple function call. Consumers do not need to know about config tokens or initializers.


Transitioning to NgRx Signal Store

The hand-rolled pattern works great until your store needs computed state that depends on async data, entity normalization, reusable cross-cutting concerns, or mutations with HTTP side effects. That is where NgRx Signal Store comes in.

It is not the old NgRx. No actions. No reducers. No effects files. It is a functional, composable store built on signals.

import { signalStore, withState, withComputed, withMethods, withHooks } from '@ngrx/signals';
import { computed } from '@angular/core';

export const PropertyStore = signalStore(
  withState({
    properties: [] as Property[],
    selectedId: null as string | null,
    filter: '',
  }),
  withComputed((store) => ({
    filteredProperties: computed(() =>
      store.properties().filter(p =>
        p.title.toLowerCase().includes(store.filter().toLowerCase())
      )
    ),
    selectedProperty: computed(() =>
      store.properties().find(p => p.id === store.selectedId())
    ),
  })),
  withMethods((store) => ({
    setFilter(filter: string) {
      patchState(store, { filter });
    },
    selectProperty(id: string) {
      patchState(store, { selectedId: id });
    },
  })),
  withHooks({
    onInit(store) {
      console.log('Store initialized');
    },
    onDestroy(store) {
      console.log('Store destroyed');
    },
  })
);

Each with* function adds capabilities to the store. They compose. They are typed. And they build on each other.


The _ Prefix Is Type-System Enforced

NgRx Signal Store uses a convention where private store members are prefixed with _. But this is not just a naming convention — it is enforced by the type system. Members prefixed with _ are excluded from the store’s public type signature.

export const CounterStore = signalStore(
  withState({ count: 0 }),
  withProps(() => ({
    _internalMultiplier: 3, // Not visible outside the store
  })),
  withComputed((store) => ({
    tripleCount: computed(() => store.count() * store._internalMultiplier),
  })),
  withMethods((store) => ({
    increment() {
      patchState(store, { count: store.count() + 1 });
    },
  }))
);

// In a component:
const store = inject(CounterStore);
store.count();         // Works
store.tripleCount();   // Works
store.increment();     // Works
store._internalMultiplier; // Type error — not exposed

This gives you real encapsulation without private class fields. Internal implementation details stay internal.


withResource for Async Data

Loading data from an API is the most common store operation. withResource auto-generates value, isLoading, error, and status signals:

import { withResource } from '@ngrx/signals';

export const PropertyStore = signalStore(
  withState({ selectedId: null as string | null }),
  withResource('property', (store) => ({
    request: () => store.selectedId(),
    loader: (id) => inject(PropertyService).getById(id),
  }))
);

// Auto-generated signals:
// store.property()       — the loaded data
// store.propertyIsLoading() — boolean
// store.propertyError()  — error if failed
// store.propertyStatus() — 'idle' | 'loading' | 'loaded' | 'error'

When selectedId changes, the resource automatically reloads. Loading state, error handling, and value management are all handled.


Mutations with httpMutation

For write operations, httpMutation gives you mutation tracking with operator selection for concurrency control:

import { httpMutation, switchOp, exhaustOp, concatOp } from '@ngrx/signals';

export const PropertyStore = signalStore(
  withState({ properties: [] as Property[] }),
  withMethods((store) => ({
    saveProperty: httpMutation((property: Property) => ({
      request: () => inject(PropertyService).save(property),
      operator: exhaustOp,  // Ignore new calls while one is in-flight
      onSuccess: (saved) => {
        patchState(store, {
          properties: store.properties().map(p => p.id === saved.id ? saved : p)
        });
      },
    })),
    deleteProperty: httpMutation((id: string) => ({
      request: () => inject(PropertyService).delete(id),
      operator: switchOp,  // Cancel previous, use latest
      onSuccess: () => {
        patchState(store, {
          properties: store.properties().filter(p => p.id !== id)
        });
      },
    })),
  }))
);

exhaustOp ignores duplicate calls (good for save buttons). switchOp cancels the previous request (good for search). concatOp queues them (good for ordered operations). You pick the right strategy per mutation.


Entity Management with withEntities

For collections, withEntities gives you normalized entity state with built-in CRUD operations:

import { withEntities, entityConfig } from '@ngrx/signals/entities';

const propertyConfig = entityConfig({
  entity: type<Property>(),
  collection: 'properties',
  selectId: (property) => property.id,
});

export const PropertyStore = signalStore(
  withEntities(propertyConfig),
  withMethods((store) => ({
    loadProperties(properties: Property[]) {
      setEntities(store, properties, propertyConfig);
    },
    addProperty(property: Property) {
      addEntity(store, property, propertyConfig);
    },
    updateProperty(id: string, changes: Partial<Property>) {
      updateEntity(store, { id, changes }, propertyConfig);
    },
  }))
);

// Auto-generated:
// store.propertiesEntities()   — the array
// store.propertiesIds()        — just the IDs
// store.propertiesEntityMap()  — normalized map by ID

Entities are stored normalized internally but exposed as both arrays and maps. This is the kind of thing you used to need @ngrx/entity and a full Redux setup for.


Custom Features: Reusable Cross-Cutting Concerns

The real power of the functional API is building reusable features. Here is a withCallState() feature that adds loading and error tracking to any store:

import { signalStoreFeature, withState, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

export type CallState = 'idle' | 'loading' | 'loaded' | 'error';

export function withCallState() {
  return signalStoreFeature(
    withState<{ callState: CallState }>({ callState: 'idle' }),
    withComputed((state) => ({
      isLoading: computed(() => state.callState() === 'loading'),
      isLoaded: computed(() => state.callState() === 'loaded'),
      hasError: computed(() => state.callState() === 'error'),
    }))
  );
}

// Use it in any store
export const PropertyStore = signalStore(
  withCallState(),
  withState({ properties: [] as Property[] }),
  withMethods((store) => ({
    async loadProperties() {
      patchState(store, { callState: 'loading' });
      try {
        const properties = await inject(PropertyService).getAll();
        patchState(store, { properties, callState: 'loaded' });
      } catch {
        patchState(store, { callState: 'error' });
      }
    },
  }))
);

Write it once, use it in every store that needs loading state. The feature adds its state and computed signals to the store’s type automatically.


The Non-Obvious Insight: Feature Ordering Matters

This is the thing that will bite you if you do not understand it. Feature ordering in signalStore() is not arbitrary. Each feature extends the store type, and later features can only reference members that were added by earlier features.

// This works:
export const PropertyStore = signalStore(
  withCallState(),                    // Adds callState, isLoading, etc.
  withState({ properties: [] }),      // Adds properties
  withMethods((store) => ({
    load() {
      patchState(store, { callState: 'loading' }); // Can reference callState
    }
  }))
);

// This does NOT work:
export const PropertyStore = signalStore(
  withState({ properties: [] }),
  withMethods((store) => ({
    load() {
      patchState(store, { callState: 'loading' }); // Error: callState doesn't exist yet
    }
  })),
  withCallState(), // Too late — methods were defined before this
);

withCallState() must appear before withMethods if the methods need to reference callState. The store type is built incrementally, feature by feature, top to bottom. This is not a quirk — it is by design. Each feature sees only what came before it.

Also worth noting: patchState is restricted to inside the store by default. Components cannot call patchState directly. They go through the methods you expose. This keeps mutations controlled and traceable.


Final Thoughts

State management in Angular is no longer a binary choice between “too simple” and “too complex.” The progression is smooth now. Start with a hand-rolled signal service. Scope it correctly with DI. When you outgrow it, move to NgRx Signal Store without throwing away your mental model — it is still signals, still DI, still Angular.

Here is my hard-won advice after living through a real microfrontend migration: get the DI scope right from day one. It is the single decision that is hardest to change later. Moving state from root scope to route scope means rewiring every consumer. Moving it the other direction means rethinking ownership. Spend the time upfront to ask “who owns this state and what is its lifecycle?” For authentication state, it was the shell (root). For clinical workflows, it was the route. For form drafts, it was the component. Every wrong answer cost us a refactor.

The functional composition model is genuinely different from what came before. Custom features, type-safe encapsulation, built-in resource and mutation management — it is a lot of power with very little ceremony.

Next, I will dig into how to structure large Angular codebases using vertical domain slicing and horizontal layers — the Architecture Matrix that keeps teams moving independently without stepping on each other. Stay tuned.