NgRx to Angular Signals: A Migration Guide (v22)

Move a classic NgRx app to Angular Signals: map Store, Selectors, Effects and Reducers to SignalStore or plain signals, see a before/after slice, learn when to keep NgRx, and dodge the pitfalls.

Share
NgRx to Angular Signals: A Migration Guide (v22)
NgRx to Angular Signals: A Migration Guide (v22)

You have a classic NgRx app. There is a Store, a folder of actions, reducers wired through createReducer, a pile of createSelector calls, and effects that switchMap into HTTP services. It works. But every new feature means a new action, a new reducer case, a new effect, and three selectors, and you are tired of the boilerplate tax.

Angular v22 makes Signals the default reactivity model. New apps are zoneless, OnPush is the default change detection strategy, and the resource and httpResource APIs are now stable. So the obvious question: how do you move NgRx state to Signals, and is it even worth it?

This guide gives you two realistic paths and a concept-by-concept mapping so you are not guessing.

  • Path A - @ngrx/signals SignalStore. The incremental, NgRx-native route. You keep the NgRx mental model (a store, derived state, methods) but drop most of the ceremony.
  • Path B - plain signals in a service. For teams dropping NgRx entirely and managing state with signal + computed in a plain injectable.

Both are valid. The right one depends on how much global, shared, cross-feature state you actually have. We will get to that.

The core mapping

Most of a migration comes down to translating five NgRx concepts. Here is the cheat sheet for both paths.

NgRx concept SignalStore equivalent (Path A) Plain signals equivalent (Path B)
StoreModule.forFeature slice / state shape withState({ ... }) signal({ ... }) per slice
createSelector (derived state) withComputed(...) computed(...)
createReducer + on(...) (state transitions) withMethods + patchState a method that calls set / update
dispatch(action) call a store method directly call a service method directly
createEffect (async side effects) rxMethod(...) or httpResource rxMethod, effect, or httpResource

The big conceptual shift: in NgRx you dispatch an action and a reducer decides what happens. With Signals you call a method that updates state directly. The indirection (action to reducer to selector) collapses. That is the whole point, and it is also the thing that makes some teams nervous, so we will cover the trade-off honestly.

Path A: NgRx Store to @ngrx/signals SignalStore

@ngrx/signals is NgRx's own signal-based store. If your team likes the NgRx structure but wants less boilerplate, this is the path of least resistance. Install it alongside your existing @ngrx/store so you can migrate one feature at a time.

npm install @ngrx/signals

Before: a classic NgRx counter slice

Here is a minimal feature slice the old way: state, actions, reducer, and a couple of selectors.

// counter.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';

export const CounterActions = createActionGroup({
  source: 'Counter',
  events: {
    Increment: emptyProps(),
    Decrement: emptyProps(),
    'Set Step': props<{ step: number }>(),
  },
});

// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { CounterActions } from './counter.actions';

export interface CounterState {
  count: number;
  step: number;
}

const initialState: CounterState = { count: 0, step: 1 };

export const counterReducer = createReducer(
  initialState,
  on(CounterActions.increment, (state) => ({
    ...state,
    count: state.count + state.step,
  })),
  on(CounterActions.decrement, (state) => ({
    ...state,
    count: state.count - state.step,
  })),
  on(CounterActions.setStep, (state, { step }) => ({ ...state, step })),
);

// counter.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CounterState } from './counter.reducer';

export const selectCounter = createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(selectCounter, (s) => s.count);
export const selectIsNegative = createSelector(selectCount, (c) => c < 0);

Plus the wiring in app.config.ts, plus the component injecting Store and calling store.select(...) and store.dispatch(...). Four files for a counter.

After: the same slice as a SignalStore

// counter.store.ts
import { computed } from '@angular/core';
import {
  signalStore,
  withState,
  withComputed,
  withMethods,
  patchState,
} from '@ngrx/signals';

type CounterState = { count: number; step: number };

const initialState: CounterState = { count: 0, step: 1 };

export const CounterStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ count }) => ({
    isNegative: computed(() => count() < 0),
  })),
  withMethods((store) => ({
    increment() {
      patchState(store, { count: store.count() + store.step() });
    },
    decrement() {
      patchState(store, { count: store.count() - store.step() });
    },
    setStep(step: number) {
      patchState(store, { step });
    },
  })),
);

One file. Notice the mapping:

  • withState replaces the reducer's initialState and the state shape.
  • withComputed replaces your selectors. Every state field becomes a signal you call (count()), and isNegative is a computed exactly like a memoized selector.
  • withMethods replaces actions plus reducer cases. patchState(store, partial) is the immutable update, the same job on(...) did. There is no separate action object to dispatch.

The component side gets simpler too. Inject the store and read signals directly. No async pipe, no select.

