State Management

By Syncfusion®, Inc. · Last updated

Where to store app data: component state, shared state in services, BehaviorSubject and signal-based state, common patterns, and an overview of NgRx store, actions, reducers and selectors.

State Management Fundamentals

State is the data that your app stores at a moment in time. Examples: the items in the cart, the signed-in user, the selected tab and the text in a search box.

State management means deciding where each piece of state is stored and how it is changed. Good state management keeps the screen correct: every component shows the same, current data.

Real-World Example

In an online store, the header badge, the product page and the checkout page all need the cart. The cart is stored in one CartStore service, and every component reads it from there.

Components change the cart only with the service's methods, such as add(). So the badge and the checkout can never show different numbers.

CartStore serviceitems, add(), totalHeader badgereads countProduct pagecalls add()Checkoutreads totalOne place stores the cart. Every component reads it from there.

Shared state lives in one service.

There are two kinds of state:

KindWho uses itExampleWhere to store it
Component stateOne componentIs this panel open?In the component
Shared stateMany componentsCart items, signed-in userIn a service

Component State

Component state is data that only one component needs. Store it in a signal in that component.

Each copy of the component has its own state. In the next example, each FAQ item opens and closes on its own.

Component state: each item has its own open flag

import { Component, Input, signal } from '@angular/core';

@Component({
  selector: 'app-faq-item',
  template: `
    <p>
      <button (click)="open.update(v => !v)">{{ open() ? '−' : '+' }}</button>
      {{ question }}
    </p>
    @if (open()) {
      <p>{{ answer }}</p>
    }
  `,
})
export class FaqItem {
  @Input() question = '';
  @Input() answer = '';
  open = signal(false);
}

@Component({
  selector: 'app-root',
  imports: [FaqItem],
  template: `
    <app-faq-item question="How long is delivery?" answer="3 to 5 working days." />
    <app-faq-item question="Can I return an item?" answer="Yes, within 30 days." />
  `,
})
export class App {}

open is component state. No other component needs to know if this item is open.

Shared State

Shared state is data that many components need. If each component stores its own copy, the copies can show different values.

You learned two ways to share data between components:

  • @Input() and @Output(): good for a parent and its direct child. With many levels, you must pass the data through every level.
  • A service: any component can inject it, at any level. This is the usual way to store shared state.

The next example shows the problem with copies. Header and Sidebar each store their own cartCount. The Add button changes only the copy in Header.

The problem: two copies of shared data

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-header',
  template: `
    <p>Header cart: {{ cartCount() }}
      <button (click)="cartCount.update(n => n + 1)">Add</button></p>
  `,
})
export class Header {
  cartCount = signal(0);
}

@Component({
  selector: 'app-sidebar',
  template: `<p>Sidebar cart: {{ cartCount() }}</p>`,
})
export class Sidebar {
  cartCount = signal(0);
}

@Component({
  selector: 'app-root',
  imports: [Header, Sidebar],
  template: `<app-header /><app-sidebar />`,
})
export class App {}

Click Add. The header shows 1, but the sidebar still shows 0. The section "Signal-Based State" below fixes this with one shared service.

Keep only one copy of each piece of shared data. When the same data is stored in two places, the two copies soon show different values.

Service-Based State

In service-based state, a service stores the shared data, and components read and change it through the service. You did this with the cart service in the Services lesson.

A good state service has three parts:

  • The state: the data, kept private, so only the service can change it.
  • Read access: fields or methods that components read.
  • Methods that change the state, such as add() and clear().

The next two sections show two ways to store the state inside the service: a BehaviorSubject and a signal.

BehaviorSubject State

You learned BehaviorSubject in the RxJS lesson. It stores the current value and sends every new value to its subscribers. So it can store shared state.

  • Keep the BehaviorSubject private.
  • Give components an Observable with asObservable(). Components can read it, but cannot call next().
  • In the template, display it with the async pipe.

State in a BehaviorSubject

