Telerik blogs

When and why should you use Signals, Services, SignalStore or NgRx to manage state in your Angular app?

This is the guide I wish I had before starting my first Angular project with state management.

Every developer needs to decide where data lives, how it changes and how different parts of the UI stay in sync. Get this wrong, and your app could become a mess of scattered data, broken UIs, and hours of debugging. Get it right, and your code stays clean, predictable and easy to change.

Most articles are outdated. They still push NgRx Store as the default answer or ignore Signals entirely. This guide is different. It covers the modern landscape: Signals, service stores, SignalStore and classic NgRx. It shows when each option makes sense and when to keep things simple.

We use examples from an accounting dashboard to make each concept concrete, so you can see how these patterns work in a real application.

Follow along: All the code for this dashboard is available on GitHub. Clone it and experiment while you read.

What Is State Management in Angular?

State management in practical terms means deciding how your app handles different types of data:

  • UI state – The search text a user types in a list. Only one component needs it.
  • Server state – Data fetched from an API, like a list of bank accounts.
  • Form state – User input before it is submitted.
  • Shared application state – Data many parts of the app need, like the current user or an open financial period.

Why does this matter more as Angular apps grow? More components will mean more async flows, more shared data and more chances for inconsistency. Without clear rules, one component might delete data another component needs, or two components might try to change the same data at once.

Consider concrete examples: authentication state controls what every component shows. Shopping cart state is shared across a product list, a cart view and a checkout form. Selected filters affect both a list and a detail view. Loading and error states need coordination across features. Each of these needs a different level of state management.

Why Angular State Management Feels Different Nowadays

Signals changed things by making reactivity part of Angular itself. Before Signals, you had to pick between BehaviorSubject in a service or use NgRx. Now you have signal(), computed() and effect() as built-in tools.

Modern Angular advice is less about libraries than before. In the past the answer was often “just use NgRx.” Now the community says: start simple and add complexity only when you need it. Most developers on Reddit and forums are practical. They use services with Signals or RxJS for shared state. They use NgRx only for large apps with complex async workflows.

This guide follows the current Angular and NgRx direction. Signals, service stores and SignalStore are the new normal.

The Tiered Approach to State Management

Think of state in three levels: local component state, shared feature state and global application state.

The best approach is to start small. Move to a bigger solution only when you need to share data or coordinate across components. Different parts of the same app can use different approaches.

Local Component State with Signals

When data lives in only one component, use signal(). Angular’s change detection works with Signals by default. Components only update when a signal changes or a new input arrives. This makes signal() a good fit for local state: fast, simple and direct.

In our accounting dashboard, a journal list with a search filter is a good example. The search text belongs to this component alone. The <kendo-textbox> gives us a styled search input with no CSS work.

import { Component, signal, computed } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { KENDO_TEXTBOX } from '@progress/kendo-angular-inputs';

interface JournalEntry {
  id: number;
  date: string;
  description: string;
  total: number;
}

@Component({
  selector: 'app-journal-list',
  standalone: true,
  imports: [CurrencyPipe, KENDO_TEXTBOX],
  template: `
    <kendo-textbox
      [value]="searchTerm()"
      (valueChange)="searchTerm.set($event)"
      placeholder="Search entries..."
      [clearButton]="true"
    />

    <p>Showing {{ filteredCount() }} of {{ entries().length }} entries</p>

    @for (entry of filteredEntries(); track entry.id) {
      <div>
        {{ entry.date }} - {{ entry.description }}: {{ entry.total | currency }}
      </div>
    } @empty {
      <p>No entries found.</p>
    }
  `
})
export class JournalListComponent {
  readonly entries = signal<JournalEntry[]>([
    { id: 1, date: '2026-07-01', description: 'Monthly Subscription Sale', total: 150.00 },
    { id: 2, date: '2026-07-02', description: 'Office Supplies', total: 45.50 },
    { id: 3, date: '2026-07-05', description: 'Consulting Service', total: 1200.00 }
  ]);
  readonly searchTerm = signal('');

  readonly filteredEntries = computed(() =>
    this.entries().filter(e =>
      e.description.toLowerCase().includes(this.searchTerm().toLowerCase())
    )
  );
  readonly filteredCount = computed(() => this.filteredEntries().length);
}