import { Component, inject } from '@angular/core';
import { CounterStore } from './counter.store';

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ store.count() }} (step {{ store.step() }})</p>
    @if (store.isNegative()) {
      <p class="warn">Below zero</p>
    }
    <button (click)="store.increment()">+</button>
    <button (click)="store.decrement()">-</button>
  `,
})
export class CounterComponent {
  protected readonly store = inject(CounterStore);
}

store.count, store.step, and store.isNegative are all signals. State and derived state are read the same way, which is one less distinction to carry in your head.

Migrating effects: createEffect to rxMethod

This is where most NgRx apps actually live: effects that listen for an action, call an HTTP service, and dispatch a success or failure action. @ngrx/signals replaces that with rxMethod, a reactive method that takes an RxJS pipeline and runs it whenever you call it with a value, a signal, or an observable.

Here is a typical "load a user when the selected id changes" effect, migrated. rxMethod comes from @ngrx/signals/rxjs-interop, and tapResponse (the safe way to handle next/error without killing the stream) comes from @ngrx/operators.

import { computed, inject } from '@angular/core';
import {
  signalStore,
  withState,
  withMethods,
  patchState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap, tap } from 'rxjs';
import { UsersService } from './users.service';
import { User } from './user';

type UsersState = {
  user: User | null;
  loading: boolean;
  error: string | null;
};

const initialState: UsersState = { user: null, loading: false, error: null };

export const UserStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods((store, usersService = inject(UsersService)) => ({
    loadUser: rxMethod<string>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap((id) =>
          usersService.getById(id).pipe(
            tapResponse({
              next: (user) => patchState(store, { user, loading: false }),
              error: (err: Error) =>
                patchState(store, { error: err.message, loading: false }),
            }),
          ),
        ),
      ),
    ),
  })),
);

The mapping from a classic effect:

  • The effect's ofType(loadUser) trigger becomes calling store.loadUser(id) (or passing it a signal, in which case it re-runs whenever that signal changes).
  • switchMap into the service is identical. Your existing operator knowledge transfers directly.
  • The success/failure dispatch becomes patchState inside tapResponse. The loading flag you used to manage with two actions is now two patchState calls.

Note that rxMethod is tied to its injection context and cleans itself up when the injector is destroyed, so there is no manual unsubscribe.

When httpResource is the better answer

If your "effect" is really just "fetch this data when a parameter changes and expose loading/error/value," you may not need rxMethod at all. Angular v22 ships httpResource as a stable API built exactly for that. It gives you value(), isLoading(), and error() signals out of the box.

import { Component, signal, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-user',
  template: `
    @if (userResource.isLoading()) {
      <p>Loading...</p>
    } @else if (userResource.error()) {
      <p>Failed to load</p>
    } @else {
      <h2>{{ userResource.value()?.name }}</h2>
    }
  `,
})
export class UserComponent {
  readonly userId = signal('1');
  readonly userResource = httpResource<User>(
    () => `/api/users/${this.userId()}`,
  );
}

That single declaration replaces a state slice (loading/error/value), an effect, and the actions that drove it. For read-heavy "fetch and display" features, reach for httpResource first and keep rxMethod for the cases that genuinely need custom RxJS orchestration (debounced search, race-condition control with exhaustMap, retries with backoff). We cover resource, rxResource, and httpResource in depth in what's new in Angular v22.

Path B: dropping NgRx for plain signals

If most of your "global state" is really feature-local state that you put in NgRx out of habit, you may not need a store library at all. A plain injectable with signal and computed is often enough.

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

export interface CartItem {
  id: string;
  qty: number;
  price: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  // state (was: a reducer slice)
  private readonly _items = signal<CartItem[]>([]);

  // public read-only views (was: selectors)
  readonly items = this._items.asReadonly();
  readonly total = computed(() =>
    this._items().reduce((sum, i) => sum + i.qty * i.price, 0),
  );
  readonly itemCount = computed(() => this._items().length);

  // methods (was: actions + reducer cases)
  add(item: CartItem) {
    this._items.update((items) => [...items, item]);
  }

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

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

The mapping is the same as Path A, minus the library:

  • The private signal holds the slice; exposing .asReadonly() gives consumers a selector-like read-only signal they cannot mutate.
  • computed replaces selectors and is memoized the same way.
  • Methods replace actions plus reducer. update for immutable transforms, set for replacement.

A useful detail: mutate() was removed from signals, so always produce a new array or object inside update (spread, filter, map) rather than mutating in place. That immutability discipline is exactly what patchState enforced for you in NgRx.

For async work in Path B you have the same options: httpResource for declarative fetching, rxMethod (yes, you can use @ngrx/signals/rxjs-interop even without a SignalStore), or a plain effect for fire-and-forget side effects. If you are coming from RxJS-heavy services rather than NgRx specifically, the companion RxJS to Angular Signals migration guide walks through toSignal, toObservable, and refactoring operator chains.

When to keep NgRx (honestly)

Signals are not a universal replacement for the classic NgRx Store. Keep it, or stay hybrid, when:

  • You rely on the time-travel and action history in Redux DevTools for debugging complex flows. SignalStore has community DevTools support, but classic NgRx's action log is more mature, and a plain-signals service gives you none of it for free.
  • You have a genuinely large, deeply shared, normalized state tree that many distant features read and write. The action/reducer indirection that feels like overhead in a counter becomes a real architectural benefit at that scale: a single, auditable place where every state transition is named and logged.
  • Your team depends on the strict unidirectional, event-sourced discipline that explicit actions enforce. Direct method calls are more convenient and also easier to scatter. If "who changed this state and why" is a recurring debugging question on your team, named actions earn their keep.
  • You use @ngrx/entity, effects-heavy orchestration, or meta-reducers that have no one-to-one signal equivalent yet.

A pragmatic middle ground that many teams land on: keep classic NgRx for the few truly global, cross-cutting slices (auth, feature flags, a normalized entity cache), and use SignalStore or plain signals for everything feature-local. You do not have to pick one religion for the whole app. NgRx Store and @ngrx/signals coexist in the same application without issue.

Pitfalls to watch for

A few things that trip people up during the move:

  • Reading a signal where you meant to pass it. store.count is the signal; store.count() is its value. In a computed or template you call it. When wiring an rxMethod to re-run on a signal, you pass the signal itself: store.loadUser(this.userId), not this.userId().
  • Mutating state in place. No mutate(), and patchState expects a new partial. Spread your objects and arrays. In-place pushes will not trigger updates and will introduce shared-reference bugs.
  • Calling rxMethod or creating effects outside an injection context. Both rely on the current injector for cleanup. Set them up in a field initializer, constructor, or a withMethods callback, not inside an async callback firing later.
  • Porting every action one-to-one. The point of the migration is to collapse indirection. If a SignalStore method just calls one patchState, you do not also need a corresponding "action" abstraction. Resist recreating the boilerplate you came to escape.
  • Overusing global stores. With Signals it is cheap to keep state local to a component or a feature service. Not everything needs to be in a root-provided store. Provide a SignalStore at the component or route level when its state is feature-scoped.
  • Expecting NgRx-style global DevTools by default. Plan for this before you rip out the classic Store if action-log debugging is part of your workflow.

FAQ

What is the equivalent of an NgRx selector in Signals?

A computed. In @ngrx/signals you define them in withComputed; in a plain service you use computed() directly. Both are memoized and only recompute when their dependencies change, exactly like createSelector.

What replaces NgRx Effects with Signals?

Three options depending on the job: rxMethod from @ngrx/signals/rxjs-interop for custom RxJS orchestration, httpResource (stable in v22) for declarative data fetching, or a plain effect() for simple fire-and-forget side effects. Most "load on parameter change" effects become a one-line httpResource.

Is @ngrx/signals SignalStore replacing classic NgRx Store?

No. They are complementary and ship from the same NgRx team. SignalStore is the recommended signal-based option for most state, while classic Store remains a strong fit for large, normalized, event-sourced state where explicit actions and mature DevTools matter. They run side by side in one app.

Do I have to migrate the whole app at once?

No, and you should not. Install @ngrx/signals next to your existing @ngrx/store, migrate one feature slice at a time, and ship continuously. Signals and the classic Store interoperate fine during the transition.

How do I update state in a SignalStore?

Call patchState(store, partial) inside a withMethods method. It performs an immutable merge into the store's state, the same role a reducer's on(...) handler played.

Can I use rxMethod without a SignalStore?

Yes. rxMethod is a standalone factory from @ngrx/signals/rxjs-interop. You can use it in a plain service or component to drive an RxJS pipeline from a signal, even if you never create a SignalStore.

Go deeper

This article is the field guide. The book is the full map.

Mastering Angular Signals has a dedicated chapter on interoperability and migration that takes these patterns further: bridging RxJS and Signals with toSignal and toObservable, phased migration strategy for large codebases, and refactoring tangled operator chains into computed and effect. It is written against Angular v22 with runnable code, carries a foreword from Mark Thompson on the Angular team at Google, and is edited by GDE Sonu Kapoor.

Mastering Angular Signals
Master Angular Signals, updated for v22 — Signal Forms, httpResource, zoneless, and a practical RxJS/NgRx migration. From core concepts to advanced patterns.

If RxJS rather than NgRx is your bottleneck, start with the RxJS to Angular Signals migration guide. For the full v22 picture (zoneless defaults, OnPush, httpResource, Signal Forms), see what's new in Angular v22.