State Management in Angular: NgRx vs. BehaviorSubject

Compare NgRx and BehaviorSubject for state management in Angular applications. Learn when to use each approach based on the complexity and needs of your app

In modern Angular applications, managing state efficiently is crucial for building scalable, maintainable, and high-performance apps. State management helps developers keep track of data across components, manage user interactions, and maintain a predictable flow of information throughout the application.

When it comes to state management in Angular, two popular approaches dominate the landscape: NgRx, a powerful state management library inspired by Redux, and BehaviorSubject, an observable from RxJS that can be used for state management in a more lightweight, simpler way. Both have their strengths and are suitable for different use cases, but how do you decide which one is the best fit for your project?

In this article, we’ll dive into a detailed comparison between NgRx and BehaviorSubject, exploring how each handles state management, when to use them, and how they fit into large-scale Angular applications.

Understanding State Management in Angular

Before diving into the comparison between NgRx and BehaviorSubject, it’s important to understand why state management is essential in Angular applications, especially as they grow in complexity.

State refers to the data that represents the current status of your application. This data can include:

UI state: Such as whether a modal is open or a user is authenticated.

Application state: The core data your app operates on, such as products in a shopping cart or user profiles.

Server state: Data fetched from a backend API that needs to be displayed or interacted with.

Without a structured approach to managing state, your app can quickly become difficult to maintain, with components relying on multiple services, repeated API calls, and inconsistent data flows. Proper state management centralizes the handling of this data, ensuring that state is consistent across components and interactions are predictable.

Why Not Just Use Services?

In Angular, services are a natural way to share data between components. A service with an observable like BehaviorSubject can be used to store and manage state. While this approach works well in simpler applications, it doesn’t scale as effectively for more complex use cases where multiple components need to access, modify, and track changes to the state.

This is where dedicated state management solutions like NgRx or more structured usage of RxJS observables come into play.

What is NgRx?

NgRx is a state management library for Angular that implements the Redux pattern, providing a robust, scalable solution for handling global state. NgRx centralizes the state into a store, which acts as the single source of truth for the entire application. Actions are dispatched to update the state, and reducers specify how the state should change based on those actions.

NgRx is particularly useful for complex applications where you need to handle:

Global state: Data shared across multiple components, such as authentication information or user preferences.

Side effects: Asynchronous operations like API calls, using NgRx Effects to handle these interactions predictably.

Predictability: NgRx enforces a strict flow of data, making it easier to reason about changes and debug the application.

Key Concepts of NgRx

NgRx revolves around a few key concepts:

Store: The central place where the global state is stored. The store is immutable, meaning the state cannot be changed directly; it can only be updated by dispatching actions.

Actions: These are payloads of information that describe what happened in the application. For example, an action might be dispatched when a user logs in or adds an item to the cart.

Reducers: Reducers specify how the state should change based on the dispatched actions. They are pure functions that take the current state and the action, and return a new state.

Effects: NgRx effects are used to handle side effects, such as making API calls, without directly changing the state. They allow you to dispatch additional actions when async tasks are completed.

NgRx effects are used to handle side effects, such as making API calls, without directly changing the state.

Example: Setting Up NgRx in Angular

Let’s look at a basic example of how NgRx works in an Angular application.

Install NgRx:

ng add @ngrx/store @ngrx/effects

Define Actions:

// actions/cart.actions.ts
import { createAction, props } from '@ngrx/store';

export const addItem = createAction('[Cart] Add Item', props<{ item: any }>());
export const removeItem = createAction('[Cart] Remove Item', props<{ itemId: string }>());

Create a Reducer:

// reducers/cart.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { addItem, removeItem } from '../actions/cart.actions';

export const initialState: any[] = [];

const _cartReducer = createReducer(
initialState,
on(addItem, (state, { item }) => [...state, item]),
on(removeItem, (state, { itemId }) => state.filter(item => item.id !== itemId))
);

export function cartReducer(state: any, action: any) {
return _cartReducer(state, action);
}

Register the Reducer in the Store:

// app.module.ts
import { StoreModule } from '@ngrx/store';
import { cartReducer } from './reducers/cart.reducer';

@NgModule({
declarations: [...],
imports: [
StoreModule.forRoot({ cart: cartReducer })
],
bootstrap: [AppComponent]
})
export class AppModule {}

Dispatch Actions and Select State in Components:

// cart.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { addItem } from './actions/cart.actions';

@Component({
selector: 'app-cart',
templateUrl: './cart.component.html'
})
export class CartComponent {
cart$ = this.store.select(state => state.cart);

constructor(private store: Store) {}

addItemToCart(item: any) {
this.store.dispatch(addItem({ item }));
}
}