No NgRx imports, no actions, no reducers. Two signal() calls and a computed(). That is it.

Good for: Little code, easy to read, works with Angular change detection, fits local component state.

Not good for: When many unrelated components need the same data. A search filter in one component is fine. A list of accounts that the entry form and the ledger both need is not.

Shared Feature State with Services

When multiple components need the same data, use a service with Signals. This is the next step after local state.

The pattern is simple: the service keeps writable state in private fields. It exposes read-only Signals or Observables to the outside. Components call methods on the service instead of changing state directly. The @Service() decorator makes the service available app-wide and works with inject().

import { Service, computed, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

export interface Account {
  code: string;
  name: string;
  type: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
}

@Service()
export class AccountService {
  private readonly _accountsRes = httpResource<Account[]>(() => '/api/accounts');
  private readonly _localAccounts = signal<Account[]>([
    { code: '101', name: 'Cash', type: 'asset' },
    { code: '102', name: 'Accounts Receivable', type: 'asset' },
    { code: '401', name: 'Sales Revenue', type: 'revenue' },
    { code: '501', name: 'Operating Expenses', type: 'expense' }
  ]);
  private readonly _selectedCode = signal<string | null>(null);

  readonly accounts = computed(() => [
    ...(this._accountsRes.value() ?? []),
    ...this._localAccounts()
  ]);
  readonly isLoading = this._accountsRes.isLoading;
  readonly selectedCode = this._selectedCode.asReadonly();

  readonly selectedAccount = computed(() =>
    this.accounts().find(a => a.code === this._selectedCode())
  );

  readonly revenueAccounts = computed(() =>
    this.accounts().filter(a => a.type === 'revenue')
  );

  selectAccount(code: string) {
    this._selectedCode.set(code);
  }

  addAccount(account: Account) {
    this._localAccounts.update(list => [...list, account]);
  }
}

Any component can inject AccountService and get the same data. The <kendo-dropdownlist> gives us search, keyboard navigation and disabled state with no extra code.

import { Component, inject } from '@angular/core';
import { KENDO_DROPDOWNLIST } from '@progress/kendo-angular-dropdowns';
import { AccountService } from '../account.service';

@Component({
  selector: 'app-journal-entry',
  standalone: true,
  imports: [KENDO_DROPDOWNLIST],
  template: `
    <h3>New Journal Entry</h3>

    <label>Revenue Account:</label>
    <kendo-dropdownlist
      [data]="revenueAccounts()"
      textField="name"
      valueField="code"
      [valuePrimitive]="true"
      [defaultItem]="{ code: '', name: 'Select account...' }"
      (valueChange)="onSelectAccount($event)"
    >
    </kendo-dropdownlist>

    @if (selectedAccount(); as acct) {
      <p>Selected: {{ acct.name }} - {{ acct.type }}</p>
    }
  `
})
export class JournalEntryComponent {
  private readonly accountService = inject(AccountService);

  readonly revenueAccounts = this.accountService.revenueAccounts;
  readonly selectedAccount = this.accountService.selectedAccount;

  onSelectAccount(code: string) {
    if (code) {
      this.accountService.selectAccount(code);
    }
  }
}

Use this when: State is shared by 2 to 10 components in the same feature. Examples: chart of accounts, user settings for a module or filters shared between a list and a detail view.

Move up when: You need strict rules, computed state with complex logic or DevTools for debugging. That is when SignalStore helps.

Signals vs. BehaviorSubject for Service Stores

Which one should you use, signal() or BehaviorSubject?

Concernsignal()BehaviorSubject
Read current valueitems()items$.value
Derived valuescomputed()pipe(map(...))
Template syntax{{ items() }}{{ items$ | async }}
Change detectionAutomaticNeeds OnPush or async pipe
Async compositionNeeds rxMethodswitchMap, combineLatest

Use RxJS when you need stream features like debounce, cancellation or WebSocket events. For most synchronous state, Signals are simpler.

For new code, Signals are the better default. They are simpler, synchronous and work well with Angular’s change detection.

NgRx SignalStore and SignalState

SignalStore gives you structure without the extra code of classic NgRx. It is the recommended state management for Angular apps (NgRx team, v19+).

A simple service works for basic state. But as your feature grows, you want computed values, loading states and a consistent way to update data. Without these, different team members might use different patterns.

SignalStore is between a service and a full Redux store. Think of it as a service with clear rules.

import { signalStore, withState, withComputed, withMethods, patchState, withHooks } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap, tap } from 'rxjs';

