DEV Community

Cover image for Using NgRx Signal Store for Scalable State Management in Angular
Dimitris Kiriakakis
Dimitris Kiriakakis

Posted on

5 2 2 2 1

Using NgRx Signal Store for Scalable State Management in Angular

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, ...});
  }),
Enter fullscreen mode Exit fullscreen mode
  • withEntities() to manage our collection of tasks
  withEntities({ entity: type<Task>(), collection: 'task' }),
Enter fullscreen mode Exit fullscreen mode
  • 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
  })
Enter fullscreen mode Exit fullscreen mode

..and these views evaluate automatically whenever taskEntities() changes.

  • withMethods() to define actions like fetchTasks, createTask, deleteTask
  withMethods((store, service = inject(TaskService)) => {
    const updateTasks = (tasks: Task[]) => {
      patchState(store, setEntities(tasks, { collection: 'task' }));
    };
    // .. more methods
    return { updateTasks, ... }
  })
Enter fullscreen mode Exit fullscreen mode
  • withHooks() to fetch tasks on init and reset the store on destroy
  withHooks(store => ({
    onInit() {
      store.fetchTasks();
    },
    // .. more hooks
  })
Enter fullscreen mode Exit fullscreen mode

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;
  }
},
Enter fullscreen mode Exit fullscreen mode

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.

Signal Store Logs

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());
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo 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.

Postmark Image

The email service that speaks your language

Whether you code in Ruby, PHP, Python, C#, or Rails, Postmark's robust API libraries make integration a breeze. Plus, bootstrapping your startup? Get 20% off your first three months!

Start free

Top comments (0)

ACI image

ACI.dev: Fully Open-source AI Agent Tool-Use Infra (Composio Alternative)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Check out our GitHub!