NgRx Signal Store is a lightweight and reactive approach to managing state in Angular applications. It integrates seamlessly with Angular’s signal-based reactivity model and removes the need for traditional boilerplate around reducers, selectors, and effects.
We’ve all worked on apps where we don’t necessarily need full-blown reducers, actions, and effects just to handle local or feature-specific state. Signal Store gives us a more compact, declarative way to manage state, while still supporting powerful patterns like entity collections and computed views.
In this post, we’ll walk through a real-world Signal Store in Angular that features a task board. You’ll find the full sample repository linked at the end.
Why Signal Store
NgRx Signal Store is designed to work with Angular's newer primitives like signals
, inject()
, and computed()
. It removes the need for action creators, reducers, and selectors by giving us a compact way to manage state and handle async operations in one place.
What I liked most when using it:
- We get fine-grained reactivity without manually subscribing
- We can define computed properties with native
computed()
functions - Everything can be colocated and self-contained in one store class
Overall, this feels much closer to modern Angular and way easier to grasp than traditional NgRx reducer-based stores.
Signal Store structure
A Signal Store is composed of features like withState()
for defining reactive state, withEntities()
for managing collections, withComputed()
for reactive derived values, and withMethods()
for exposing actions like fetch or update.
It also supports withHooks()
for lifecycle logic, which can be used to automatically trigger actions when the store is initialized (e.g. loading data) or destroyed (e.g. clearing entities or resetting state). This helps us to place such logic directly inside the store, without relying on component-based lifecycles.
So in our case and the task board we needed:
-
withState()
to hold loading, pagination, and config
withState(() => {
return inject({ isLoading: false, pageSize: 10, ...});
}),
-
withEntities()
to manage our collection of tasks
withEntities({ entity: type<Task>(), collection: 'task' }),
-
withComputed()
to expose filtered views (e.g. todos vs done)
withComputed(store => {
return {
tasksTodo: computed(() => {
const tasks = store.taskEntities().filter(t => t.status === 'todo');
return tasks;
}),
// .. more views
})
..and these views evaluate automatically whenever taskEntities()
changes.
-
withMethods()
to define actions likefetchTasks
,createTask
,deleteTask
withMethods((store, service = inject(TaskService)) => {
const updateTasks = (tasks: Task[]) => {
patchState(store, setEntities(tasks, { collection: 'task' }));
};
// .. more methods
return { updateTasks, ... }
})
-
withHooks()
to fetch tasks on init and reset the store on destroy
withHooks(store => ({
onInit() {
store.fetchTasks();
},
// .. more hooks
})
Store and service separation still makes sense
Even though Signal Store simplifies how we manage state, it doesn’t remove the need for clear separation of concerns.
In this sample project, I used the following pattern:
- The store holds all reactive state: task entities, pagination state, loading flags, and computed views.
- The service handles data operations: fetching tasks, creating new ones, updating status, deleting.
This split makes it easier to mock data sources, swap backends later, or reuse the service logic elsewhere (e.g. in tests or command-line tools).
Here’s how a store action looks:
async createTask(task: Omit<Task, 'id' | 'createdAt'>) {
try {
const newTask = await firstValueFrom(service.createTask(task));
const currentTasks = store.taskEntities();
updateTasks([...currentTasks, newTask]);
return newTask;
} catch (error) {
throw error;
}
},
In the changeTaskStatus
method, there's also a small but important detail: we optimistically update the task status in the UI before the request completes. If the backend call fails, we revert the task back to its previous state. This pattern gives the user immediate feedback while still keeping the state consistent.
Real-time logging for store actions
One thing I wanted to include from the start was clear logging around all store actions. Each method in the store logs:
- when it starts
[Store - Action]
- when it completes or fails
[Store - Error]
,[Store - Update]
- and when state changes
[Store - Selector]
This makes it easier to trace data flow through the app and understand exactly what happens at each step. It also helps when testing or debugging async flows. Seeing the sequence of actions and updates printed in the console gives us a mental model of how the store behaves — especially useful when you're still getting familiar with how Signal Store works, as shown in the attached screenshot.
Testing the store with Vitest
To make sure everything behaves as expected, I wrote a full unit test suite for the store using Vitest.
Some highlights:
- The store is created in an Angular test injector using runInInjectionContext
- Services are mocked with
vi.fn()
and tested with observable returns - All core methods (fetchTasks, createTask, deleteTask, changeTaskStatus) are covered
- Computed views are tested against sample data
Here’s a condensed look at the test setup:
const mockTasks: Task[] = [/* ... */];
beforeEach(() => {
mockTaskService = {
getTasks: vi.fn(),
createTask: vi.fn(),
deleteTask: vi.fn(),
updateTaskStatus: vi.fn(),
};
injector = TestBed.configureTestingModule({
providers: [
TaskStore,
{ provide: TaskService, useValue: mockTaskService },
{
provide: TASK_BOARD_INITIAL_STATE,
useValue: { isLoading: false, pageSize: 10, pageCount: 1, currentPage: 1 },
},
],
});
store = runInInjectionContext(injector, () => new TaskStore());
});
Tests are grouped into:
- Initial state assertions
- Computed selectors
- Async method behavior (success and failure scenarios)
Example: testing status changes:
it('should change task status successfully', async () => {
mockTaskService.updateTaskStatus = vi.fn().mockReturnValue(of(true));
await store.changeTaskStatus('1', 'in-progress');
expect(store.taskEntities().find(t => t.id === '1')?.status).toBe('in-progress');
});
This kind of testing setup gives us confidence when refactoring or extending the store.
Final thoughts
NgRx Signal Store is a solid choice for Angular apps that want fine-grained reactivity without the boilerplate of classic NgRx.
Keeping the store and service logic separate still applies, even in this more modern setup — and combining signals with good testing gives us both power and predictability.
You can find the full repo below. If you're exploring Signal Store or migrating from legacy state management setups, this example might be a good reference.
dimeloper
/
task-tracker-ngrx
A task tracker that demonstrates NGRX signal store capabilities.
Task Tracker with NgRx Signals
A modern task management application built with Angular and NgRx Signals, demonstrating state management best practices and reactive programming patterns.
Features
- 📋 Task management with three states: Todo, In Progress, and Done
- 🔄 Real-time state updates using NgRx Signals
- 🧪 Comprehensive test coverage with Vitest
- 📊 Detailed logging for debugging and monitoring
Tech Stack
- Angular 19+
- NgRx Signals for state management
- RxJS for reactive programming
- Vitest for testing
- SCSS for styling
- pnpm for package management
Project Structure
src/
├── app/
│ ├── components/ # Reusable UI components
│ ├── interfaces/ # TypeScript interfaces
│ ├── mocks/ # Mock data for development
│ ├── pages/ # Page components
│ ├── services/ # Angular services
│ └── stores/ # NgRx Signal stores
State Management
The application uses NgRx Signals for state management, providing a reactive and efficient way to handle application state. The main store (TaskStore
…
You can find more information and examples in the official NgRx Signal Store documentation.
Top comments (0)