ngrx-traits

NGRX Traits is a set of factory functions that dynamically generate actions, effects, reducers needed for common functionality like loading a list of entities, filtering, pagination, sorting. They can be mix and match, so you only use what you need, and y


License
MIT
Install
npm install ngrx-traits@2.0.0

Documentation

NGRX-Traits

NGRX Traits is a library to help you compose and reuse a set ngrx actions, selectors, effects and reducers across your app.

Features

  • Reduce boilerplate with generated strongly typed actions, selectors, reducers, and effects.
  • Easily mix with your own actions, selectors, reducers, and effects
  • Create your own traits, to easily reuse business logic
  • Transform any trait config from a global store to a local store bound to a components lifecycle
  • Trait to load entities list
  • Trait to filter remote and locally entities list
  • Trait to sort remote and locally entities list
  • Trait to paginate entities list
  • Trait to add single or multi selection entities list
  • Trait to add crud operations to an entities list
  • Trait to load single entities
  • Trait to reduce boilerplate needed when calling backend apis

Table of Contents

Getting Started

Extensible Setup

Local store traits

Custom Traits

Examples

Getting Started

The best way to understand how to use the traits is to see an example. Let's imagine that you need to implement a page that shows a list of products which you can select and purchase. Start by creating the interface for a Product Entity:

export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
}

Generally each trait has an interface that defines the state needed by it, so we will first create a interface for our state that uses our trait state interface

export interface ProductsState extends EntityAndStatusState<Product> {}

EntityAndStatusState is the most basic interface you need to create a state for a list of items, if you inspect it, it has the basic to store a list of entities using EntityState from @ngrx/entity, but it adds a status property used to track if it's loading, and a cache you can learn more in the addLoadEntities trait docs.

The next step is to create our traits file. We have two ways to configure them, one which we call minimal setup, that is better suited for cases where you don't intend to mix the generated actions,selectors, reducers, effects of your traits with your own normal ngrx action, selectors, reducer,effects and is the chosen one for this guide because is more compact (the extensible setup is design to mix it with normal ngrx actions, reducers, etc, you can see more here). The traits config for both cases is best to be contained in its own file with a .traits.ts extension with a content like:

products-basket.traits.ts

import { createFeatureSelector } from '@ngrx/store';
import { createEntityFeatureFactory } from 'ngrx-traits';
import { addLoadEntities } from 'ngrx-traits/traits';

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>() //<-- trait
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

In the minimal config I will also add the state interface to the same .traits.ts. Notice that, besides the trait we need some basic setup parameters. actionsGroupKey is the prefix that we will be added to all action types generated by the factory, which makes them unique. featureSelector is the base selector our trait selectors will join using createSelector as is normally done when creating any selector.

To check what was created by the factory, using your preferred IDE intellisense, inspect the productTraits variable

  • selectors
  • actions
  • initialState
  • reducer
  • effects
  • mutations

Choosing actions and inspecting it, you will see a list of actions with fetch, fetchSuccess, fetchFail, and in selectors you will see isLoading, selectAll, selectIds. I'll give a brief explanation of some of them, the rest can be seen in the docs of each trait.

The most important ones you will see are the fetch actions. They are meant to be used to create an effect that will populate the state. These actions are also used by other entities related traits like filter, sort, pagination, to fetch data when needed. Let's do one for our product list:

It is advised to export the actions and selectors in the traits file, so we can use them in the effect.

// in products-basket.traits.ts
export const ProductActions = productTraits.actions;
export const ProductSelectors = productTraits.selectors;

Now we create our effect that call the backend and populates our state :

products.effects.ts

import { ProductActions } from './products-basket.traits.ts';

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.fetch),
      switchMap(() =>
        //call your service to get the products data
        this.productService.getProducts().pipe(
          map((products) =>
            ProductActions.fetchSuccess({ entities: products })
          ),
          catchError(() => of(ProductActions.fetchFail()))
        )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private productService: ProductService
  ) {}
}

To be able to use the traits, we must create a module to connect the effects and reducers generated to the store:

@NgModule({
  imports: [
    EffectsModule.forFeature([...productTraits.effects, ProductsEffects]),
    StoreModule.forFeature('products', productTraits.reducer),
  ],
})
export class ProductsStateModule {}

Notice how the reducer generated by productTraits is used and the traits effects are combined with the ProductEffects manually created.

Next, just install the ProductsStateModule and use the actions and selectors in the products container component.

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    this.store.select(ProductSelectors.selectAll),
    this.store.select(ProductSelectors.isLoading),
  ]).pipe(map(([products, isLoading]) => ({ products, isLoading })));
  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <product-list [data]="data.products"></product-list>
  </ng-template>
</ng-container>

A simple product list is now ready and loaded. To keep this intro brief it's not intended to show much of the internals of the presentational components that are being used (can be found on the example folder). Examples

In order for the selection to work:

  • add the addSingleSelection trait
  • add the SingleSelectionState to our ProductState

Notice that, generally each trait has a [TraitName]State, (also has [TraitName]Actions and [TraitName]Selectors, but those are only needed for custom traits). In this case the SingleSelectionState is added like:

export interface ProductsState
  extends EntityAndStatusState<Product>,
    SingleSelectionState {}

Following, add the addSingleSelection trait:

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  // new trait ↓
  addSingleSelection<Product>()
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

The result is a new select action and a selectEntitySelected selector. Notice how this new actions and selectors are mixed with the others, the more traits you add, more actions, selectors, ...etc are mixed, this composability is very porweful, you can go from a local filtered list to a remotely paginated and remotely filtered one with small changes, and it will work the same way with your own custom traits.

Next step is to change the container component to use the new select action, () For this example, it is assumed that a select output was added as a prop of the list component called on the click of the row, the container will now look like:

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    this.store.select(ProductSelectors.selectAll),
    this.store.select(ProductSelectors.isLoading),
  ]).pipe(map(([products, isLoading]) => ({ products, isLoading })));

  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }
  // new event handler ↓
  select(id: string) {
    this.store.dispatch(ProductActions.select({ id }));
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <!-- new select event ↓ -->
    <product-list
      [data]="data.products"
      (select)="select($event)"
    ></product-list>
  </ng-template>
</ng-container>

Next step will be to add a checkout button, for that we can use the addAsyncAction, which again can save some boilerplate. In order to configure it, start by adding AsyncActionState<'checkout'> to our ProductState, like:

export interface ProductsState
  extends EntityAndStatusState<Product>,
    SingleSelectionState,
    AsyncActionState<'checkout'> {}

And next the addAsyncTrait

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  // new trait ↓
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  })
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

Checking the productTraits.actions, checkout(), checkoutSuccess({orderId:string}) and checkoutFail() are now present, and in the selectors isLoadingCheckout(), isSuccessCheckout() and isFailCheckout() can be found. These are the typical 3 actions and 3 selectors that are needed to do a backend call, for more details check the docs of the addAsyncAction trait. ()

Next, use them in an effect and in the container component:

products.effects.ts

checkout$ = createEffect(() =>
  this.actions$.pipe(
    ofType(ProductActions.checkout),
    concatLatestFrom(() =>
      this.store.select(ProductSelectors.selectEntitySelected)
    ),
    exhaustMap((product) =>
      this.productService.checkout({ productId: product.id }).pipe(
        map((orderId) => ProductActions.checkoutSuccess({ orderId })),
        catchError(() => of(ProductActions.checkoutFail()))
      )
    )
  )
);

