Skip to main content

Angular RxJS State

Introduction

State management is a crucial aspect of modern web application development, especially as applications grow in complexity. While Angular offers various options for managing state, including NgRx and Akita, you can implement a lightweight state management solution using just RxJS, which comes built into Angular.

In this guide, we'll learn how to create a clean, predictable state management system using RxJS observables. This approach is often called the "Observable Store" or "RxJS State" pattern and provides a simpler alternative to full-fledged state management libraries when your needs are more moderate.

Fundamentals of RxJS State Management

Core Principles

The RxJS state management approach is based on a few key principles:

  1. Single source of truth: State is stored in one central location
  2. State is read-only: Components can't modify state directly
  3. Changes are made through operations: State is updated through dedicated methods
  4. State is exposed as observables: Components subscribe to state changes

Basic Building Blocks

The fundamental components of an RxJS state management solution include:

  • BehaviorSubject: Stores the current state and emits to subscribers when it changes
  • State interface: Defines the shape of your state
  • Selector methods: Extract specific pieces of state
  • Action methods: Perform operations that update the state

Creating a Basic State Store Service

Let's build a simple state store for a todo application to demonstrate the pattern:

typescript
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

// Define the state interface
export interface Todo {
id: number;
text: string;
completed: boolean;
}

export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}

// Initial state
const initialState: TodoState = {
todos: [],
loading: false,
error: null
};

@Injectable({
providedIn: 'root'
})
export class TodoStore {
// The BehaviorSubject holding the current state
private state = new BehaviorSubject<TodoState>(initialState);

// Expose the current state as an observable (read-only)
private state$ = this.state.asObservable();

// SELECTORS
// Select the entire state
getState(): Observable<TodoState> {
return this.state$;
}

// Select all todos
getTodos(): Observable<Todo[]> {
return this.state$.pipe(
map(state => state.todos)
);
}

// Select loading status
getLoading(): Observable<boolean> {
return this.state$.pipe(
map(state => state.loading)
);
}

// Select error status
getError(): Observable<string | null> {
return this.state$.pipe(
map(state => state.error)
);
}

// ACTIONS
// Add a new todo
addTodo(text: string): void {
const currentState = this.state.getValue();
const newTodo: Todo = {
id: this.generateId(),
text,
completed: false
};

this.state.next({
...currentState,
todos: [...currentState.todos, newTodo]
});
}

// Toggle todo completion status
toggleTodo(id: number): void {
const currentState = this.state.getValue();

this.state.next({
...currentState,
todos: currentState.todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
});
}

// Delete a todo
deleteTodo(id: number): void {
const currentState = this.state.getValue();

this.state.next({
...currentState,
todos: currentState.todos.filter(todo => todo.id !== id)
});
}

// Set loading state
setLoading(loading: boolean): void {
const currentState = this.state.getValue();

this.state.next({
...currentState,
loading
});
}

// Set error state
setError(error: string | null): void {
const currentState = this.state.getValue();

this.state.next({
...currentState,
error
});
}

// Helper method to generate IDs
private generateId(): number {
const currentTodos = this.state.getValue().todos;
return currentTodos.length ? Math.max(...currentTodos.map(t => t.id)) + 1 : 1;
}
}

Using the Store in Components

Now let's see how to use this store in Angular components:

typescript
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Todo, TodoStore } from './todo.store';

@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async" class="loading">Loading...</div>

<div *ngIf="error$ | async as error" class="error">
{{ error }}
</div>

<div class="add-todo">
<input #todoInput placeholder="What needs to be done?" />
<button (click)="addTodo(todoInput.value); todoInput.value = ''">Add</button>
</div>

