Angular  

Managing State in Angular: A Practical Comparison of NGXS, NGRX, and Signals Store

Introduction

As Angular applications grow in complexity, state management becomes critical to maintaining predictable, maintainable, and scalable code. Over the years, developers have relied on libraries like NGRX and NGXS to handle complex state interactions, but with the arrival of Angular Signals and the Signals Store, a native, lightweight, and reactive alternative has emerged.

In this article, we’ll compare NGRX, NGXS, and Signals Store — their architecture, advantages, and use cases — and implement a practical example showing how the same problem can be solved using all three approaches.

The Evolution of State Management in Angular

ApproachIntroducedCore ConceptBest For
NGRXAngular 4+Redux pattern (actions → reducers → state)Large-scale enterprise apps
NGXSAngular 5+Simplified state handling using decoratorsMedium-sized apps needing less boilerplate
Signals StoreAngular 17+Reactive signals-based storeModern, lightweight applications

1. NGRX – The Redux Way

NGRX uses a unidirectional data flow with three main parts:

  • Actions: events that describe “what happened”.

  • Reducers: pure functions that determine “how state changes”.

  • Selectors: retrieve slices of state efficiently.

Example: Managing a Todo List

Install NGRX

ng add @ngrx/store

Define Actions

// todo.actions.tsimport { createAction, props } from '@ngrx/store';

export const addTodo = createAction('[Todo] Add', props<{ task: string }>());
export const toggleTodo = createAction('[Todo] Toggle', props<{ id: number }>());

Create Reducer

// todo.reducer.tsimport { createReducer, on } from '@ngrx/store';
import { addTodo, toggleTodo } from './todo.actions';

export interface TodoState {
  todos: { id: number; task: string; completed: boolean }[];
}

const initialState: TodoState = { todos: [] };

export const todoReducer = createReducer(
  initialState,
  on(addTodo, (state, { task }) => ({
    ...state,
    todos: [...state.todos, { id: Date.now(), task, completed: false }]
  })),
  on(toggleTodo, (state, { id }) => ({
    ...state,
    todos: state.todos.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    )
  }))
);

Use in Component

@Component({
  selector: 'app-ngrx-todo',
  template: `
    <input [(ngModel)]="task" placeholder="New task" />
    <button (click)="addTask()">Add</button>

    <ul>
      <li *ngFor="let t of todos$ | async" (click)="toggle(t.id)">
        {{ t.task }} - {{ t.completed ? '✅' : '❌' }}
      </li>
    </ul>
  `
})
export class NgrxTodoComponent {
  task = '';
  todos$ = this.store.select(state => state.todos.todos);

  constructor(private store: Store<{ todos: TodoState }>) {}

  addTask() {
    this.store.dispatch(addTodo({ task: this.task }));
    this.task = '';
  }

  toggle(id: number) {
    this.store.dispatch(toggleTodo({ id }));
  }
}

Pros

  • Predictable and testable.

  • Great DevTools for debugging.

  • Suitable for complex apps.

Cons

  • Boilerplate-heavy.

  • Steep learning curve for beginners.

2. NGXS – Simplified State Management

NGXS builds on NGRX concepts but uses decorators to simplify actions and state definition.

Example: Todo Management

Install NGXS

npm install @ngxs/store

Create State

// todo.state.tsimport { State, Action, StateContext, Selector } from '@ngxs/store';

export class AddTodo {
  static readonly type = '[Todo] Add';
  constructor(public task: string) {}
}
export class ToggleTodo {
  static readonly type = '[Todo] Toggle';
  constructor(public id: number) {}
}

export interface Todo {
  id: number;
  task: string;
  completed: boolean;
}

@State<Todo[]>({
  name: 'todos',
  defaults: []
})
export class TodoState {
  @Selector()
  static getTodos(state: Todo[]) {
    return state;
  }

  @Action(AddTodo)
  add({ getState, setState }: StateContext<Todo[]>, { task }: AddTodo) {
    const newTodo: Todo = { id: Date.now(), task, completed: false };
    setState([...getState(), newTodo]);
  }

  @Action(ToggleTodo)
  toggle({ getState, setState }: StateContext<Todo[]>, { id }: ToggleTodo) {
    setState(
      getState().map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }
}

Use in Component

@Component({
  selector: 'app-ngxs-todo',
  template: `
    <input [(ngModel)]="task" placeholder="New task" />
    <button (click)="add()">Add</button>

    <ul>
      <li *ngFor="let t of todos$ | async" (click)="toggle(t.id)">
        {{ t.task }} - {{ t.completed ? '✅' : '❌' }}
      </li>
    </ul>
  `
})
export class NgxsTodoComponent {
  task = '';
  todos$ = this.store.select(TodoState.getTodos);

  constructor(private store: Store) {}

  add() {
    this.store.dispatch(new AddTodo(this.task));
    this.task = '';
  }

  toggle(id: number) {
    this.store.dispatch(new ToggleTodo(id));
  }
}

Pros

  • Less boilerplate.

  • Easy to understand and implement.

  • Strong TypeScript integration.

Cons

  • Smaller ecosystem compared to NGRX.

  • Less granular control in very large apps.

3. Signals Store – The Native Angular Way

Starting with Angular 17, developers can manage app state using Signals Store, built on top of Angular Signals. It’s designed to be lightweight, reactive, and framework-native.

Example: Todo Management Using Signals Store

Install Signals Store (Angular 17+)

npm install @ngrx/signals

Create Store

// todo.store.tsimport { signalStore, withState, withMethods } from '@ngrx/signals';

interface Todo {
  id: number;
  task: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = { todos: [] };

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods(store => ({
    add(task: string) {
      const newTodo: Todo = { id: Date.now(), task, completed: false };
      store.setState({
        todos: [...store.state().todos, newTodo]
      });
    },
    toggle(id: number) {
      store.setState({
        todos: store.state().todos.map(t =>
          t.id === id ? { ...t, completed: !t.completed } : t
        )
      });
    }
  }))
);

Use in Component

@Component({
  selector: 'app-signals-todo',
  template: `
    <input [(ngModel)]="task" placeholder="New task" />
    <button (click)="add()">Add</button>

    <ul>
      <li *ngFor="let t of store.todos()"
          (click)="toggle(t.id)">
        {{ t.task }} - {{ t.completed ? '✅' : '❌' }}
      </li>
    </ul>
  `
})
export class SignalsTodoComponent {
  task = '';
  constructor(public store: TodoStore) {}

  add() {
    this.store.add(this.task);
    this.task = '';
  }

  toggle(id: number) {
    this.store.toggle(id);
  }
}

Pros

  • No external dependencies.

  • Fully reactive with Signals.

  • Very little boilerplate.

  • Ideal for modern Angular apps.

Cons

  • New ecosystem (still evolving).

  • Limited tooling compared to NGRX.

When to Use Which?

ScenarioRecommended Approach
Large enterprise-scale app with complex workflowsNGRX
Medium-sized app needing simplicityNGXS
Modern Angular app (v17+) with SignalsSignals Store
Rapid prototyping or minimal dependenciesSignals Store

Conclusion

Each state management solution has its strengths:

  • NGRX offers scalability and robust tooling.

  • NGXS provides simplicity and readability.

  • Signals Store delivers modern reactivity with minimal overhead.

As Angular continues to evolve, Signals Store is quickly becoming the go-to approach for most new projects, offering a clean, intuitive way to handle state without external libraries.

If you’re starting a new Angular project today, Signals Store is worth your attention—it’s fast, declarative, and feels truly native to Angular’s future.