interface JournalLine {
  accountCode: string;
  debit: number;
  credit: number;
}

interface JournalEntry {
  id: number;
  date: string;
  description: string;
  lines: JournalLine[];
}

type JournalState = {
  entries: JournalEntry[];
  isLoading: boolean;
};

const initialState: JournalState = {
  entries: [
    {
      id: 1,
      date: '2026-07-01',
      description: 'Initial Opening Balance',
      lines: [
        { accountCode: '101', debit: 5000, credit: 0 },
        { accountCode: '301', debit: 0, credit: 5000 }
      ]
    },
    {
      id: 2,
      date: '2026-07-08',
      description: 'Service Revenue Recognition',
      lines: [
        { accountCode: '102', debit: 1200, credit: 0 },
        { accountCode: '401', debit: 0, credit: 1200 }
      ]
    }
  ],
  isLoading: false
};

function totalDebits(entries: JournalEntry[]): number {
  return entries.reduce((sum, e) =>
    sum + e.lines.reduce((s, l) => s + l.debit, 0), 0);
}

function totalCredits(entries: JournalEntry[]): number {
  return entries.reduce((sum, e) =>
    sum + e.lines.reduce((s, l) => s + l.credit, 0), 0);
}

export const JournalStore = signalStore(
  { providedIn: 'root' },

  withState(initialState),

  withComputed((store) => ({
    entryCount: computed(() => store.entries().length),
    totalDebits: computed(() => totalDebits(store.entries())),
    totalCredits: computed(() => totalCredits(store.entries())),
    isBalanced: computed(() => totalDebits(store.entries()) === totalCredits(store.entries())),
  })),

  withMethods((store, http = inject(HttpClient)) => ({
    loadEntries: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { isLoading: true })),
        switchMap(() =>
          http.get<JournalEntry[]>('/api/journal-entries').pipe(
            tapResponse({
              next: (entries) => patchState(store, { entries, isLoading: false }),
              error: () => patchState(store, { isLoading: false }),
            })
          )
        )
      )
    ),

    addEntry(entry: JournalEntry) {
      patchState(store, { entries: [...store.entries(), entry] });
    },
  })),

  withHooks({
    onInit(store) {
      store.loadEntries();
    },
  })
);

SignalState is the lighter option. It has state and computed values but no methods or hooks. Use it when you want signal-based state in a service without the full store structure.

SignalStore also supports entity management with withEntities() for CRUD operations and the Events Plugin for cross-store communication. These features make SignalStore a good fit for features that need structure but would be too complex with classic NgRx.

Classic NgRx Store

The classic NgRx Store (actions, reducers, selectors, effects) is best for very complex data. Use it when you need a clear history of changes and strict patterns, even if it means more code.

This approach is useful when:

  • Many features share a lot of connected data.
  • Your team needs Redux DevTools for debugging.
  • You need a record of every state change.
  • You have complex async workflows with multiple services.

In the accounting dashboard, managing monthly periods fits here. Opening and closing a month affects entries, the ledger and reports.

createActionGroup and props come from @ngrx/store:

import { createActionGroup, props } from '@ngrx/store';

export const PeriodActions = createActionGroup({
  source: 'Financial Period',
  events: {
    'Open Period': props<{ periodId: number }>(),
    'Close Period': props<{ periodId: number }>(),
    'Period Closed': props<{ periodId: number; closedAt: string }>(),
    'Period Error': props<{ error: string }>(),
  },
});

Effects handle the async workflow using createEffect, Actions and ofType:

import { createEffect, Actions, ofType } from '@ngrx/effects';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

export const closePeriod$ = createEffect(
  (actions$ = inject(Actions), ledgerService = inject(LedgerService)) =>
    actions$.pipe(
      ofType(PeriodActions.closePeriod),
      switchMap(({ periodId }) =>
        ledgerService.closePeriod(periodId).pipe(
          map((closedAt) => PeriodActions.periodClosed({ periodId, closedAt })),
          catchError((err) => of(PeriodActions.periodError({ error: err.message })))
        )
      )
    ),
  { functional: true }
);

