Skip to main content

Angular State Management Patterns

Introduction

State management is a critical aspect of building robust Angular applications. As your application grows in complexity, managing the flow of data between components becomes increasingly challenging. Effective state management helps maintain predictability, improves debugging, and simplifies data sharing across your application.

In this guide, we'll explore different patterns for managing state in Angular applications, starting from simple approaches and progressively moving to more sophisticated solutions. By the end, you'll understand which approach is best suited for your project's needs.

What is Application State?

Before diving into patterns, let's understand what "state" means:

Application state is all the data that your application needs to function. This includes:

  • Data fetched from servers
  • User inputs and selections
  • UI state (loading indicators, open/closed panels)
  • Session information
  • Form states
  • Application settings and configurations

Basic State Management: Component State

The simplest form of state management is handling state within components.

Local Component State

typescript
@Component({
selector: 'app-counter',
template: `
<div>Count: {{ count }}</div>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
`
})
export class CounterComponent {
count = 0;

increment() {
this.count++;
}

decrement() {
this.count--;
}
}

When to use: For isolated components with simple state that doesn't need to be shared.

Limitations: This approach doesn't work well when state needs to be shared across components that aren't directly related.

Parent-Child Communication

For simple data sharing between parent and child components, Angular's @Input() and @Output() decorators are sufficient.

typescript
// Parent component
@Component({
selector: 'app-parent',
template: `
<div>
<h2>Parent Counter: {{ parentCount }}</h2>
<app-child [count]="parentCount" (countChange)="onCountChange($event)"></app-child>
</div>
`
})
export class ParentComponent {
parentCount = 0;

onCountChange(newCount: number) {
this.parentCount = newCount;
}
}

// Child component
@Component({
selector: 'app-child',
template: `
<div>
<h3>Child Counter: {{ count }}</h3>
<button (click)="increment()">Increment</button>
</div>
`
})
export class ChildComponent {
@Input() count = 0;
@Output() countChange = new EventEmitter<number>();

increment() {
this.countChange.emit(this.count + 1);
}
}

When to use: When you have a clear parent-child relationship and limited state sharing needs.

Service-Based State Management

For sharing state between unrelated components, a service-based approach is more appropriate.

Simple Service with Properties

typescript
// counter.service.ts
@Injectable({
providedIn: 'root'
})
export class CounterService {
count = 0;

increment() {
this.count++;
}

decrement() {
this.count--;
}
}

// component1.component.ts
@Component({
selector: 'app-component1',
template: `
<div>
<h2>Component 1</h2>
<p>Count: {{ counterService.count }}</p>
<button (click)="counterService.increment()">Increment</button>
</div>
`
})
export class Component1Component {
constructor(public counterService: CounterService) {}
}

// component2.component.ts
@Component({
selector: 'app-component2',
template: `
<div>
<h2>Component 2</h2>
<p>Count: {{ counterService.count }}</p>
<button (click)="counterService.decrement()">Decrement</button>
</div>
`
})
export class Component2Component {
constructor(public counterService: CounterService) {}
}

When to use: For simple state sharing between components, especially when the state is limited in scope.

Limitations: This approach doesn't provide change detection for the service properties, so components won't automatically update when the state changes.

Observable-Based Service Pattern

A more robust approach is using RxJS observables in services to create a reactive state management system.

typescript
// counter.service.ts
@Injectable({
providedIn: 'root'
})
export class CounterService {
// BehaviorSubject maintains the latest value and emits it to new subscribers
private countSubject = new BehaviorSubject<number>(0);

// Expose the observable, not the subject
count$ = this.countSubject.asObservable();

increment() {
this.countSubject.next(this.countSubject.value + 1);
}

decrement() {
this.countSubject.next(this.countSubject.value - 1);
}
}

// In components
@Component({
selector: 'app-counter-display',
template: `
<div>Current count: {{ count$ | async }}</div>
`
})
export class CounterDisplayComponent implements OnInit {
count$: Observable<number>;

constructor(private counterService: CounterService) {}

ngOnInit() {
this.count$ = this.counterService.count$;
}
}

@Component({
selector: 'app-counter-controls',
template: `
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
`
})
export class CounterControlsComponent {
constructor(private counterService: CounterService) {}

increment() {
this.counterService.increment();
}

decrement() {
this.counterService.decrement();
}
}

Benefits:

  • Components react to state changes automatically
  • Provides a clean separation between state management and UI
  • Can easily implement derived state through RxJS operators

Real-World Example: Shopping Cart Service

