Angular NgRx Entity
Introduction
Managing collections of data is a common challenge in Angular applications. When building features that require handling lists of items like users, products, or tasks, developers often need to implement the same CRUD (Create, Read, Update, Delete) operations repeatedly. This is where NgRx Entity comes in.
NgRx Entity is an extension of the NgRx library that provides a set of utility functions to simplify the management of entity collections in your application state. It helps reduce boilerplate code when working with arrays of entities that have unique identifiers.
In this guide, you'll learn how to:
- Set up NgRx Entity in your Angular application
- Create entity adapters and define entity states
- Implement CRUD operations using NgRx Entity
- Use entity selectors to query state efficiently
Prerequisites
Before diving into NgRx Entity, you should have:
- Basic understanding of Angular
- Familiarity with NgRx concepts (Store, Actions, Reducers)
- A working Angular application with NgRx installed
If you're new to NgRx, consider reading the NgRx fundamentals guide first.
Setting Up NgRx Entity
First, you need to install NgRx Entity in your project:
npm install @ngrx/entity
Understanding the Entity Model
NgRx Entity works with collections of objects that have a unique identifier. For example, a User entity might look like this:
export interface User {
id: number;
name: string;
email: string;
}
The key component here is the id
field, which uniquely identifies each user.
Creating an Entity Adapter
The EntityAdapter
is the core concept in NgRx Entity. It provides methods to manipulate and query the entity collection.
Here's how to create an entity adapter for our User entity:
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { User } from '../models/user.model';
// Create the entity adapter
export const userAdapter: EntityAdapter<User> = createEntityAdapter<User>({
// Configure how to select the entity's ID
selectId: (user: User) => user.id,
// Optional: configure how entities should be sorted
sortComparer: (a: User, b: User) => a.name.localeCompare(b.name),
});
Defining Entity State
Once we have an adapter, we need to define our state interface:
// Define the entity state structure
export interface UserState extends EntityState<User> {
// Add any additional state properties
selectedUserId: number | null;
loading: boolean;
error: string | null;
}
// Create the initial state
export const initialUserState: UserState = userAdapter.getInitialState({
selectedUserId: null,
loading: false,
error: null
});
The EntityState<User>
interface provides two properties:
entities
: An object map of entity IDs to entity objectsids
: An array of entity IDs defining the order
Creating Reducers with Entity Adapter
The adapter provides methods like addOne
, addMany
, updateOne
, etc., to manipulate entities in the state:
import { createReducer, on } from '@ngrx/store';
import * as UserActions from '../actions/user.actions';
import { userAdapter, initialUserState } from './user.state';
export const userReducer = createReducer(
initialUserState,
// Load users
on(UserActions.loadUsers, (state) => ({
...state,
loading: true,
error: null
})),
// Load users success
on(UserActions.loadUsersSuccess, (state, { users }) =>
userAdapter.setAll(users, {
...state,
loading: false
})
),
// Add a user
on(UserActions.addUserSuccess, (state, { user }) =>
userAdapter.addOne(user, state)
),
// Update a user
on(UserActions.updateUserSuccess, (state, { user }) =>
userAdapter.updateOne({
id: user.id,
changes: user
}, state)
),
// Remove a user
on(UserActions.deleteUserSuccess, (state, { id }) =>
userAdapter.removeOne(id, state)
),
// Select a user
on(UserActions.selectUser, (state, { id }) => ({
...state,
selectedUserId: id
}))
);
Entity Adapter Methods
NgRx Entity adapters provide several methods for manipulating state:
Method | Description |
---|---|
addOne | Add one entity to the collection |
addMany | Add multiple entities to the collection |
setAll | Replace current collection with provided collection |
setOne | Add or replace one entity in the collection |
removeOne | Remove one entity from the collection |
removeMany | Remove multiple entities from the collection |
updateOne | Update one entity in the collection |
updateMany | Update multiple entities in the collection |
upsertOne | Add or update one entity in the collection |
upsertMany | Add or update multiple entities in the collection |
Creating and Using Entity Selectors
The entity adapter provides built-in selectors through the getSelectors
method:
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { userAdapter, UserState } from './user.state';
// Create feature selector
const selectUserState = createFeatureSelector<UserState>('users');
// Get the selectors
const {
selectIds,
selectEntities,
selectAll,
selectTotal
} = userAdapter.getSelectors(selectUserState);
// Create custom selectors
export const selectAllUsers = selectAll;
export const selectUserEntities = selectEntities;
export const selectUsersTotal = selectTotal;
export const selectSelectedUserId = createSelector(
selectUserState,
(state) => state.selectedUserId
);
export const selectSelectedUser = createSelector(
selectUserEntities,
selectSelectedUserId,
(entities, selectedId) => selectedId ? entities[selectedId] : null
);
export const selectUsersLoading = createSelector(
selectUserState,
(state) => state.loading
);
Real World Example: Managing a User List
Let's put everything together in a complete example. First, we'll define our actions:
// user.actions.ts
import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';
export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
'[User] Load Users Success',
props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
'[User] Load Users Failure',
props<{ error: string }>()
);
export const addUser = createAction(
'[User] Add User',
props<{ user: User }>()
);
export const addUserSuccess = createAction(
'[User] Add User Success',
props<{ user: User }>()
);
export const updateUser = createAction(
'[User] Update User',
props<{ user: User }>()
);
export const updateUserSuccess = createAction(
'[User] Update User Success',
props<{ user: User }>()
);
export const deleteUser = createAction(
'[User] Delete User',
props<{ id: number }>()
);
export const deleteUserSuccess = createAction(
'[User] Delete User Success',
props<{ id: number }>()
);
export const selectUser = createAction(
'[User] Select User',
props<{ id: number }>()
);
Then, implement the effects to handle API calls:
// user.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { UserService } from '../services/user.service';
import * as UserActions from '../actions/user.actions';
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsers),
mergeMap(() =>
this.userService.getUsers().pipe(
map(users => UserActions.loadUsersSuccess({ users })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
)
);
addUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.addUser),
mergeMap(({ user }) =>
this.userService.addUser(user).pipe(
map(savedUser => UserActions.addUserSuccess({ user: savedUser }))
)
)
)
);
updateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.updateUser),
mergeMap(({ user }) =>
this.userService.updateUser(user).pipe(
map(() => UserActions.updateUserSuccess({ user }))
)
)
)
);
deleteUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.deleteUser),
mergeMap(({ id }) =>
this.userService.deleteUser(id).pipe(
map(() => UserActions.deleteUserSuccess({ id }))
)
)
)
);
constructor(
private actions$: Actions,
private userService: UserService
) {}
}
Now, we can use these in a component:
// user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { User } from '../models/user.model';
import * as UserActions from '../actions/user.actions';
import * as fromUsers from '../reducers/user.selectors';
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error" class="error">{{ error }}</div>
<div>
<h2>Users</h2>
<button (click)="addNewUser()">Add New User</button>
<ul>
<li *ngFor="let user of users$ | async"
[class.selected]="user.id === (selectedUserId$ | async)"
(click)="selectUser(user.id)">
{{ user.name }} ({{ user.email }})
<button (click)="updateUser(user)">Edit</button>
<button (click)="deleteUser(user.id)">Delete</button>
</li>
</ul>
</div>
<div *ngIf="selectedUser$ | async as user">
<h3>Selected User Details</h3>
<p>ID: {{ user.id }}</p>
<p>Name: {{ user.name }}</p>
<p>Email: {{ user.email }}</p>
</div>
`
})
export class UserListComponent implements OnInit {
users$: Observable<User[]>;
selectedUser$: Observable<User | null>;
selectedUserId$: Observable<number | null>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
constructor(private store: Store) {
this.users$ = this.store.select(fromUsers.selectAllUsers);
this.selectedUser$ = this.store.select(fromUsers.selectSelectedUser);
this.selectedUserId$ = this.store.select(fromUsers.selectSelectedUserId);
this.loading$ = this.store.select(fromUsers.selectUsersLoading);
this.error$ = this.store.select(state => state.users.error);
}
ngOnInit(): void {
this.store.dispatch(UserActions.loadUsers());
}
selectUser(id: number): void {
this.store.dispatch(UserActions.selectUser({ id }));
}
addNewUser(): void {
const newUser: User = {
id: Date.now(), // temporary ID, will be replaced by backend
name: 'New User',
email: '[email protected]'
};
this.store.dispatch(UserActions.addUser({ user: newUser }));
}
updateUser(user: User): void {
const updatedUser = {
...user,
name: `${user.name} (edited)`
};
this.store.dispatch(UserActions.updateUser({ user: updatedUser }));
}
deleteUser(id: number): void {
this.store.dispatch(UserActions.deleteUser({ id }));
}
}
Advanced Entity Features
Custom ID Field
If your entity doesn't use id
as the primary key, you can specify a different field:
export const taskAdapter = createEntityAdapter<Task>({
selectId: (task: Task) => task.taskId, // Using taskId as the primary key
});
Custom Sort Function
You can define how entities are sorted:
export const productAdapter = createEntityAdapter<Product>({
sortComparer: (a: Product, b: Product) => {
// Sort by price descending
return b.price - a.price;
},
});
Working with Non-Normalized Data
Sometimes your API may return non-normalized data. You can use the @ngrx/entity
adapter methods to normalize it:
// API returns nested data
interface OrderResponse {
id: number;
customer: {
id: number;
name: string;
};
items: Array<{
id: number;
name: string;
price: number;
}>;
}
// In your effect:
loadOrders$ = createEffect(() =>
this.actions$.pipe(
ofType(OrderActions.loadOrders),
mergeMap(() =>
this.orderService.getOrders().pipe(
map(ordersResponse => {
// Extract customers and items into their own collections
const customers = ordersResponse.map(order => order.customer);
const items = ordersResponse.flatMap(order => order.items);
// Normalize the orders
const orders = ordersResponse.map(order => ({
id: order.id,
customerId: order.customer.id,
itemIds: order.items.map(item => item.id)
}));
return OrderActions.loadOrdersSuccess({
orders,
customers,
items
});
})
)
)
)
);
Common Patterns and Best Practices
Use Entity Dictionary for Lookups
When you need to look up entities by ID, use the entities dictionary rather than filtering arrays:
// Good - O(1) lookup
const user = userEntities[userId];
// Avoid - O(n) lookup
const user = users.find(u => u.id === userId);
Batch Updates
For multiple updates, use updateMany
instead of multiple updateOne
calls:
// Good
userAdapter.updateMany([
{ id: 1, changes: { active: false } },
{ id: 2, changes: { active: false } },
{ id: 3, changes: { active: false } }
], state);
// Avoid
let newState = userAdapter.updateOne({ id: 1, changes: { active: false } }, state);
newState = userAdapter.updateOne({ id: 2, changes: { active: false } }, newState);
newState = userAdapter.updateOne({ id: 3, changes: { active: false } }, newState);
Keep Entity State Focused
Store only entity data in the entity state. Keep UI-related state separate:
// Good
interface UserState extends EntityState<User> {
selectedUserId: number | null;
loading: boolean;
}
// Avoid
interface User {
id: number;
name: string;
isSelected: boolean; // UI state doesn't belong in the entity
isExpanded: boolean; // UI state doesn't belong in the entity
}
Summary
NgRx Entity is a powerful extension to NgRx that simplifies the management of collections in your Angular applications. It provides:
- A standardized way to store collections of entities
- Utility functions for CRUD operations
- Performance optimizations through normalized state
- Built-in selectors for common query patterns
By using NgRx Entity, you can significantly reduce the boilerplate code needed to manage collections while ensuring good performance and maintainability.
Additional Resources
Exercises
- Create an entity adapter for a
Product
entity with fieldsid
,name
,price
, andcategory
. - Implement a feature that allows filtering products by category using entity selectors.
- Add functionality to sort products by price (ascending and descending).
- Create a shopping cart feature that uses NgRx Entity to track items in the cart, with the ability to change quantities.
- Extend the user management example to include user roles and permissions.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)