This is more code than SignalStore. The benefit is a clear history. Every time a month is closed, it is recorded as an action with a timestamp. Redux DevTools shows exactly what happened.

Use classic NgRx only for complex coordination problems. SignalStore is enough for most features. NgRx gives you predictability, devtools and clear data flow. But it has more code and a harder learning curve. NgRx is powerful, but that’s not always needed.

Other Libraries and Why They Rank Lower in Modern Guidance

Akita and NGXS still appear in older comparison articles. But Akita is now deprecated and should not be used for new projects. NGXS is still maintained but its community has not grown like NgRx, and it has less adoption in the Angular ecosystem.

For most projects today, the choice is between a Signal-based service, SignalStore and classic NgRx Store. These options have the widest community support, more shared knowledge and best alignment with Angular’s future direction.

How to Choose the Right State Strategy

There is no single answer that works for every case. Ask these questions:

QuestionBest Fit
Does this data belong to one component only?Local Signals
Is this data shared across 2-10 components?Service with Signals
Do you want structure and rules but not full Redux?SignalStore
Is this global data used across many features?SignalStore or Classic Store
Do you need Redux DevTools and a clear history?Classic Store
Is your data complex with many relationships?Classic Store + @ngrx/entity

Apply this framework to real examples:

  • Local modal state – A dialog open/close flag. Use a signal(false) in the component.
  • Feature-level product filters – Filters shared between a product list and a detail view. Use a service with Signals.
  • Global auth and entity cache – User session and a cache of business data. Classic NgRx Store gives you the predictability and tooling this level needs.

Different parts of the same app can use different approaches. A dashboard might use local Signals for UI state, a service for feature state and classic NgRx for global coordination. This is not wrong. It is about using the right tool for each job.

State Management in Real Angular Architectures

The best state strategy depends on more than just data scope. The size of your architecture and how your team works together often matter more than library popularity.

  • Enterprise apps – Large teams benefit from the structure of SignalStore or classic NgRx. Consistent patterns make it easier for new team members to learn the codebase.
  • Design systems – Component libraries should not use global state. Use inputs, outputs and local Signals. Let the app that uses the library decide on shared state.
  • Admin dashboards –Many widgets sharing data makes service stores and SignalStore a good fit. Each widget can use local Signals for its own UI state while reading shared data from a store.
  • Micro-frontends and Nx-based architectures – Each micro-app should own its own state. Classic NgRx at the shell level handles cross-app coordination. SignalStore inside each micro-app keeps feature code clean.

Signals, RxJS and NgRx: How They Work Together

You do not have to choose only one. The modern Angular approach uses all three together.

  • Signals for local and view-model state. Use signals in components, computed for derived state and read-only signals from services. This covers most day-to-day state needs.
  • RxJS for async and event streams. HTTP requests, WebSocket connections, debounced input and complex stream transformations are still good use cases for RxJS. Use rxMethod to connect RxJS with SignalStore.
  • NgRx when you need structure or tooling. Classic NgRx Store and SignalStore give you conventions, DevTools and predictable data flow for the features that need them most.

These tools work together. A component can use a Signal for local state, read from a SignalStore for feature data and dispatch classic NgRx actions for global coordination. The choice is practical, not about following rules.

Key Takeaways and Next Steps

  1. Start with the simplest solution that works. Use signal() for local data. Move to a service when you need to share data. Use libraries only when the logic gets complex.
  2. Signals and services are the new default. Most data does not need a complex store.
  3. Use classic NgRx only for complex problems. If you need a clear history of changes or DevTools, the extra code is worth it.
  4. Mix different tools in the same app. Each part of the app can use the approach that fits best.

Modern Angular state management is about choosing what fits, not about following strict rules. The right answer depends on your project size, team and needs.

To learn more, check Angular Signals, the NgRx SignalStore guide, ComponentStore migration, RxJS and architecture guides for large Angular projects.


About the Author

Dany Paredes

Dany Paredes is a Google Developer Expert on Angular and Progress Champion. He loves sharing content and writing articles about Angular, TypeScript and testing on his blog and on Twitter (@danywalls).

Related Posts

Comments

Comments are disabled in preview mode.