NgRx enforces a clear and predictable flow of state changes, making it easier to handle complex, global state management in large-scale applications.

Benefits of NgRx

Predictability: The Redux pattern used by NgRx ensures that state updates are predictable and easy to follow.

Debugging Tools: NgRx has robust dev tools that allow you to inspect the state, trace actions, and time-travel through previous states.

Modularity: NgRx is highly modular, making it easy to scale the application by adding new state slices without cluttering the existing codebase.

Effects for Async Logic: NgRx Effects provide a clean way to handle asynchronous tasks like API calls, ensuring that side effects are handled outside of the components.

Downsides of NgRx

Boilerplate Code: One of the main criticisms of NgRx is that it requires a lot of boilerplate code—actions, reducers, effects—which can be overkill for smaller applications.

Steeper Learning Curve: For developers unfamiliar with Redux patterns, NgRx can take time to understand and implement properly, especially in simpler use cases.

What is BehaviorSubject?

BehaviorSubject is an RxJS observable that can be used as a state management solution within Angular. It is part of the Subject class in RxJS, which allows multiple components to subscribe to a single stream of data. A BehaviorSubject not only emits new values to subscribers but also retains the most recent value, which can be accessed immediately upon subscription.

Unlike NgRx, BehaviorSubject doesn’t come with a full-fledged state management system but offers a simpler, more lightweight approach. It’s often used in smaller applications where you need to manage local or shared state without the overhead of a full state management library.

Key Features of BehaviorSubject

Initial Value: A BehaviorSubject requires an initial value, and it emits this value to subscribers until a new value is emitted.

Multiple Subscribers: Components can subscribe to the BehaviorSubject to reactively receive updates whenever the state changes.

State Updates: Components can update the state by calling next() on the BehaviorSubject, which pushes new data to all subscribers.

Example: Using BehaviorSubject for State Management

Here’s how you can use BehaviorSubject to manage state in Angular:

  1. Create a Service with BehaviorSubject:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class CartService {
private cartSubject = new BehaviorSubject<any[]>([]);
cart$ = this.cartSubject.asObservable();

addItem(item: any) {
const currentCart = this.cartSubject.value;
this.cartSubject.next([...currentCart, item]);
}

removeItem(itemId: string) {
const updatedCart = this.cartSubject.value.filter(item => item.id !== itemId);
this.cartSubject.next(updatedCart);
}
}
  1. Use the Service in Components:
// cart.component.ts
import { Component } from '@angular/core';
import { CartService } from './cart.service';

@Component({
selector: 'app-cart',
templateUrl: './cart.component.html'
})
export class CartComponent {
cart$ = this.cartService.cart$;

constructor(private cartService: CartService) {}

addItem(item: any) {
this.cartService.addItem(item);
}

removeItem(itemId: string) {
this.cartService.removeItem(itemId);
}
}

This setup provides a simple way to manage shared state across components using RxJS observables without the complexity of a dedicated state management library like NgRx.

Benefits of BehaviorSubject

Lightweight: BehaviorSubject doesn’t require a lot of setup or boilerplate code, making it ideal for smaller applications or less complex state management needs.

Reactive: As part of RxJS, BehaviorSubject integrates seamlessly with Angular’s reactive programming model, allowing you to manage state reactively across components.

Simplicity: BehaviorSubject is easy to understand and implement, especially for developers already familiar with RxJS.

Downsides of BehaviorSubject

Manual State Management: While BehaviorSubject provides a way to share state, it lacks the structured state management features of NgRx, such as reducers and effects. You’ll need to manage state updates and logic manually.

Not Scalable: For larger applications with complex state management requirements, BehaviorSubject can quickly become unwieldy, as it doesn’t provide the same tools for handling side effects or modular state updates as NgRx.

No DevTools: Unlike NgRx, which has built-in dev tools for debugging state changes, BehaviorSubject doesn’t provide out-of-the-box debugging support, making it harder to trace state changes over time.

NgRx vs. BehaviorSubject: A Detailed Comparison

Now that we’ve looked at the features of both NgRx and BehaviorSubject, let’s break down the key differences between them and when you should choose one over the other.

FeatureNgRxBehaviorSubject
ComplexityHigh—requires setup of actions, reducers, effectsLow—can be implemented with simple RxJS observables
BoilerplateRequires a lot of boilerplateMinimal setup
PredictabilityPredictable, centralized state managementLess predictable—state changes are manual
Side EffectsHandled with NgRx EffectsRequires custom implementation with RxJS
DevToolsPowerful debugging tools with time travelNo built-in dev tools
ScalabilityHighly scalable for large applicationsSuitable for small to medium applications
Learning CurveSteeper learning curveEasier to learn, especially with RxJS knowledge

When to Use NgRx