Let's see how an observable-based service can manage a shopping cart:

typescript
// product.model.ts
export interface Product {
id: number;
name: string;
price: number;
}

export interface CartItem {
product: Product;
quantity: number;
}

// cart.service.ts
@Injectable({
providedIn: 'root'
})
export class CartService {
private itemsSubject = new BehaviorSubject<CartItem[]>([]);
items$ = this.itemsSubject.asObservable();

// Derived state
totalPrice$ = this.items$.pipe(
map(items => items.reduce((total, item) => total + (item.product.price * item.quantity), 0))
);

totalItems$ = this.items$.pipe(
map(items => items.reduce((count, item) => count + item.quantity, 0))
);

addToCart(product: Product) {
const currentItems = this.itemsSubject.value;
const existingItem = currentItems.find(item => item.product.id === product.id);

if (existingItem) {
// Update quantity of existing item
const updatedItems = currentItems.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
this.itemsSubject.next(updatedItems);
} else {
// Add new item
this.itemsSubject.next([...currentItems, { product, quantity: 1 }]);
}
}

removeFromCart(productId: number) {
const updatedItems = this.itemsSubject.value.filter(item => item.product.id !== productId);
this.itemsSubject.next(updatedItems);
}

updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
this.removeFromCart(productId);
return;
}

const updatedItems = this.itemsSubject.value.map(item =>
item.product.id === productId ? { ...item, quantity } : item
);
this.itemsSubject.next(updatedItems);
}

clearCart() {
this.itemsSubject.next([]);
}
}

Usage in components:

typescript
@Component({
selector: 'app-cart',
template: `
<div class="cart">
<h2>Your Cart</h2>
<p *ngIf="(cartItems$ | async)?.length === 0">Your cart is empty</p>

<div *ngFor="let item of cartItems$ | async" class="cart-item">
<span>{{ item.product.name }}</span>
<input type="number" [value]="item.quantity"
(change)="updateQuantity(item.product.id, $event.target.value)" min="1">
<span>{{ item.product.price * item.quantity | currency }}</span>
<button (click)="removeItem(item.product.id)">Remove</button>
</div>

<div class="cart-summary">
<p>Total Items: {{ totalItems$ | async }}</p>
<p>Total Price: {{ totalPrice$ | async | currency }}</p>
<button (click)="checkout()" [disabled]="(totalItems$ | async) === 0">Checkout</button>
<button (click)="clearCart()" [disabled]="(totalItems$ | async) === 0">Clear Cart</button>
</div>
</div>
`
})
export class CartComponent implements OnInit {
cartItems$: Observable<CartItem[]>;
totalPrice$: Observable<number>;
totalItems$: Observable<number>;

constructor(private cartService: CartService) {}

ngOnInit() {
this.cartItems$ = this.cartService.items$;
this.totalPrice$ = this.cartService.totalPrice$;
this.totalItems$ = this.cartService.totalItems$;
}

updateQuantity(productId: number, quantity: string) {
this.cartService.updateQuantity(productId, parseInt(quantity, 10));
}

removeItem(productId: number) {
this.cartService.removeFromCart(productId);
}

clearCart() {
this.cartService.clearCart();
}

checkout() {
// Implementation for checkout process
console.log('Proceeding to checkout...');
}
}

State Management Libraries: NgRx

For larger applications with complex state requirements, a dedicated state management library like NgRx provides additional benefits. NgRx implements the Redux pattern and offers features like:

  • Centralized, immutable state
  • Actions to describe state changes
  • Reducers to implement state transitions
  • Effects to handle side effects
  • DevTools for time-travel debugging

Basic NgRx Counter Example

First, install NgRx:

bash
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools

Let's implement our counter example with NgRx:

typescript
// counter.actions.ts
import { createAction } from '@ngrx/store';

export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');

// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as CounterActions from './counter.actions';

export interface State {
count: number;
}

export const initialState: State = {
count: 0
};

export const counterReducer = createReducer(
initialState,
on(CounterActions.increment, state => ({ ...state, count: state.count + 1 })),
on(CounterActions.decrement, state => ({ ...state, count: state.count - 1 })),
on(CounterActions.reset, state => ({ ...state, count: 0 }))
);

// app.module.ts
@NgModule({
imports: [
StoreModule.forRoot({ counter: counterReducer }),
// Enable the Redux DevTools
StoreDevtoolsModule.instrument({
maxAge: 25 // Retains the last 25 states
})
]
})
export class AppModule {}