And add the button to the component:

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    this.store.select(ProductSelectors.selectAll),
    this.store.select(ProductSelectors.isLoading),
    // new selectors ↓
    this.store.select(ProductSelectors.selectEntitySelected),
    this.store.select(ProductSelectors.isLoadingCheckout),
  ]).pipe(
    map(([products, isLoading, selectedProduct, isLoadingCheckout]) => ({
      products,
      isLoading,
      selectedProduct,
      isLoadingCheckout,
    }))
  );

  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }

  select(id: string) {
    this.store.dispatch(ProductActions.select({ id }));
  }
  // new event handler ↓
  checkout() {
    this.store.dispatch(ProductActions.checkout());
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <product-list
      [data]="data.products"
      (select)="select($event)"
    ></product-list>
  </ng-template>
  <!-- new checkout button ↓ -->
  <button
    type="submit"
    [disabled]="!data.selectedProduct || data.isLoadingCheckout"
    (click)="checkout()"
  >
    <mat-spinner *ngIf="data.isLoadingCheckout"></mat-spinner>
    Checkout
  </button>
</ng-container>

The next requirement in this context could be to add a filter section at the top to search by name or description, and sort the results. To start with the filters, create an interface that represents that filter form

export interface ProductFilter {
  search: string;
}

Add the FilterState interface to the ProductState

export interface ProductsState
  extends EntityAndStatusState<Product>,
    SingleSelectionState,
    AsyncActionState<'checkout'>,
    FilterState<ProductFilter> {}

Then add the addFilter trait:

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  }),
  // new trait ↓
  addFilter<Product, ProductFilter>({
    filterFn: (filter, entity) => {
      // filter function for local filtering
      return (
        entity.name.includes(filter.search) ||
        entity.description.includes(filter.search)
      );
    },
  })
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

This includes a new filter action and a selectFilter selector.

Next, change the container component to use the new filter action. Implement a presentational component for the filter section that has and input box for the search. The changes of that field will be piped to the output prop called search, this changes the container to look like:

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    this.store.select(ProductSelectors.selectAll),
    this.store.select(ProductSelectors.isLoading),
    this.store.select(ProductSelectors.selectEntitySelected),
    this.store.select(ProductSelectors.isLoadingCheckout),
  ]).pipe(map(([products, isLoading]) => ({ products, isLoading })));

  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }

  select(id: string) {
    this.store.dispatch(ProductActions.select({ id }));
  }

  checkout() {
    this.store.dispatch(ProductActions.checkout());
  }
  // new event handler ↓
  filter(filters: ProductFilter) {
    this.store.dispatch(ProductActions.filter({ filters }));
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <!-- new search event ↓ -->
    <products-search-form (search)="filter($event)"></products-search-form>
    <product-list
      [list]="data.products"
      (selectProduct)="select($event)"
    ></product-list>
  </ng-template>
  <button
    type="submit"
    [disabled]="!data.selectedProduct || data.isLoadingCheckout"
    (click)="checkout()"
  >
    <mat-spinner *ngIf="data.isLoadingCheckout"></mat-spinner>
    Checkout
  </button>
</ng-container>

One great benefit of using the filter trait is that it already contains debouncing and distinct until change, meaning that, there is no need to implement it in the form component, making it easier to test and saving you some code.

Next stop, sorting. First, add the SortState interface to the product state like:

export interface ProductsState
  extends EntityAndStatusState<Product>,
    SingleSelectionState,
    AsyncActionState<'checkout'>,
    FilterState<ProductFilter>,
    SortState<Product> {}

Then, add to trait:

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  }),
  addFilter<Product, ProductFilter>({
    filterFn: (filter, entity) => {
      return entity.name.includes(filter.search);
    },
  }),
  // ↓ new trait
  addSort<Product>()
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

That's it, the sort action and the selectSort selector have been added, you just need to use it. By default, it does local sorting (remote sorting can also be done, but for simplicity we choose local) Next, add it to the component

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    this.store.select(ProductSelectors.selectAll),
    this.store.select(ProductSelectors.isLoading),
    this.store.select(ProductSelectors.selectEntitySelected),
    this.store.select(ProductSelectors.isLoadingCheckout),
  ]).pipe(map(([products, isLoading]) => ({ products, isLoading })));

  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }

  select(id: string) {
    this.store.dispatch(ProductActions.select({ id }));
  }

  checkout() {
    this.store.dispatch(ProductActions.checkout());
  }

  filter(filters: ProductFilter) {
    this.store.dispatch(ProductActions.filter({ filters }));
  }
  // new event handler ↓
  sort(sort: Sort<Product>) {
    this.store.dispatch(ProductActions.sort(sort));
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <products-search-form (search)="filter($event)"></products-search-form>
    <!-- new sort event ↓ -->
    <product-list
      [list]="data.products"
      (select)="select($event)"
      (sort)="sort($event)"
    ></product-list>
  </ng-template>
  <button
    type="submit"
    [disabled]="!data.selectedProduct || data.isLoadingCheckout"
    (click)="checkout()"
  >
    <mat-spinner *ngIf="data.isLoadingCheckout"></mat-spinner>
    Checkout
  </button>
</ng-container>

That's all great, but after a meeting with the backend devs it was decided that the product list is growing too much so better if we implement remote pagination, and if we do pagination that means sorting and filtering also need to be implemented in the backend, lets start with remote filtering. To do remote filtering you first need to remove the filterFn in the traits like:

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  }),
  // changed trait ↓
  addFilter<Product, ProductFilter>(),
  addSort<Product>()
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

And to change our effect, it needs to use the selectFilter selector to get the filter params of the search:

products.effects.ts

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.fetch),
      concatLatestFrom(() =>
        // get filters ↓
        this.store.select(ProductSelectors.selectFilters)
      ),
      switchMap(([_, filters]) =>
        //call your service to get the products data
        this.productService
          .getProducts({
            search: filters.search,
          })
          .pipe(
            map((products) =>
              ProductActions.fetchSuccess({ entities: products })
            ),
            catchError(() => of(ProductActions.fetchFail()))
          )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private productService: ProductService
  ) {}
}

Now lets use remote sort, in our traits we add the remote param as true

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addFilter<Product, ProductFilter>(),
  // changed trait ↓
  addSort<Product>({ remote: true })
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

Now we use our selectSort in the effect , like we did with selectFilter:

products.effects.ts

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.fetch), // on fetch
      concatLatestFrom(() => [
        this.store.select(ProductSelectors.selectFilters),
        // get sorting ↓
        this.store.select(ProductSelectors.selectSort),
      ]),
      switchMap(([_, filters, sort]) =>
        //call your service to get the products data
        this.productService
          .getProducts({
            search: filters.search,
            sortColumn: sort.active,
            sortAcsending: sort.direction === 'asc',
          })
          .pipe(
            map((products) =>
              ProductActions.fetchSuccess({ entities: products })
            ),
            catchError(() => of(ProductActions.fetchFail()))
          )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private productService: ProductService
  ) {}
}

Notice no changes are needed on the component side for sorting or filtering to make them remote.

Now last thing is pagination, you should know the drill by now, first we add PaginationState to our ProductState:

export interface ProductsState
  extends EntityAndStatusState<Product>,
    FilterState<ProductFilter>,
    SortState<Product>,
    PaginationState {}

Then we add the addPagination to the traits like:

products-basket.traits.ts

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  }),
  addFilter<Product, ProductFilter>({
    filterFn: (filter, entity) => {
      return entity.name.includes(filter.search);
    },
  }),
  addSort<Product>({ remote: true }),
  // new trait ↓
  addPagination<Product>({ cacheType: 'partial' })
)({
  actionsGroupKey: '[Products]',
  featureSelector: createFeatureSelector<ProductsState>('products'),
});

This gives a bunch of extra actions and selectors, for this guide we will only use loadPage({index: number}) action and the selectPage and selectPageRequest selectors , lets start with selectPageRequest, this is used in the effect to get pagination details for a backend request:

products.effects.ts

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.fetch), // on fetch
      concatLatestFrom(() => [
        this.store.select(ProductSelectors.selectFilter),
        this.store.select(ProductSelectors.selectSort),
        // get pagination details for the request ↓
        this.store.select(ProductSelectors.selectPagedRequest),
      ]),
      switchMap(([_, filters, sort, pagination]) =>
        //call your service to get the products data
        this.productService
          .getProducts({
            search: filters.search,
            sortColumn: sort.active,
            sortAscending: sort.direction === 'asc',
            skip: pagination.startIndex,
            take: pagination.size,
          })
          .pipe(
            map((products) =>
              ProductActions.fetchSuccess({ entities: products })
            ),
            catchError(() => of(ProductActions.fetchFail()))
          )
      )
    )
  );

  constructor(
    private actions$: Actions,
    private productService: ProductService
  ) {}
}

Now that we have our effect ready , lets change our container component to use the new actions and selectors

product-page.component.ts

@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
})
export class ProductPageContainerComponent implements OnInit {
  data$ = combineLatest([
    // changed selectAll for selectPage ↓
    this.store.select(ProductSelectors.selectPage),
    this.store.select(ProductSelectors.isLoading),
    this.store.select(ProductSelectors.selectEntitySelected),
    this.store.select(ProductSelectors.isLoadingCheckout),
  ]).pipe(map(([products, isLoading]) => ({ products, isLoading })));

  constructor(private store: Store) {}

  ngOnInit() {
    this.store.dispatch(ProductActions.fetch());
  }

  select(id: string) {
    this.store.dispatch(ProductActions.select({ id }));
  }

  checkout() {
    this.store.dispatch(ProductActions.checkout());
  }

  filter(filters: ProductFilter) {
    this.store.dispatch(ProductActions.filter({ filters }));
  }

  sort(sort: Sort<Product>) {
    this.store.dispatch(ProductActions.sort(sort));
  }
  // new event handler ↓
  loadPage(page: PageEvent) {
    this.store.dispatch(ProductActions.loadPage({ index: page.pageIndex }));
  }
}

product-page.component.html

<ng-container *ngIf="data$ | async as data">
  <mat-spinner *ngIf="data.isLoading; else listProducts"></mat-spinner>
  <ng-template #listProducts>
    <products-search-form (search)="filter($event)"></products-search-form>
    <!-- new page event ↓ -->
    <product-list
      [data]="data.products"
      (select)="select($event)"
      (sort)="sort($event)"
      (page)="loadPage($event)"
    ></product-list>
  </ng-template>
  <button
    type="submit"
    [disabled]="!data.selectedProduct || data.isLoadingCheckout"
    (click)="checkout()"
  >
    <mat-spinner *ngIf="data.isLoadingCheckout"></mat-spinner>
    Checkout
  </button>
</ng-container>

The selectPage returns an object with following interface

export interface PageModel<T> {
  entities: T[];
  pageIndex: number;
  total: number;
  pageSize: number;
}

The entities you should use to render the rows of your table, and the rest of the object can be pass to your paginator component, the loadPage action should be call by your paginator component when someone changes the page using the paginator, like hitting a next button, or last page, in doubt check the presentational components in the examples.

Extensible Setup

In this section we will explain how to mix your traits generated actions, selectors, etc with your own manually created ones, for this we use a very opinionated structure that we recommend, the goal is not only make it easy to extend your store but also avoid circular dependencies, which can happen easily due the traits been needed in many files. Also, we will create a schematic that generates this structure for you in the future, this is based on a ngrx feature schematic, just enhanced to use the traits.

You can see an example of it here

Structure

The preferred structure we like to use is to create a state folder with a folder per subject or feature, which all the actions,selector etc inside, for example following the previous products case:

state/
    products/
          products.state.ts
          products.traits.ts
          products.actions.ts
          products.selectors.ts
          products.reducer.ts
          products.effects.ts
          products-state.module.ts
          index.ts
    featureX/
          featureX.state.ts
          featureX.traits.ts
          featureX.actions.ts
          .
          .

State

The .state.ts file is where we will add all the state interfaces, and we will add the extra props we want for our manually created reducer for example, following the state we created in the getting started section, lets add a discountCode prop.

products.state.ts

export interface ProductsState
  extends EntityAndStatusState<Product>,
    FilterState<ProductFilter>,
    SortState<Product>,
    PaginationState {
  discountCode?: string; // <-- new prop for our custom reducer and actions
  //... anything else you need
}

Traits

