Add NgRx Signal Store to an Existing Classic NgRx App
Add signal-based state to one feature without rewriting your reducers, effects, or selectors.
20 Jul 2025, 07:49 UTC

One feature, two state styles
You inherit an Angular application that already uses classic NgRx Store, Effects, and selectors. A new feature needs fine-grained reactivity in templates, and the team wants to avoid writing another reducer, action type, and selector for a simple CRUD slice. You do not have time to migrate the whole store.
NgRx Signal Store lets you add a signal-based feature alongside your existing classic NgRx setup. You can migrate one slice at a time, keep current reducers and effects untouched, and read store state directly in templates with Angular signals. This assumes Angular 17.1 or later and NgRx 17 or later.
What Signal Store changes
Signal Store replaces the classic trio of reducer, actions, and selectors with a single signalStore factory. The factory accepts feature functions:
withStatedefines the initial state shape.withMethodsdefines synchronous or asynchronous functions that mutate state usingpatchState.withComputeddefines read-only derived signals using Angular'scomputed.
State mutations are synchronous method calls on the store service. Templates read signals directly, so there is no async pipe or selector subscription for that feature.
Worked example: a todo feature
Here is a minimal Signal Store for a todo list. It keeps items in state, exposes a filtered list as a computed signal, and updates state through methods.
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
export interface TodoState {
items: Todo[];
filter: string;
}
export const initialState: TodoState = {
items: [],
filter: 'all',
};
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store) => ({
async addTodo(text: string) {
// Replace with your data service call.
patchState(store, { items: [...store.items(), { id: Date.now(), text, done: false }] });
},
setFilter(filter: string) {
patchState(store, { filter });
},
})),
withComputed((store) => ({
filteredTodos: computed(() => {
const items = store.items();
const filter = store.filter();
return filter === 'all'
? items
: items.filter((todo) => todo.done === (filter === 'completed'));
}),
}))
);
In a component template, read the signal directly:
@for (todo of store.filteredTodos(); track todo.id) {
{{ todo.text }}
}
Because filteredTodos is a signal, Angular updates the DOM when the underlying state changes without an explicit subscription.
Running Signal Store next to classic NgRx
Classic provideState and provideEffects remain available. You can keep one feature on classic Store while adding a Signal Store feature in the same application.
export const appConfig: ApplicationConfig = {
providers: [
provideState({ name: 'classicTodos', reducer: classicTodoReducer }),
provideEffects([ClassicTodoEffects]),
],
};
If the Signal Store is provided in root with { providedIn: 'root' }, it is available application-wide. For a lazy-loaded feature, add the store to that route's providers array:
{
path: 'todos',
loadComponent: () => import('./todo/todo.component'),
providers: [TodoStore],
}
Forgetting the lazy-route provider causes a NullInjectorError at runtime because the injector cannot find the store in that lazy context.
Limits and how to verify
withComputed signals are read-only. Attempting to write to them throws, so mutable derived data must stay in withState and be updated through methods. Complex Effects chains, such as debounced searches or optimistic updates with rollback, often become more verbose in withMethods than in declarative createEffect.
Verify the store works before shipping it:
- Run
ng add @ngrx/signals@latestin the project root. No special permissions are required beyond normal npm access. It updates package.json and peer dependencies. Risk: on Angular 16 or earlier, this command fails or installs incompatible versions; upgrade Angular first. - Write a test that injects
TodoStore, callsaddTodo, and assertsstore.items().lengthincreases synchronously. You do not needdetectChangesfor the assertion. - Add
withDevtools('todos')to the factory, serve the app, open the Redux DevTools extension, invoke a store method, and confirm an entry appears with previous and next state. - Run
ng build --stats-jsonin the project root and inspect the output withwebpack-bundle-analyzerto compare the Signal Store chunk against an equivalent classic reducer, effect, and selector chunk.
Closing
Pick one read-heavy feature, implement it as a Signal Store, and run it beside your classic Store for a sprint. If the computed signals simplify your templates and the bundle impact is acceptable, migrate additional slices one at a time. There is no requirement to rewrite the entire store at once.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.