Practical NgRx Patterns: Managing Collections of Records
Original cover photo by Glenn Carstens-Peters on Unsplash.
In my prior write-up, I demonstrated how to manage access control with @ngrx/store and @ngrx/effects. This time around, the focus shifts to handling collections of data. The scenarios explored below include:
- Fetching a data collection from a backend
- Inserting a new entry into the collection
- Modifying a specific entry within the collection
- Removing an entry from the collection
- Fetching a single entry for display purposes
While each of these operations could be handled with a custom set of actions, reducers, and effects, the @ngrx/entity library streamlines the whole process considerably.
Getting Started with @ngrx/entity
The @ngrx/entity package simplifies working with collections in your store. It exposes helper utilities that handle entity collections, including operations for adding, removing, updating, selecting, sorting, and filtering entities, among others.
The Core Idea
Let's use a product catalog as our example. Users can browse a list of products, view details for a single product, and add items to a cart. A product might look like this:
export interface Product {
id: number;
name: string;
price: number;
description: string;
}
However, there's a twist. The list response from the API contains only partial data for each item — the id, name, and price. Fetching full details requires a second request for every individual product. This leads to two distinct interfaces for a product. One approach is to define them like so:
export interface Product {
id: number;
name: string;
price: number;
image: string;
}
export interface ProductDetails extends Product {
description: string;
tags: string[];
}
This design has a notable issue: it would force us to store the currently viewed product separately in the store, which we want to avoid. Instead, our goal is to keep only the product list in the state and pick a specific item using a selector when needed. We can achieve this with a clever type:
type Product = {
id: number;
title: string;
price: number;
image: string;
description: string;
tags: string[];
};
type PartialRequired<
T,
K extends keyof T
> = Pick<T, K> & Partial<Omit<T, K>>;
type ProductDetails = PartialRequired<
Product,
"title" | "price" | "image"
>;
The PartialRequired type does the heavy lifting. It accepts two generic arguments: the original type and a set of keys that should remain required. The result is a new type where all properties are optional except the ones specified. In this case, title, price, and image stay mandatory. This way, the store holds a collection of ProductDetails. Initially, we populate a product with only the available data and then expand it with full details as they arrive, selecting it as a ProductDetail whenever necessary.
Now, let's wire this up into the store.
Defining the State
With @ngrx/entity, collections are represented by the EntityState interface. Here's how it comes together:
// state.ts file
export interface ProductsState extends EntityState<ProductDetails> {}
export const productsAdapter = createEntityAdapter<ProductDetails>();
Here, productsAdapter is an object filled with helper functions that operate on the EntityState. It provides methods for adding, removing, updating, selecting, sorting, filtering, and more. The generic parameter passed to createEntityAdapter specifies the entity type we're managing — in this case, ProductDetails.
Defining Actions
To handle changes, we define a set of actions. Since these actions are related, we can leverage the createActionGroup function available in @ngrx/store:
// actions.ts file
export const ProductsActions = createActionGroup({
source: 'Products',
events: {
'Load All': emptyProps(),
'Load All Success': props<{products: ProductDetails[]}>(),
},
});
Building the Reducer
With the state and actions in place, we can create a reducer that shows how the entity adapter simplifies state transitions:
// reducer.ts file
// imports omitted
export const productsReducer = createReducer(
productsAdapter.getInitialState(),
on(ProductsActions.loadAllSuccess, (state, { products }) =>
productsAdapter.addMany(products, state)
),
);
The adapter's addMany method is our first encounter with its utility. It takes two arguments: the array of products to insert and the current state, returning a new state with the products added. Like all adapter methods, it's a pure function — it takes the previous state and the data to modify, and produces a fresh state instance.
Accessing the State
Now, let's handle selecting the products. The adapter ships with pre-built selectors:
// selectors.ts file
const productsFeature = createFeatureSelector<ProductsState>(
'products',
);
export const selectors = productsAdapter.getSelectors();
export const selectAllProducts = createSelector(
productsFeature,
selectors.selectAll
);
Using this selector in a component is straightforward:
// product-list.component.ts file
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css'],
})
export class ProductListComponent implements OnInit {
products$ = this.store.select(fromProducts.selectAllProducts);
constructor(private readonly store: Store) {}
ngOnInit() {
this.store.dispatch(ProductsActions.loadAll());
}
}
As you can see, the entity adapter removes the need for writing custom list logic. Built-in selectors and reducers handle the state management, significantly cutting down boilerplate. But this is just scratching the surface. Next, let's look at product details, which requires a bit more work.
Note: We will omit the effects logic for brevity, but we can assume there is an effect that performs the HTTP request and dispatches the
ProductActions.loadAllSuccessaction.
Fetching a Single Entity
The goal here is a dedicated product details page. When a user navigates to it, we need to load and display full information for that product. We can use the @ngrx/router-store package to access the current route and extract the product id. Then, we can work with the entity state.
The router store offers built-in actions and state for navigation. We'll listen for a navigation event that matches product-details/:id to trigger the details fetch:
// effects.ts file
@Injectable()
export class ProductsEffects {
// other effects omitted
loadProductDetails$ = createEffect(() => {
return this.actions$.pipe(
ofType(routerNavigationAction),
filter(({payload}) => {
return payload.event.url.includes('product-details');
}),
mergeMap(({payload}) => {
const id = payload.event.state.root.paramMap.get('id');
return this.productsService.getProduct(id).pipe(
map(product => ProductsActions.loadProductDetailsSuccess({
product,
})),
catchError(
error => of(
ProductsActions.loadProductDetailsRrror({error}),
),
)
);
})
);
});
constructor(
private readonly actions$: Actions,
private readonly productService: ProductService,
) {}
}
The key is the routerNavigationAction from @ngrx/router-store. It fires whenever the router navigates. We use the filter operator to catch only navigations to the product details route.
Next comes an interesting reducer logic. We need to update an existing product in the state with the full data from the server. The natural approach is to find the product by its id and update it. However, there's a scenario we must handle: a user might land directly on the details page via a URL, without first visiting the list. In that case, the product isn't in the store yet, and we'd need to add it from scratch — requiring extra logic.
Fortunately, @ngrx/entity provides a solution with the upsertOne method:
// reducer.ts file
export const productsReducer = createReducer(
productsAdapter.getInitialState(),
// other handlers ommited
on(
ProductsActions.loadProductDetailsSuccess,
(state, {product}) => productsAdapter.upsertOne(product, state)
),
);
What does upsertOne do? It checks if an entity with the given id exists. If it does, it updates it; if not, it inserts it. This exactly solves our problem without any manual conditionals.
Selecting an Individual Entity
All that's left is to select the correct product from the store to show in the component. We'll combine the built-in entity selector with a router selector:
// selectors.ts file
import { getSelectors } from '@ngrx/router-store';
export const selectEntities = createSelector(
productsFeature,
selectors.selectEntities
);
const { selectRouteParams } = getSelectors();
export const selectSingleProduct = createSelector(
selectEntities,
selectRouteParams,
(entities, { id }) => entities[id]
);
Here, selectEntites returns a dictionary of products, allowing us to look up by id. Then we use the getSelectors function from @ngrx/router-store to get selectRouteParams, which gives us the current route parameters and lets us access the product id.
Finally, in the component:
// product-details.component.ts file
@Component({
selector: 'app-product-details',
templateUrl: './product-details.component.html',
styleUrls: ['./product-details.component.css'],
})
export class ProductDetailsComponent implements OnInit {
product$ = this.store.select(selectSingleProduct);
constructor(
private readonly store: Store,
) {}
ngOnInit() {}
}
Now the template can bind directly to the selected product details.
Note: This pattern allows us to potentially skip an API call on subsequent visits to the details page. We could check if the product is already in the store and select it instead of fetching. Whether to do this heavily depends on your business requirements. Don't rush to eliminate all API calls; evaluate each case to see if reading from the store is genuinely better than hitting the server.
Interconnected States: Building a Cart
A natural feature in such an app is a shopping cart. Users need to add products and later modify quantities.
One might represent a cart as an array of ids or objects with id and quantity. However, consider a scenario where a user adds a product that's already in the cart — we'd want to increment the quantity, not add a duplicate entry. We also need the total price and the ability to remove items. Storing the cart as a simple array would require manual logic for these cases. Using another EntityState is a cleaner option. Let's define the state and adapter:
// state.ts file
export interface CartItem {
productId: number;
quantity: number;
}
export interface CartState extends EntityState<CartItem> {}
Notice an important configuration in the adapter:
export const cartAdapter = createEntityAdapter<CartItem>({
selectId: item => item.productId,
});
We pass a selectId function to tell the adapter to use productId as the entity's id rather than a custom generated one. This ensures that each product appears only once in the cart, and adding it again simply updates the quantity.
Now for the reducer:
export const cartReducer = createReducer(
cartAdapter.getInitialState(),
on(
CartActions.addToCart,
(state, {item}) => cartAdapter.upsertOne(item, state),
),
on(
CartActions.removeFromCart,
(state, {productId}) => cartAdapter.removeOne(productId, state),
),
);
We again use upsertOne, which is perfect for our needs — if the product is already in the cart, its quantity is updated; if it's new, it's added.
Now for the interesting part: creating a single selector that provides the complete cart picture — item count, total price, and the list of items with quantities. Here it is:
export const cartSelectors = cartAdapter.getSelectors();
export const selectCartItems = createSelector(
cartFeature,
cartSelectors.selectAll
);
export const selectCart = createSelector(
selectCartItems,
selectCartTotal,
selectProductEntities,
(items, total, entities) => ({
items: items.map((item) => ({
...entities[item.productId],
quantity: item.quantity,
})),
total,
totalPrice: items.reduce(
(
acc,
next,
) => acc + (next.quantity * entities[next.productId].price),
0
),
})
);
This selector combines the product list with cart contents to generate comprehensive cart data. It uses the selectCartTotal selector we defined earlier for the item count. This finalized selector can be used directly in the cart component. A few noteworthy points:
- If product details change, the cart display updates automatically
- Adding a product already in the cart only increments the quantity
- No extra logic is needed to keep these states in sync
Note: You can find the complete working example here.
Wrapping Up
@ngrx/entity is a versatile utility that fits a wide range of list-management scenarios. When paired with the other techniques discussed earlier, such as leveraging router state, it enables you to build a complete user experience with remarkably little code. Keep in mind that @ngrx/entity offers additional methods and features that fall outside the scope of this discussion. For a deeper dive, consult the official docs or review the source implementation.