The .traits.ts file is where we will add all the traits config, and the base selector for the state:

products-basket.traits.ts

export const selectProductState =
  createFeatureSelector<ProductsState>('products');

export const productTraits = createEntityFeatureFactory(
  addLoadEntities<Product>(),
  addSingleSelection<Product>(),
  addAsyncAction({
    name: 'checkout',
    actionSuccessProps: props<{ orderId: string }>(),
  }),
  addFilter<Product, ProductFilter>({
    filterFn: (filter, entity) => {
      return entity.name.includes(filter.search);
    },
  }),
  addSort<Product>({ remote: true }),
  addPagination<Product>({ cacheType: 'partial' })
)({
  actionsGroupKey: '[Products]',
  featureSelector: selectProductState,
});

Notice that we have selectProductState exported, that is here mainly to avoid a circular dependency issue between products.trait.ts and products.selectors.ts, you will understand better in the selectors section.

Actions

Notice now how we mix the generated and manually created actions

products.actions.ts

import { productTraits } from './products-basket.traits.ts';
// we destruct the generated actions so they get mixed with the normal ones
// it also allows us to only expose some actions, or rename them.
export const {
  fetch: loadProducts, // rename example
  select,
  fetch,
  fetchSuccess,
  fetchFail,
  filter,
  sort,
  loadPage,
  checkout,
  checkoutSuccess,
  checkoutFailure,
} = productTraits.actions;

export const setDiscountCode = createAction(
  '[Products ]',
  props<{ code: string }>()
);

Selectors

Notice now how we mix the generated and manually created selectors

products.selectors.ts

import { productTraits, selectProductState } from './products-basket.traits.ts';
// we destruct the generated selectors so they get mixed with the normal ones
// it also allows us only expose some selectors, or rename them.
export const {
  selectAll,
  selectEntitySelected: selectProductSelected, // rename example
  selectFilters,
  selectSorting,
  selectPagedRequest,
  isLoadingCheckout,
} = productTraits.selectors;

export const selectDiscountCode = createSelector(
  selectProductState,
  (state) => state.discountCode
);

Notice if we had created selectProductState here instead of in the traits file , you will be getting a circular dependency between the two.

Reducers

Here you just need to notice how we mix the reducers, and the initialStates

products.selectors.ts

import { productTraits, selectProductState } from './products-basket.traits.ts';
import { createReducer } from '@ngrx/store';
import * as ProductActions from './products.actions.ts';

const initialState: ProduceState = {
  ...productTraits.initialState,
  discountCode: '',
};
//normal ngrx reducer
const myReducer = createReducer(
  initialState,
  on(ProductActions.setDiscountCode, (state, { code }) => ({
    ...state,
    discountCode: code,
  }))
);
// mixing our productTraits reducers with myReducer
export function productsReducer(state = initialState, action: Action) {
  const s = myReducer(state, action);
  return productTraits.reducer(s, action);
}
// the previous function is the same as using the helper function
// export const productsReducer = joinReducers(myReducer,productTraits.reducer);

Effects

There is nothing special here related to traits, just be sure to add them to a EffectsModule.forFeature mixed with the traits ones like is shown in the next section, also use relative path to actions and selectors from the products.actions.ts, and products.selectors.ts, don't import them from the barrel file, or you will get a circular dependency.

Module

Here we show how we setup the reducer and effects in the module

products-state.module.ts

import { productsTraits } from './products-basket.traits.ts';
import { ProductsEffects } from './products.effects.ts';
import { productsReducer } from './products.reducer.ts';
@NgModule({
  imports: [
    EffectsModule.forFeature([...productTraits.effects, ProductsEffects]),
    StoreModule.forFeature('products', productTraits.reducer),
  ],
})
export class ProductsStateModule {}

We particularly like to add a module in each feature state folder, makes them easier to move if needed

Barrel file

Finally, the index.ts file where we just re-export our actions and selectors like

index.ts

import * as ProductActions from './products.actions.ts';
import * as ProductSelectors from './products.selectors.ts';
export { ProductActions, ProductSelectors };