Choosing NgRx Classic or SignalStore for New Angular Projects
Decide between NgRx Classic and the new SignalStore for your next Angular project. This guide compares boilerplate, type safety, async handling, and testing, then walks through a concrete Todo feature implementation in both APIs.
18 Sept 2026, 15:31 UTC

Decision Context
When starting a new Angular application, the team must decide how to manage global state. NgRx Classic (Store, Reducers, Effects, Selectors) has been the de‑facto standard for years, but NgRx 17+ introduced SignalStore, a signal‑based API that promises less boilerplate and stronger type inference. The decision hinges on project constraints such as Angular version, team familiarity, third‑party libraries, and desired testing ergonomics.
Classic NgRx vs SignalStore
Below is a concise comparison of the two approaches for typical CRUD features.
| Aspect | NgRx Classic | SignalStore |
|---|---|---|
| Angular Version | ≥12 | ≥17.1 |
| Boilerplate | Actions, Reducers, Effects, Selectors, State interfaces (separate files) | Single service with withState, withMethods, withComputed, withHooks |
| Type Inference | Manual generics on actions and selectors | Automatic inference for state shape, computed signals, and method return types |
| Asynchronous Flow | Effects (RxJS streams) | RxMethod inside withMethods (simplified callback API) |
| Testing | provideMockStore / provideMockActions, separate reducer/effect tests | Plain TestBed with service provider; no special utilities |
| DevTools | StoreDevtoolsModule.instrument() | withDevtools('featureName') – logs method calls as actions |
| Third‑party Libraries | ngrx-data, ngrx-forms, ngrx-router-store, etc. | Not yet fully ported; community‑driven adapters exist |
| SSR Hydration | Meta‑reducers for state rehydration | Signals must be serialized/deserialized manually; no official SSR helper yet |
| Learning Curve | Well‑documented patterns, large hiring pool | New API; requires adjustment to signal semantics and patchState usage |
Trade‑offs
- Boilerplate vs. Familiarity: SignalStore cuts ~40‑60% of files for CRUD features, but teams used to classic patterns may face a learning curve.
- Type Safety: SignalStore’s inference reduces manual type maintenance, especially for nested state.
- Asynchronous Complexity: For simple async flows,
rxMethodis straightforward. Complex effect chains (debounce, retry, cancellation) still benefit from raw RxJS operators inside the method. - Tooling & Ecosystem: Classic NgRx has mature DevTools integration, community plugins, and a large body of tutorials. SignalStore is newer; some third‑party libraries are missing.
- SSR & Hydration: Classic patterns have established meta‑reducers. SignalStore will require custom serialization logic for SSR.
- Performance: Signals compute lazily and avoid selector memoization overhead. For most applications the difference is negligible; profiling is recommended if large data sets are involved.
Concrete Implementation: Todo Feature
The following snippets show how a simple Todo CRUD feature could be implemented in both approaches. The code is illustrative; run it in an Angular 18 project with the respective NgRx packages installed.
Classic NgRx
// actions.ts
export const loadTodos = createAction('[Todo] Load');
export const loadSuccess = createAction('[Todo] Load Success', props<{todos: Todo[]}>());
export const addTodo = createAction('[Todo] Add', props<{title: string}>());
// reducer.ts
export interface TodoState { todos: Todo[]; loading: boolean; }
const initialState: TodoState = { todos: [], loading: false };
export const todoReducer = createReducer(
initialState,
on(loadTodos, state => ({ ...state, loading: true })),
on(loadSuccess, (state, { todos }) => ({ ...state, todos, loading: false })),
on(addTodo, (state, { title }) => ({ ...state, todos: [...state.todos, { id: Date.now(), title }] }))
);
// effects.ts
@Injectable()
export class TodoEffects {
load$ = createEffect(() => this.actions$.pipe(
ofType(loadTodos),
mergeMap(() => this.service.getTodos().pipe(
map(todos => loadSuccess({ todos }))
))
));
constructor(private actions$: Actions, private service: TodoService) {}
}
// selectors.ts
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(selectTodoState, s => s.todos);
export const selectLoading = createSelector(selectTodoState, s => s.loading);
SignalStore
export interface Todo { id: number; title: string; }
export interface TodoState { todos: Todo[]; loading: boolean; }
@Injectable({ providedIn: 'root' })
export class TodoSignalStore extends SignalStore {
constructor(service: TodoService) {
super(
withState({ todos: [], loading: false }),
withMethods({
load: rxMethod(async () => {
this.patchState({ loading: true });
const todos = await service.getTodos();
this.patchState({ todos, loading: false });
}),
add: rxMethod((title: string) => {
this.patchState(state => ({
...state,
todos: [...state.todos, { id: Date.now(), title }]
}));
})
}),
withComputed({
allTodos: computed(() => this.state().todos),
isLoading: computed(() => this.state().loading)
}),
withDevtools('todos')
);
}
}
Testing Strategy
Classic NgRx tests typically look like this:
describe('todoReducer', () => {
it('should load todos', () => {
const action = loadSuccess({ todos: [{ id: 1, title: 'a' }] });
const state = todoReducer(undefined, action);
expect(state.todos.length).toBe(1);
});
});
describe('TodoEffects', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TodoEffects, provideMockActions(), provideMockStore(), TodoService]
});
});
});
SignalStore tests are simpler:
describe('TodoSignalStore', () => {
let store: TodoSignalStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TodoSignalStore, TodoService]
});
store = TestBed.inject(TodoSignalStore);
});
it('should add a todo', () => {
const before = store.allTodos();
store.add('new');
expect(store.allTodos().length).toBe(before.length + 1);
});
});
Migration Path
Teams can adopt SignalStore incrementally. Use signalStoreFeature to wrap an existing reducer:
export const todoFeature = signalStoreFeature('todos', withState(reducerInitialState), withMethods(reducer));
Both provideStore (classic) and provideState (SignalStore) can coexist, allowing a feature‑by‑feature transition.
Practical Next Steps
- Check your Angular version – SignalStore requires 17.1+.
- Prototype a single feature in both styles and compare lines of code and type errors.
- Profile with Angular DevTools for any performance concerns.
- Review third‑party library support; if you rely on
ngrx-dataorngrx-forms, staying with classic may be safer. - Plan a migration sprint if you choose SignalStore; allocate time for training and refactoring.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.