import { Component, Injectable, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserStore {
  private readonly user$ = new BehaviorSubject<string>('Guest');
  readonly user = this.user$.asObservable();

  login(name: string) {
    this.user$.next(name);
  }

  logout() {
    this.user$.next('Guest');
  }
}

@Component({
  selector: 'app-header',
  imports: [AsyncPipe],
  template: `<p>Header: Hello, {{ users.user | async }}</p>`,
})
export class Header {
  users = inject(UserStore);
}

@Component({
  selector: 'app-root',
  imports: [AsyncPipe, Header],
  template: `
    <app-header />
    <button (click)="users.login('Asha')">Log in as Asha</button>
    <button (click)="users.logout()">Log out</button>
    <p>Profile page: {{ users.user | async }}</p>
  `,
})
export class App {
  users = inject(UserStore);
}

Click Log in as Asha. The header and the profile page both show the new name, because both read the same BehaviorSubject.

Signal-Based State

You can store the same state in signals. This is the recommended way in new Angular code, because the template reads a signal directly, without the async pipe.

  • Keep the writable signal private.
  • Give components a read-only signal with asReadonly().
  • Use computed() for values that come from the state, such as a count or a total.

State in signals

import { Component, Injectable, computed, inject, signal } from '@angular/core';

export interface CartItem {
  name: string;
  price: number;
}

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();
  readonly count = computed(() => this._items().length);
  readonly total = computed(() => this._items().reduce((sum, i) => sum + i.price, 0));

  add(item: CartItem) {
    this._items.update(list => [...list, item]);
  }

  clear() {
    this._items.set([]);
  }
}

@Component({
  selector: 'app-cart-badge',
  template: `<p>🛒 {{ cart.count() }} items</p>`,
})
export class CartBadge {
  cart = inject(CartStore);
}

@Component({
  selector: 'app-checkout',
  template: `<p>Checkout total: {{ cart.total() }}</p>`,
})
export class Checkout {
  cart = inject(CartStore);
}

@Component({
  selector: 'app-root',
  imports: [CartBadge, Checkout],
  template: `
    <app-cart-badge />
    <button (click)="cart.add({ name: 'Mouse', price: 15 })">Add mouse ($15)</button>
    <button (click)="cart.add({ name: 'Keyboard', price: 40 })">Add keyboard ($40)</button>
    <button (click)="cart.clear()">Clear</button>
    <app-checkout />
  `,
})
export class App {
  cart = inject(CartStore);
}

The badge and the checkout read the same CartStore. Each click changes the private _items signal, and both count and total recalculate.

PointBehaviorSubject stateSignal state
Read in the template{{ store.user | async }}{{ store.user() }}
Read in the classsubscribe()store.user()
Values from the stateRxJS operators such as map()computed()
Best forCode that already uses RxJSNew code

State Management Patterns

A pattern is a common, tested way to organize code. These are the usual patterns for state in Angular apps:

  • Local state: a signal in the component, for data only that component needs.
  • Store service: one service per feature, for example CartStore and UserStore, with private state, read-only access and methods. Most apps need only this pattern.
  • One-way data flow: components never change state directly. They call a method, the store changes the state, and the screen shows the new state.
  • A state library such as NgRx: for large apps with many developers and complex rules. It is explained in the next section.

Start with local state and store services. Choose a library such as NgRx only when many features share state and the team needs strict rules for every change.

NgRx Overview

NgRx is a popular library for state management in large Angular apps. It is not part of Angular, so you install it separately:

ng add @ngrx/store

NgRx uses the one-way data flow pattern with strict rules. It has four main parts.

Componentdispatch(action)ActionaddItemReducerreturns new stateStoreholds the stateSelectorreads a partThe selector gives the new data back to the component, and the screen updates.

NgRx data flow. Data always moves in one direction.

NgRx Store

The store is one object that holds the state of the whole app. Components cannot change it directly. They read it with selectors and request changes with actions.

Actions

An action is an object that describes what happened, for example "an item was added to the cart". You create action types with createAction():

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