// counter.component.ts
@Component({
selector: 'app-counter',
template: `
<div>
<h2>NgRx Counter</h2>
<p>Count: {{ count$ | async }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent implements OnInit {
count$: Observable<number>;

constructor(private store: Store<{ counter: State }>) {}

ngOnInit() {
this.count$ = this.store.select(state => state.counter.count);
}

increment() {
this.store.dispatch(increment());
}

decrement() {
this.store.dispatch(decrement());
}

reset() {
this.store.dispatch(reset());
}
}

When to Use NgRx

NgRx is most beneficial when:

  1. Your application has a lot of user interactions
  2. Multiple components need to work with the same data
  3. You need to manage complex state transitions
  4. You require time-travel debugging and state inspection
  5. Your team is familiar with Redux patterns or wants to implement strict architectural guidelines

However, NgRx adds complexity and boilerplate, so it's not always the best choice for smaller applications.

Alternative Libraries

Besides NgRx, there are other state management libraries worth considering:

NGXS

NGXS aims to reduce boilerplate and provide a simpler API while still following Redux patterns:

typescript
// counter.state.ts
import { State, Action, StateContext, Selector } from '@ngxs/store';

// Define actions
export class Increment {
static readonly type = '[Counter] Increment';
}

export class Decrement {
static readonly type = '[Counter] Decrement';
}

export class Reset {
static readonly type = '[Counter] Reset';
}

// Define state model
export interface CounterStateModel {
count: number;
}

@State<CounterStateModel>({
name: 'counter',
defaults: {
count: 0
}
})
export class CounterState {
@Selector()
static getCount(state: CounterStateModel) {
return state.count;
}

@Action(Increment)
increment(ctx: StateContext<CounterStateModel>) {
const state = ctx.getState();
ctx.setState({
...state,
count: state.count + 1
});
}

@Action(Decrement)
decrement(ctx: StateContext<CounterStateModel>) {
const state = ctx.getState();
ctx.setState({
...state,
count: state.count - 1
});
}

@Action(Reset)
reset(ctx: StateContext<CounterStateModel>) {
ctx.setState({
count: 0
});
}
}

Akita

Akita is query-based state management that takes a different approach:

typescript
// counter.store.ts
import { Store, StoreConfig } from '@datorama/akita';

export interface CounterState {
count: number;
}

export function createInitialState(): CounterState {
return {
count: 0
};
}

@StoreConfig({ name: 'counter' })
export class CounterStore extends Store<CounterState> {
constructor() {
super(createInitialState());
}
}

// counter.query.ts
import { Query } from '@datorama/akita';
import { CounterState, CounterStore } from './counter.store';

export class CounterQuery extends Query<CounterState> {
constructor(protected store: CounterStore) {
super(store);
}

getCount() {
return this.select(state => state.count);
}
}

// counter.service.ts
@Injectable({ providedIn: 'root' })
export class CounterService {
constructor(private counterStore: CounterStore) {}

increment() {
this.counterStore.update(state => ({
count: state.count + 1
}));
}

decrement() {
this.counterStore.update(state => ({
count: state.count - 1
}));
}

reset() {
this.counterStore.update({ count: 0 });
}
}

Choosing the Right Approach

Here's a decision guide for choosing a state management approach:

  1. Component State: For simple, isolated components with no need for shared state
  2. Service with Properties: For basic state sharing with minimal complexity
  3. Observable Service: For reactive applications with moderate complexity
  4. NgRx/NGXS/Akita: For large applications with complex state requirements

Consider these factors:

  • Application size and complexity
  • Team experience and preferences
  • Need for debugging tools
  • Performance requirements
  • Scalability needs

Summary

Angular provides multiple options for state management, from simple component state to sophisticated libraries:

  1. Component State: Using properties and methods within components
  2. Parent-Child Communication: Using @Input() and @Output() decorators
  3. Service-Based State: Sharing state through services
  4. Observable Data Services: Reactive state management using RxJS
  5. State Management Libraries: Comprehensive solutions like NgRx, NGXS, and Akita

The best approach depends on your application's specific requirements. For beginners, start with simpler approaches and adopt more complex solutions as your application grows and your needs evolve.

Additional Resources

Exercises

  1. Create a simple counter application using the Observable Service pattern.
  2. Enhance your counter app to persist the state in localStorage using an effect or service method.
  3. Implement a todo list application with the ability to add, remove, and mark tasks as complete using NgRx.
  4. Build a shopping cart feature that allows users to add items, adjust quantities, and calculate totals using one of the state management approaches covered.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)