DEV Community

Cover image for NgRx Use Cases, part II: Working with Lists
Armen Vardanyan for This is Angular

Posted on • Edited on

NgRx Use Cases, part II: Working with Lists

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;
}
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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"
>;
Enter fullscreen mode Exit fullscreen mode

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>(); 
Enter fullscreen mode Exit fullscreen mode

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[]}>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

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)
  ),
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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)
  ),
);
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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() {}
}
Enter fullscreen mode Exit fullscreen mode

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> {}
Enter fullscreen mode Exit fullscreen mode

Now, in the adapter, we must do something new:

export const cartAdapter = createEntityAdapter<CartItem>({
  selectId: item => item.productId,
});
Enter fullscreen mode Exit fullscreen mode

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),
  ),
);
Enter fullscreen mode Exit fullscreen mode

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
    ),
  })
);
Enter fullscreen mode Exit fullscreen mode

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:

  1. If the products themselves get updated, the cart will be updated as well
  2. When we add the same item, only the quantity gets updated
  3. 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)

Collapse
 
tanyibing profile image
CheeseBear

获取所有数据在实际开发中可能比较少见,如果遇到需要分页的情况的话是不是就不太适用了。