Large-scale applications where multiple components need to interact with global state.

Applications with complex state logic, requiring structured state management.

Projects where predictability and debugging are important—NgRx’s dev tools make it easier to debug complex applications.

Handling side effects like API calls, where NgRx Effects provide a clean solution.

Smaller applications or medium-sized apps with less complex state management needs.

When to Use BehaviorSubject

Smaller applications or medium-sized apps with less complex state management needs.

When you want a lightweight solution that doesn’t involve a lot of boilerplate.

Applications that don’t require structured side effect management, where a simple observable-based state solution suffices.

Prototyping or early-stage projects where you don’t want to invest in a full state management solution until the application’s requirements become clearer.

Advanced Considerations for NgRx and BehaviorSubject

While both NgRx and BehaviorSubject offer effective ways to manage state in Angular, there are some advanced considerations you should keep in mind when deciding which one is best for your project. As your application scales or evolves, you may need to revisit your choice or even combine both approaches. Let’s explore some advanced strategies and considerations for using NgRx or BehaviorSubject, and even how you can leverage them together for optimal state management.

Combining NgRx and BehaviorSubject in Large Applications

In many large-scale applications, you might find yourself needing both the structured state management provided by NgRx and the simplicity of BehaviorSubject for more localized or component-specific state management.

Example: Using BehaviorSubject for Local State with NgRx for Global State

In some scenarios, you may want to use NgRx for global state management while using BehaviorSubject for more isolated, local state needs that don’t require the overhead of NgRx.

For instance, you could use NgRx to handle global application-wide state, like authentication, user roles, or product catalogs, while using BehaviorSubject to manage temporary UI states within a specific component.

// auth.actions.ts (NgRx actions for global state)
import { createAction } from '@ngrx/store';

export const loginSuccess = createAction('[Auth] Login Success');
export const logout = createAction('[Auth] Logout');

// global-state.component.ts (Using NgRx for global state)
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { loginSuccess, logout } from './auth.actions';

@Component({
selector: 'app-global-state',
template: `<button (click)="login()">Login</button>`
})
export class GlobalStateComponent {
constructor(private store: Store) {}

login() {
// Dispatch an action to update global state
this.store.dispatch(loginSuccess());
}

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

In the same application, you could use BehaviorSubject to manage a piece of local state, such as the visibility of a modal or dropdown in a form component, without needing to add unnecessary complexity to the NgRx store.

// ui.service.ts (BehaviorSubject for local UI state)
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class UiService {
private modalVisibleSubject = new BehaviorSubject<boolean>(false);
modalVisible$ = this.modalVisibleSubject.asObservable();

toggleModal() {
this.modalVisibleSubject.next(!this.modalVisibleSubject.value);
}
}

// local-state.component.ts (Using BehaviorSubject for local state)
import { Component } from '@angular/core';
import { UiService } from './ui.service';

@Component({
selector: 'app-local-state',
template: `
<button (click)="toggleModal()">Toggle Modal</button>
<div *ngIf="modalVisible$ | async">Modal is open</div>
`
})
export class LocalStateComponent {
modalVisible$ = this.uiService.modalVisible$;

constructor(private uiService: UiService) {}

toggleModal() {
this.uiService.toggleModal();
}
}

In this setup, you’re using NgRx for handling global state (like authentication) and BehaviorSubject for managing local, UI-specific states (like modals or dropdowns). This hybrid approach allows you to keep your application efficient and avoid unnecessary overhead where it’s not needed.

Optimizing Performance in Large Applications

One common concern with state management in Angular—especially when using NgRx—is performance. In large applications, if not managed properly, unnecessary re-renders can occur, leading to performance bottlenecks. Optimizing state management and the way components interact with the store is key to maintaining performance in large-scale projects.

Memoization with NgRx Selectors

When using NgRx, it’s important to optimize how components select and consume state. Without optimization, components might re-render unnecessarily whenever any part of the store changes, even if the data they rely on hasn’t changed. This is where memoized selectors come into play.

NgRx selectors can be memoized, meaning they cache the result of the selector and only recalculate the output if the relevant part of the state changes. This prevents unnecessary recalculations and improves the performance of your application.

// selectors/cart.selectors.ts
import { createSelector } from '@ngrx/store';

export const selectCartState = (state: any) => state.cart;

export const selectTotalItems = createSelector(
selectCartState,
(cart) => cart.items.length
);

In this example, the selectTotalItems selector will only recompute if the cart state changes, preventing unnecessary re-renders in components that rely on it.

Unsubscribing from Observables in BehaviorSubject

When using BehaviorSubject for state management, particularly in components that are destroyed and recreated often (like modals or child components), it’s important to unsubscribe from observables to prevent memory leaks and ensure optimal performance.

You can handle this in Angular using the takeUntil operator with a Subject to complete the observable when the component is destroyed.

// local-state.component.ts
import { Component, OnDestroy } from '@angular/core';
import { UiService } from './ui.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
selector: 'app-local-state',
template: `
<button (click)="toggleModal()">Toggle Modal</button>
<div *ngIf="modalVisible$ | async">Modal is open</div>
`
})
export class LocalStateComponent implements OnDestroy {
modalVisible$ = this.uiService.modalVisible$;
private unsubscribe$ = new Subject<void>();

constructor(private uiService: UiService) {
this.modalVisible$
.pipe(takeUntil(this.unsubscribe$))
.subscribe();
}

toggleModal() {
this.uiService.toggleModal();
}

ngOnDestroy() {
this.unsubscribe$.next();
this.unsubscribe$.complete();
}
}

