Original cover photo by Glenn Carstens-Peters on Unsplash.
In my previous article we covered implementing authorization using @ngrx/store and @ngrx/effects. In this article we will address working with lists of data. Here are some particular use cases we will cover:
- Loading a list of data from the server
- Adding an item to the list
- Updating an item in the list
- Deleting an item from the list
- Retrieving a single item to show
Of course, all of these cases can be implemented "manually" by just creating actions, reducers, and effects. But we will use the @ngrx/entity package to make our lives easier.
Introducing @ngrx/entity
The @ngrx/entity package provides a set of helper functions for working with collections of data. It provides a set of functions for managing the state of a collection of entities, including:
- Adding and removing entities
- Updating entities
- Selecting entities
- Sorting entities
- Filtering entities
- and so on
How it works?
First of all, let's describe our example. Imagine we have a web application that allows users to view and buy products. We have a list of products that we want to display on the main page. We also have a product details page where we show more information about a single product. We also want to allow users to add products to their shopping cart.
Here is how a product will look like:
export interface Product {
id: number;
name: string;
price: number;
description: string;
}
But here is a catch. When we retrieve the list of products, we don't get the full information about each product. We only get the product id, name, and price. We have to make a separate request to get the full information about a product. So we will have two different interfaces for a product. This is one way of doing this:
export interface Product {
id: number;
name: string;
price: number;
image: string;
}
export interface ProductDetails extends Product {
description: string;
tags: string[];
}
But this approach has a significant drawback. Problem is, we do not want to store a current viewed product detail in the store. We want to store only the list of products, and select one particular product via a selector when we need it. We can do the following to solve this problem:
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"
>;
Here, the PartialRequired
type we created does the heavylifting: it creates a new type that is a combination of the Product
type and a partial version of the Product
type. The PartialRequired
type takes two generic parameters: the first one is the type we want to create a partial version of, and the second one is a union type of the properties we want to make required. So the resulting type will have all the properties of the Product
type as optional, but the title
, price
, and image
properties will be required. Thus, we will store a list of ProductDetails
in the store, and we will first update a single Product
to contain additional data for displaying and then select it as a ProductDetail
when we need it.
Next, let's put this all into the Store
:
Defining the state
In @ngrx/entity
the state of a collection of entities is represented by the EntityState
interface. Here is how it works:
// state.ts file
export interface ProductsState extends EntityState<ProductDetails> {}
export const productsAdapter = createEntityAdapter<ProductDetails>();
productsAdapter
is an object that contains a set of helper functions for working with the EntityState
. It provides a set of functions for managing the state of a collection of entities, including adding, removing, updating, selecting, sorting, filtering, and so on. The createEntityAdapter
function takes a generic type parameter that is the type of the entity we want to work with. In our case, it is the ProductDetails
type.
Creating actions
Next, we want to define some actions to handle the state of the products. As we are going to create several related actions, we can use the new createActionGroup
function from @ngrx/store
:
// actions.ts file
export const ProductsActions = createActionGroup({
source: 'Products',
events: {
'Load All': emptyProps(),
'Load All Success': props<{products: ProductDetails[]}>(),
},
});
Creating a reducer for state updates
So far, we have created the state and defined the actions that can modify it. Now let's create a reducer to show exactly how the state can be changed and how the entity adapter helps us to do exactly that:
// reducer.ts file
// imports omitted
export const productsReducer = createReducer(
productsAdapter.getInitialState(),
on(ProductsActions.loadAllSuccess, (state, { products }) =>
productsAdapter.addMany(products, state)
),
);
Here we see our productsAdapter
in action for the first time: we use its built-in addMany
method to insert the list of products into the state. The addMany
method takes two parameters: the first one is the list of products we want to insert, and the second one is the current state. The addMany
method returns a new state with the products inserted. In general, all the methods of the productsAdapter
work in this fashion, as pure functions that take the previous state and the data to be modified and returning a new instance of our state.
Using the state
Now let's address selecting our list of products. productsAdapter
will contain a bunch of built-in selectors for our list of products:
// selectors.ts file
const productsFeature = createFeatureSelector<ProductsState>(
'products',
);
export const selectors = productsAdapter.getSelectors();
export const selectAllProducts = createSelector(
productsFeature,
selectors.selectAll
);
Now we can simply use this newly created selector in our component:
// 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, with an entity adapter we do not really need to write any sort of business logic about the lists of products. We can simply use the built-in selectors and reducers to manage the state of our products. This means greatly reducing boilerplate, but this is only the beginning. Now let's handle displaying details for one product, which will require a bit more data.
Note: for the sake of brevity we skip the effects part, but we can imagine we have an effect that makes an http call and returns the
ProductActions.loadAllSuccess
action.
Loading a single entity
Essentially in this part, what we want to do is to have a separate page for product details, and, when the user navigates to that page, we want to load the full information about the product and display it. Here, we can use the @ngrx/router-store
package to get the current route and the id of the product we want to display. We can then use the productsAdapter
to select the product from the store:
Router store provides built-in actions and states for router navigation. We are going to use an action from that collection in our case to load details about a single product when the user navigates to product-details/:id
:
// 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 important deal here is the routerNavigationAction
action imported from @ngrx/router-store
. This action is dispatched when the user navigates to a new route. We can use the filter
operator to filter out the actions that do not match our route. In our case, we want to load the product details only when the user navigates to the product-details/:id
route.
Next, we are going to do an interesting thing in our reducer. In our case, we want to update an existing product in the state with the full information we have received from the server. So for this, we want to find the product by id and update it. But here is a catch: what if the user navigates to the details page directly by the url, without going through the list of products? In this case, we do not have the product list in the state yet. In this case, we would want to add that one single product with full details, which now would require some messy logic.
Thankfully, @ngrx/entity
has got us covered with a special upsertOne
method:
// reducer.ts file
export const productsReducer = createReducer(
productsAdapter.getInitialState(),
// other handlers ommited
on(
ProductsActions.loadProductDetailsSuccess,
(state, {product}) => productsAdapter.upsertOne(product, state)
),
);
So what does upsertOne
do? It finds a product and updates it, or if it does not exist, it adds it to the state. This is exactly what we need in our case, no need to write any heavy logic!
Selecting a single entity
Now, all is left is to select the correct entity from the store and display it in our component. For this, we are going to use a predefined router selector from @ngrx/router-store
to select a specific product from the list:
// 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]
);
selectEntites
selector here returns the dictionary of the products ditrectly, so we can just select one by id. Next we use the getSelectors
function from @ngrx/router-store
to get the selectRouteParams
selector. This selector returns the current route parameters, so we can use it to get the id of the product we want to display.
And 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() {}
}
So what remains is to just display the product details in the template:
Note: with this approach it is possible to skip calling the API every time the user navigates to the details page. We can simply check if the product is already in the store and if it is, we can just select it. This heavily depends on the actual business logic and should be considered individually, so do not rush to change all your API calls, try to understand whether picking from the store is better than making a call
Interconnected states: Adding a product to the cart
In such an app, it is an expected feature to have a cart where the user can add products before checking out.
A cart may be thought of as an array of product ids, or maybe objects with product ids and quantities. So it might be tempting to just keep an array of such objects, but let's consider a scenario: what if the user wants to add a product into the cart after already having it in there, for example, to change the quantity of items? Also, we would need to see the total price of the cart, and we would need to be able to remove products from the cart.
For this, it would be a better approach to have the cart as another EntityState
to be able to pull off these manipulations without writing too much boilerplate code. Lets create the state and reducer:
// state.ts file
export interface CartItem {
productId: number;
quantity: number;
}
export interface CartState extends EntityState<CartItem> {}
Now, in the adapter, we must do something new:
export const cartAdapter = createEntityAdapter<CartItem>({
selectId: item => item.productId,
});
The selectId
here is needed to define that we do not want a custom generated id
, but, rather, we want to use the productId
as the id of this entity. This is because we want to have only one item per product in the cart entity, and we want to only update the quantity of the item if the user adds the same product again.
Now, we can create 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),
),
);
Notice that we use upsertOne
here as well, because, as mentioned, we want to update the quantity of the item if it already exists in the cart, rather than add the new product as a separate entity.
Now, we finally come to the most interesting part. What we want is to create a single selector that will return us the whole data related to the cart, namely, how many items we have, the total price and the list of products with their quantities itself.
Here we go:
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
),
})
);
Here, we combine the list of all products with the contents of the cart to extract the full information about the cart. We also use the selectCartTotal
selector that we have created earlier to get the total number of items in the cart. This finalized selector is ready to be used directly in the cart component. Notice two things:
- If the products themselves get updated, the cart will be updated as well
- When we add the same item, only the quantity gets updated
- No need to write hard logic to keep all those states synchronized
Note: You can view the full project implementation here.
Conclusion
@ngrx/entity
is a powerful tool, and can be used in a multitude of scenarios. Combined with other tools we have seen in this article (router state, for instance), it can create an implementation of a full UX experience with a minimal amount of code. Note that @ngrx/entity
has several other functions and nuances not covered here - feel free to explore the documentation and the source code to learn more.
Top comments (1)
获取所有数据在实际开发中可能比较少见,如果遇到需要分页的情况的话是不是就不太适用了。