export const addItem = createAction('[Cart] Add Item', props<{ name: string }>());
export const clearCart = createAction('[Cart] Clear');

The text '[Cart] Add Item' is the name of the action. props describes the extra data that the action carries.

Reducers

A reducer is a function that gets the current state and an action, and returns the new state. It never changes the old state; it creates a new one, the same as update() with signals.

import { createReducer, on } from '@ngrx/store';

export interface CartState {
  items: string[];
}

const initialState: CartState = { items: [] };

export const cartReducer = createReducer(
  initialState,
  on(addItem, (state, action) => ({ items: [...state.items, action.name] })),
  on(clearCart, () => ({ items: [] })),
);

Selectors

A selector is a function that reads one part of the state, for example the number of items. It works like a computed signal: it recalculates only when its part of the state changes.

import { createFeatureSelector, createSelector } from '@ngrx/store';

export const selectCart = createFeatureSelector<CartState>('cart');
export const selectCount = createSelector(selectCart, cart => cart.items.length);

Basic NgRx Data Flow

The parts work together in one direction:

  1. The user clicks a button, and the component dispatches (sends) an action: store.dispatch(addItem({ name: 'Pen' })).
  2. The reducer gets the action and returns the new state.
  3. The store saves the new state.
  4. A selector gives the new value to the component: store.selectSignal(selectCount) returns it as a signal.
  5. The screen shows the new value.

The practice blocks do not include NgRx. But you can see the same flow with only the Angular you already know: one signal holds the state, a reducer function creates the new state, and computed signals act as selectors.

The NgRx data flow in plain Angular

import { Component, Injectable, computed, inject, signal } from '@angular/core';

// Actions: objects that describe what happened
export type CartAction =
  | { type: 'add', name: string }
  | { type: 'clear' };

export interface CartState {
  items: string[];
}

// Reducer: current state + action → new state
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'add':
      return { items: [...state.items, action.name] };
    case 'clear':
      return { items: [] };
  }
}

@Injectable({ providedIn: 'root' })
export class Store {
  // Store: holds the state
  private readonly state = signal<CartState>({ items: [] });

  // Selectors: read one part of the state
  readonly items = computed(() => this.state().items);
  readonly count = computed(() => this.state().items.length);

  dispatch(action: CartAction) {
    this.state.update(current => cartReducer(current, action));
  }
}

@Component({
  selector: 'app-root',
  template: `
    <button (click)="store.dispatch({ type: 'add', name: 'Pen' })">Add pen</button>
    <button (click)="store.dispatch({ type: 'clear' })">Clear</button>
    <p>Count: {{ store.count() }}</p>
    <p>Items: {{ store.items().join(', ') }}</p>
  `,
})
export class App {
  store = inject(Store);
}

The component only sends actions and reads selectors. It never changes the state itself. Real NgRx works the same way, with more tools for large apps.

export type CartAction = A | B is a TypeScript type alias that lists the allowed action objects. The | means "or": an action is either an add object or a clear object.

Common Mistakes

MistakeWhat you seeFix
Each component stores its own copy of shared dataDifferent screens show different valuesStore shared data in one service
Public writable signal or BehaviorSubject in a storeAny component can change the state, and bugs are hard to findKeep it private; give asReadonly() or asObservable()
Putting component-only data in a shared serviceThe service grows, and other screens are affectedKeep it as component state
Changing state objects directly, for example state.items.push(x)Computed signals and selectors keep old valuesReturn a new object
Adding NgRx to a small appMany extra files for simple changesUse store services; add NgRx when the app needs it

Check Yourself

Exercises

What to Remember

  • Store data that one component needs in that component. Store shared data in one service, keep it private, and change it only with the service's methods.
  • A service can hold state in a BehaviorSubject (read with the async pipe) or in signals (read with ()). Signals are the simpler choice for new code.
  • NgRx uses one-way data flow: components dispatch actions, reducers return new state, the store holds it, and selectors give it back to components.

Next: Client-Side Rendering, how Angular creates the page in the browser.