Angular Services State
Introduction
State management is a critical aspect of modern web applications. While there are many specialized libraries for managing state in Angular (like NgRx, NGXS, or Akita), you often don't need these complex solutions for smaller to medium-sized applications. Angular's built-in services offer a lightweight yet powerful way to manage application state.
In this guide, we'll explore how to leverage Angular services as a state management solution, a pattern sometimes called "Service-based State Management" or simply "Services State". This approach utilizes Angular's dependency injection system, RxJS observables, and services to create a maintainable state management system without external libraries.
What is Service-based State Management?
Service-based state management involves creating Angular services that:
- Store application state
- Provide methods to modify that state
- Expose observables for components to subscribe to state changes
- Handle side effects like API calls
This approach is simpler than formal state management libraries but still maintains the core benefits of centralized state management.
Basic Service-based State Management
Let's start with a simple example - a counter service:
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class CounterService {
// Private BehaviorSubject to hold the current count
private count$ = new BehaviorSubject<number>(0);
// Public Observable that components can subscribe to
public currentCount$: Observable<number> = this.count$.asObservable();
constructor() {}
// Methods to update state
increment(): void {
this.count$.next(this.count$.getValue() + 1);
}
decrement(): void {
this.count$.next(this.count$.getValue() - 1);
}
reset(): void {
this.count$.next(0);
}
}
Now, in a component, you can use this service:
import { Component, OnInit } from '@angular/core';
import { CounterService } from './counter.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-counter',
template: `
<div class="counter">
<p>Current Count: {{ count$ | async }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
</div>
`,
})
export class CounterComponent {
// Expose the observable directly to the template
count$: Observable<number>;
constructor(private counterService: CounterService) {
this.count$ = this.counterService.currentCount$;
}
increment(): void {
this.counterService.increment();
}
decrement(): void {
this.counterService.decrement();
}
reset(): void {
this.counterService.reset();
}
}
In this example, the CounterService
maintains the state and exposes methods to modify it. The component simply subscribes to the state and calls the service methods when needed.
Managing Complex State
For more complex applications, we need to manage more complex state. Let's look at a user management service example:
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { map, tap } from 'rxjs/operators';
export interface User {
id: number;
name: string;
email: string;
}
export interface UserState {
users: User[];
selectedUserId: number | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
users: [],
selectedUserId: null,
loading: false,
error: null
};
@Injectable({
providedIn: 'root'
})
export class UserService {
// Private BehaviorSubject to hold the current state
private state$ = new BehaviorSubject<UserState>(initialState);
// Public Observables that components can subscribe to
public users$ = this.state$.pipe(map(state => state.users));
public selectedUser$ = this.state$.pipe(
map(state => state.selectedUserId !== null
? state.users.find(user => user.id === state.selectedUserId) || null
: null
)
);
public loading$ = this.state$.pipe(map(state => state.loading));
public error$ = this.state$.pipe(map(state => state.error));
constructor(private http: HttpClient) {}
// Get current state snapshot
private get state(): UserState {
return this.state$.getValue();
}
// Update state
private setState(newState: Partial<UserState>): void {
this.state$.next({ ...this.state, ...newState });
}
// Load users from API
loadUsers(): void {
this.setState({ loading: true, error: null });
this.http.get<User[]>('https://api.example.com/users')
.pipe(
tap({
next: (users) => {
this.setState({ users, loading: false });
},
error: (err) => {
this.setState({
error: 'Failed to load users: ' + err.message,
loading: false
});
}
})
).subscribe();
}
// Select a user
selectUser(userId: number): void {
this.setState({ selectedUserId: userId });
}
// Add a new user
addUser(user: Omit<User, 'id'>): void {
this.setState({ loading: true, error: null });
this.http.post<User>('https://api.example.com/users', user)
.pipe(
tap({
next: (newUser) => {
const users = [...this.state.users, newUser];
this.setState({ users, loading: false });
},
error: (err) => {
this.setState({
error: 'Failed to add user: ' + err.message,
loading: false
});
}
})
).subscribe();
}
// Update a user
updateUser(id: number, userData: Partial<User>): void {
this.setState({ loading: true, error: null });
this.http.put<User>(`https://api.example.com/users/${id}`, userData)
.pipe(
tap({
next: (updatedUser) => {
const users = this.state.users.map(user =>
user.id === id ? updatedUser : user
);
this.setState({ users, loading: false });
},
error: (err) => {
this.setState({
error: 'Failed to update user: ' + err.message,
loading: false
});
}
})
).subscribe();
}
// Delete a user
deleteUser(id: number): void {
this.setState({ loading: true, error: null });
this.http.delete<void>(`https://api.example.com/users/${id}`)
.pipe(
tap({
next: () => {
const users = this.state.users.filter(user => user.id !== id);
const selectedUserId = this.state.selectedUserId === id
? null
: this.state.selectedUserId;
this.setState({ users, selectedUserId, loading: false });
},
error: (err) => {
this.setState({
error: 'Failed to delete user: ' + err.message,
loading: false
});
}
})
).subscribe();
}
}
This more complex example demonstrates several important patterns:
- Using interfaces to define the shape of your state
- Creating derived state with RxJS operators
- Handling loading and error states
- Handling API interactions and updating the state accordingly
- Keeping state operations encapsulated within the service
Using the Complex State in Components
Now let's see how to use this more complex state in components:
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { UserService, User } from './user.service';
@Component({
selector: 'app-user-list',
template: `
<div class="user-list">
<h2>Users</h2>
<div *ngIf="loading$ | async" class="loading">Loading users...</div>
<div *ngIf="error$ | async as error" class="error">{{ error }}</div>
<ul *ngIf="(users$ | async)?.length">
<li *ngFor="let user of users$ | async"
[class.selected]="user === (selectedUser$ | async)"
(click)="selectUser(user.id)">
{{ user.name }} ({{ user.email }})
<button (click)="deleteUser(user.id); $event.stopPropagation()">Delete</button>
</li>
</ul>
<div *ngIf="(users$ | async)?.length === 0 && !(loading$ | async)">
No users found
</div>
<button (click)="loadUsers()" [disabled]="loading$ | async">
Reload Users
</button>
<!-- User form would be here -->
</div>
`,
})
export class UserListComponent implements OnInit {
users$: Observable<User[]>;
selectedUser$: Observable<User | null>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
constructor(private userService: UserService) {
this.users$ = this.userService.users$;
this.selectedUser$ = this.userService.selectedUser$;
this.loading$ = this.userService.loading$;
this.error$ = this.userService.error$;
}
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.userService.loadUsers();
}
selectUser(id: number): void {
this.userService.selectUser(id);
}
deleteUser(id: number): void {
if (confirm('Are you sure you want to delete this user?')) {
this.userService.deleteUser(id);
}
}
}
Best Practices for Service-based State Management
1. Keep Services Focused
Each service should manage a specific domain of your application:
// Auth state in its own service
@Injectable({ providedIn: 'root' })
export class AuthService {
private userSubject = new BehaviorSubject<User | null>(null);
user$ = this.userSubject.asObservable();
// Auth methods...
}
// Products state in another service
@Injectable({ providedIn: 'root' })
export class ProductService {
private productsSubject = new BehaviorSubject<Product[]>([]);
products$ = this.productsSubject.asObservable();
// Product methods...
}
2. Create Facade Services
For very complex applications, you can create facade services that coordinate between multiple state services:
@Injectable({ providedIn: 'root' })
export class ShopFacade {
constructor(
private productService: ProductService,
private cartService: CartService,
private authService: AuthService
) {}
// Expose combined state
shopState$ = combineLatest([
this.productService.products$,
this.cartService.items$,
this.authService.user$
]).pipe(
map(([products, cartItems, user]) => ({
products,
cartItems,
user,
canCheckout: cartItems.length > 0 && user !== null
}))
);
// Coordinated actions
addToCart(productId: number): void {
const product = this.productService.getProductById(productId);
if (product) {
this.cartService.addItem(product);
}
}
checkout(): void {
const user = this.authService.getCurrentUser();
const cartItems = this.cartService.getAllItems();
if (user && cartItems.length) {
// Process checkout...
this.cartService.clearCart();
}
}
}
3. Use Immutable State Updates
Always create new state objects rather than mutating existing ones:
// Good - creates new array
addItem(item: CartItem): void {
const currentItems = this.cartItems$.getValue();
this.cartItems$.next([...currentItems, item]);
}
// Bad - mutates existing array
addItem(item: CartItem): void {
const currentItems = this.cartItems$.getValue();
currentItems.push(item); // Mutation!
this.cartItems$.next(currentItems);
}
4. Optimize with distinctUntilChanged
Use distinctUntilChanged
to prevent unnecessary emissions when the relevant part of the state hasn't changed:
// Only emit when the count value actually changes
count$ = this.state$.pipe(
map(state => state.count),
distinctUntilChanged()
);
When to Use Service-based State vs. Other Solutions
Service-based state management is ideal for:
- Small to medium-sized applications
- Applications with moderate complexity
- Teams new to Angular or state management
- Prototypes and MVPs
Consider more robust state management libraries when:
- Your application has highly complex state
- You need strict state immutability guarantees
- You need powerful dev tools for debugging
- You have a large team that benefits from more explicit patterns
Real-World Example: Shopping Cart
Let's build a complete shopping cart example:
// Models
export interface Product {
id: number;
name: string;
price: number;
imageUrl: string;
}
export interface CartItem {
product: Product;
quantity: number;
}
// Cart Service
@Injectable({
providedIn: 'root'
})
export class CartService {
private itemsSubject = new BehaviorSubject<CartItem[]>([]);
public items$ = this.itemsSubject.asObservable();
// Derived state
public totalItems$ = this.items$.pipe(
map(items => items.reduce((total, item) => total + item.quantity, 0))
);
public totalPrice$ = this.items$.pipe(
map(items => items.reduce(
(total, item) => total + (item.product.price * item.quantity),
0
))
);
addToCart(product: Product): void {
const currentItems = this.itemsSubject.getValue();
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 }]);
}
}
removeItem(productId: number): void {
const currentItems = this.itemsSubject.getValue();
const updatedItems = currentItems.filter(item => item.product.id !== productId);
this.itemsSubject.next(updatedItems);
}
updateQuantity(productId: number, quantity: number): void {
if (quantity <= 0) {
this.removeItem(productId);
return;
}
const currentItems = this.itemsSubject.getValue();
const updatedItems = currentItems.map(item =>
item.product.id === productId
? { ...item, quantity }
: item
);
this.itemsSubject.next(updatedItems);
}
clearCart(): void {
this.itemsSubject.next([]);
}
}
Usage in a cart component:
@Component({
selector: 'app-shopping-cart',
template: `
<div class="shopping-cart">
<h2>Your Cart</h2>
<div *ngIf="(totalItems$ | async) === 0" class="empty-cart">
Your cart is empty
</div>
<ul *ngIf="(totalItems$ | async) > 0">
<li *ngFor="let item of items$ | async">
<img [src]="item.product.imageUrl" [alt]="item.product.name">
<div>
<h3>{{ item.product.name }}</h3>
<p>{{ item.product.price | currency }}</p>
<div class="quantity">
<button (click)="decreaseQuantity(item)">-</button>
<span>{{ item.quantity }}</span>
<button (click)="increaseQuantity(item)">+</button>
</div>
<button (click)="removeItem(item.product.id)">Remove</button>
</div>
<div class="item-total">
{{ item.product.price * item.quantity | currency }}
</div>
</li>
</ul>
<div *ngIf="(totalItems$ | async) > 0" class="cart-summary">
<p>Total Items: {{ totalItems$ | async }}</p>
<p>Total Price: {{ totalPrice$ | async | currency }}</p>
<button (click)="checkout()">Checkout</button>
<button (click)="clearCart()">Clear Cart</button>
</div>
</div>
`
})
export class ShoppingCartComponent {
items$ = this.cartService.items$;
totalItems$ = this.cartService.totalItems$;
totalPrice$ = this.cartService.totalPrice$;
constructor(private cartService: CartService) {}
increaseQuantity(item: CartItem): void {
this.cartService.updateQuantity(item.product.id, item.quantity + 1);
}
decreaseQuantity(item: CartItem): void {
if (item.quantity > 1) {
this.cartService.updateQuantity(item.product.id, item.quantity - 1);
} else {
this.removeItem(item.product.id);
}
}
removeItem(productId: number): void {
this.cartService.removeItem(productId);
}
clearCart(): void {
this.cartService.clearCart();
}
checkout(): void {
// In a real app, this would navigate to checkout
alert('Proceeding to checkout!');
}
}
Summary
Service-based state management in Angular is a powerful pattern that leverages the built-in dependency injection system along with RxJS to create a robust state management solution without external libraries. It follows these key principles:
- Services act as state containers
- BehaviorSubjects hold the state privately
- Observables expose state to components
- Methods provide ways to update the state
- Components remain focused on presentation
This approach is ideal for small to medium-sized applications where you want maintainable state management without the overhead of dedicated state management libraries. As your application grows in complexity, you can evolve this pattern by adding facades or eventually migrating to a more formal state management solution.
Additional Resources
- RxJS Documentation - Learn more about reactive programming
- Angular Services Guide - Official Angular documentation on services
- Thinking Reactively in Angular - Deep dive into reactive patterns in Angular
Exercises
- Create a simple theme toggle service that manages dark/light mode state
- Build a notification service that manages a queue of notifications with add and remove functionality
- Implement a user preferences service that stores user settings in localStorage
- Create a shopping cart service that persists the cart state in sessionStorage
- Build a multi-step form wizard that manages form state across multiple components
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)