<ul class="todo-list">
<li *ngFor="let todo of todos$ | async">
<input
type="checkbox"
[checked]="todo.completed"
(click)="toggleTodo(todo.id)"
/>
<span [class.completed]="todo.completed">{{ todo.text }}</span>
<button (click)="deleteTodo(todo.id)">X</button>
</li>
</ul>
`,
styles: [`
.completed { text-decoration: line-through; }
.loading { color: blue; }
.error { color: red; }
`]
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;

constructor(private todoStore: TodoStore) {
this.todos$ = this.todoStore.getTodos();
this.loading$ = this.todoStore.getLoading();
this.error$ = this.todoStore.getError();
}

ngOnInit(): void {
// Load initial data
this.loadTodos();
}

addTodo(text: string): void {
if (text.trim()) {
this.todoStore.addTodo(text);
}
}

toggleTodo(id: number): void {
this.todoStore.toggleTodo(id);
}

deleteTodo(id: number): void {
this.todoStore.deleteTodo(id);
}

private loadTodos(): void {
// In a real app, you would fetch todos from an API
this.todoStore.setLoading(true);

// Simulate API call
setTimeout(() => {
this.todoStore.setLoading(false);

// Add some initial todos
this.todoStore.addTodo('Learn Angular');
this.todoStore.addTodo('Master RxJS');
this.todoStore.addTodo('Build awesome apps');
}, 1000);
}
}

Advanced Pattern: Enhanced State Store Class

For more complex applications, you can create a reusable base store class that handles common operations:

typescript
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';

export class Store<T> {
private state$: BehaviorSubject<T>;

constructor(initialState: T) {
this.state$ = new BehaviorSubject<T>(initialState);
}

/**
* Gets the current state value
*/
protected get(): T {
return this.state$.getValue();
}

/**
* Sets the state to a new value
*/
protected set(nextState: T): void {
this.state$.next(nextState);
}

/**
* Updates parts of the state
*/
protected update(partialState: Partial<T>): void {
this.set({...this.get(), ...partialState});
}

/**
* Creates a selector to select slice of state
*/
protected select<K>(mapFn: (state: T) => K): Observable<K> {
return this.state$.asObservable().pipe(
map(mapFn),
distinctUntilChanged()
);
}
}

We can now extend this base class for our todo store:

typescript
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Store } from './store';

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

interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}

const initialState: TodoState = {
todos: [],
loading: false,
error: null
};

@Injectable({
providedIn: 'root'
})
export class TodoStore extends Store<TodoState> {
constructor() {
super(initialState);
}

// SELECTORS
public todos$: Observable<Todo[]> = this.select(state => state.todos);
public loading$: Observable<boolean> = this.select(state => state.loading);
public error$: Observable<string | null> = this.select(state => state.error);

// ACTIONS
addTodo(text: string): void {
const todo: Todo = {
id: this.generateId(),
text,
completed: false
};

this.update({ todos: [...this.get().todos, todo] });
}

toggleTodo(id: number): void {
this.update({
todos: this.get().todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
});
}

deleteTodo(id: number): void {
this.update({
todos: this.get().todos.filter(todo => todo.id !== id)
});
}

setLoading(loading: boolean): void {
this.update({ loading });
}

setError(error: string | null): void {
this.update({ error });
}

private generateId(): number {
const todos = this.get().todos;
return todos.length ? Math.max(...todos.map(t => t.id)) + 1 : 1;
}
}

Real-World Example: Integrating with HTTP

Let's expand our store to include API integration. This example demonstrates how to handle asynchronous operations:

typescript
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, finalize, tap } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { Store } from './store';

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

interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}

const initialState: TodoState = {
todos: [],
loading: false,
error: null
};

@Injectable({
providedIn: 'root'
})
export class TodoStore extends Store<TodoState> {
private apiUrl = 'https://api.example.com/todos';

constructor(private http: HttpClient) {
super(initialState);
}

// SELECTORS
todos$ = this.select(state => state.todos);
loading$ = this.select(state => state.loading);
error$ = this.select(state => state.error);

// ACTIONS
loadTodos(): void {
this.update({ loading: true, error: null });

this.http.get<Todo[]>(this.apiUrl).pipe(
tap(todos => this.update({ todos })),
catchError(error => {
this.update({ error: 'Failed to load todos' });
return of([]);
}),
finalize(() => this.update({ loading: false }))
).subscribe();
}

addTodo(text: string): void {
const newTodo = { text, completed: false };
this.update({ loading: true, error: null });

this.http.post<Todo>(this.apiUrl, newTodo).pipe(
tap(todo => {
this.update({
todos: [...this.get().todos, todo]
});
}),
catchError(error => {
this.update({ error: 'Failed to add todo' });
return of(null);
}),
finalize(() => this.update({ loading: false }))
).subscribe();
}

toggleTodo(id: number): void {
const todo = this.get().todos.find(t => t.id === id);
if (!todo) return;

const updatedTodo = { ...todo, completed: !todo.completed };
this.update({ loading: true, error: null });

this.http.put<Todo>(`${this.apiUrl}/${id}`, updatedTodo).pipe(
tap(() => {
this.update({
todos: this.get().todos.map(t => t.id === id ? updatedTodo : t)
});
}),
catchError(error => {
this.update({ error: 'Failed to update todo' });
return of(null);
}),
finalize(() => this.update({ loading: false }))
).subscribe();
}

deleteTodo(id: number): void {
this.update({ loading: true, error: null });

this.http.delete(`${this.apiUrl}/${id}`).pipe(
tap(() => {
this.update({
todos: this.get().todos.filter(todo => todo.id !== id)
});
}),
catchError(error => {
this.update({ error: 'Failed to delete todo' });
return of(null);
}),
finalize(() => this.update({ loading: false }))
).subscribe();
}
}

Best Practices for RxJS State Management

  1. Keep state immutable: Always create new objects when updating state
  2. Use the async pipe: Let Angular handle subscription management
  3. Use selectors: Create specific selectors for each piece of state you need
  4. Isolate state logic: Keep state management logic in store services
  5. Document your state shape: Define interfaces for your state
  6. Consider caching: Implement cache strategies for expensive operations
  7. Handle errors: Add proper error handling for all operations
  8. Manage side effects: Use operators like tap, catchError, and finalize for side effects

Benefits and Drawbacks

Benefits

  • Lightweight: No additional libraries required
  • Native to Angular: Uses Angular's built-in dependency injection
  • Flexible: Can be as simple or complex as needed
  • Testable: Easier to unit test compared to complex state libraries
  • Less boilerplate: Compared to Redux-based solutions

Drawbacks

  • Less structured: Fewer enforced conventions than NgRx or other libraries
  • Manual optimization: You need to handle performance optimizations yourself
  • Scaling challenges: Can become harder to manage for very large applications
  • No dev tools: Lacks the debugging tools of dedicated state libraries

Summary

RxJS-based state management provides a flexible approach to handle state in Angular applications without requiring additional libraries. By leveraging observables, we can create a reactive state container that follows good practices like immutability and the single source of truth principle.

This approach works well for small to medium-sized applications or when you want more control over your state management implementation. For larger applications with more complex state requirements, you might want to consider dedicated state management libraries like NgRx.

Additional Resources

Exercises

  1. Extend the TodoStore to include filtering capabilities (all, active, completed)
  2. Add the ability to edit existing todos
  3. Implement pagination for the todo list
  4. Create a user authentication store that manages login state
  5. Build a shopping cart store that handles product selection and checkout

Happy coding!



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