By using takeUntil, you ensure that the subscription to the BehaviorSubject is properly cleaned up when the component is destroyed, preventing memory leaks and ensuring better performance.

Managing Side Effects in Complex Applications

Handling side effects (such as API calls, authentication, or navigation) is another critical aspect of state management in Angular. In large applications, managing side effects manually within components can become unwieldy, leading to messy code and hard-to-debug issues.

Using NgRx Effects for Side Effects

NgRx provides Effects, a powerful way to handle asynchronous tasks such as API calls or interacting with external systems. Effects are isolated from the components and reducers, ensuring that the business logic related to side effects is centralized and doesn’t clutter your components or reducers.

// effects/cart.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { CartService } from './cart.service';
import { loadCart, loadCartSuccess, loadCartFailure } from './cart.actions';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { of } from 'rxjs';

@Injectable()
export class CartEffects {
loadCart$ = createEffect(() =>
this.actions$.pipe(
ofType(loadCart),
mergeMap(() =>
this.cartService.getCart().pipe(
map(cart => loadCartSuccess({ cart })),
catchError(error => of(loadCartFailure({ error })))
)
)
)
);

constructor(private actions$: Actions, private cartService: CartService) {}
}

This effect listens for the loadCart action, triggers the getCart API call, and then dispatches either a success or failure action based on the result. Using effects keeps your components clean and focuses on the side effect logic separately, improving code maintainability.

In large-scale applications, testing becomes increasingly important, particularly when using NgRx or BehaviorSubject

Testing Your State Management

In large-scale applications, testing becomes increasingly important, particularly when using NgRx or BehaviorSubject to manage complex state. Both approaches offer ways to test state management to ensure the application works as expected and is easy to debug.

Testing NgRx State

NgRx provides tools for testing reducers, actions, and effects. Since reducers are pure functions, they’re easy to test by passing different states and actions to them and asserting the expected output.

// cart.reducer.spec.ts
import { cartReducer } from './cart.reducer';
import { addItem } from './cart.actions';

describe('Cart Reducer', () => {
it('should add an item to the cart', () => {
const initialState = { items: [] };
const action = addItem({ item: { id: 1, name: 'Product' } });
const state = cartReducer(initialState, action);

expect(state.items.length).toBe(1);
});
});

Similarly, you can test NgRx Effects by mocking API calls and ensuring that the correct actions are dispatched based on the outcome of the API call.

Testing BehaviorSubject State

For BehaviorSubject, testing usually involves asserting the values emitted by the observable over time. You can use marble testing or simply subscribe to the BehaviorSubject and test the emitted values.

// ui.service.spec.ts
import { UiService } from './ui.service';

describe('UiService', () => {
let service: UiService;

beforeEach(() => {
service = new UiService();
});

it('should toggle modal visibility', () => {
let modalVisible = false;

service.modalVisible$.subscribe(value => {
modalVisible = value;
});

expect(modalVisible).toBe(false); // Initial value
service.toggleModal();
expect(modalVisible).toBe(true); // After toggling
});
});

By testing your state management logic, you can ensure that your application behaves as expected and catch potential issues early in the development process.

Conclusion: Choosing the Right Tool for State Management in Angular

Choosing between NgRx and BehaviorSubject comes down to the complexity and scale of your Angular application. NgRx offers a powerful, structured approach to state management, with robust features for handling side effects, modular state, and debugging tools that make it perfect for large applications. On the other hand, BehaviorSubject provides a more lightweight and flexible solution, ideal for smaller projects or applications where full-fledged state management might be overkill.

At PixelFree Studio, we help developers and teams navigate the intricacies of state management in Angular, providing solutions tailored to your project’s size and needs. Whether you’re building a simple app or scaling a complex enterprise-level solution, we’re here to help you choose and implement the right state management strategy to ensure your app runs smoothly. Contact us today to learn more about how we can assist with your Angular development!

Read Next: