NgRX Slice

ngrx-slice is a plugin that intends to provide the same functionalities that Redux Toolkit createSlice provides. It is meant to be opinionated .

ngrx-slice also assumes the consumers to be familiar with the concepts of NgRX . If not, please visit NgRX Official Documentations before continue on.

  • Express a slice of the global state in a single central place
  • Generate ActionCreators from reducers' cases
  • Generate AsyncActionCreators from reducers' cases
  • Generate MemoizedSelectors from initialState
  • Utilize immer and ngrx-immer under the hood for State updates
  • Customizable (partial) Actions' types

Peer Dependencies

AngularNgRX
12+12+
11+11+
No support for Angular <11 because of TypeScript version

Installation

      npm install ngrx-slice
      yarn add ngrx-slice
    

ngrx-slice has ngrx-immer and immer as its peerDependencies so go ahead and install those:

        npm install ngrx-immer immer
        yarn add ngrx-immer immer
      

Here's one command for all three:

        npm install ngrx-slice ngrx-immer immer
        yarn add ngrx-slice ngrx-immer immer
      

Counter Example

Count: 0

You have pressed increment: 0 times

You have pressed decrement: 0 times

This is the most minimal-simple example of a Counter . Let's take a look again at slice.ts

slice.ts

import { createSlice } from 'ngrx-slice';
export interface CounterState {
value: number;
incrementCount: number;
decrementCount: number;
}
export const initialState: CounterState = {
decrementCount: 0,
incrementCount: 0,
value: 0,
};
export const {
actions: CounterActions,
selectors: CounterSelectors,
...CounterFeature
} = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value++;
state.incrementCount++;
},
decrement: (state) => {
state.value--;
state.decrementCount++;
},
},
});

A slice represents a piece of the global state . createSlice works based on two main things:

  • reducers : used to generate the ActionCreators
  • initialState : used to generate the MemoizedSelectors

Reducers - ActionCreators

With createSlice , we express the State Updaters via reducers . Each case defined in reducers will act as the name of the generated ActionCreator respectively

For example, reducers.increment will allow createSlice to generate actions.increment()

Initial State - MemoizedSelectors

On the other hand, the initialState will be used to generate the Selectors . If your initialState is an empty object {} , then no Selectors will be generated except for the Feature Selector

For example, initialState in the example above will generate selectors.selectCounter ( FeatureSelector via createFeatureSelector ), selectors.selectValue ( initialState.value via createSelector ), selectors.selectIncrementCount , and selectors.selectDecrementCount

Mutability

createSlice makes use of Immer under the hood to provide simpler State Updates operations. Normally, mutating state is not allowed but as you notice, we mutate our CounterState with state.value++ . This is all thanks to Immer . For more complex update patterns, please check out Immer documentations

Counter with Effects Example

Count: 0

You have pressed increment: 0 times

You have pressed decrement: 0 times

In this Effects example, we introduce a couple of additional features that ngrx-slice has

  • Async Case Reducer
  • Typed Action
  • Noop Reducers

Async Case Reducer

In previous examples, we have seen increment: state => {/*...*/} . This is a Case Reducer which is a reducer function that updates state . In addition, ngrx-slice provides Async Case Reducer to define group of cases that are highly related to each other.

An Async Case Reducer is an object with the shape of { success: CaseReducer, trigger?: CaseReducer, failure?: CaseReducer, cancel?: CaseReducer, clear?: CaseReducer } where the success case is required .

Typed Action

Since Action Creators are generated from Case Reducers , there is a need to provide a type for Actions with Payload

ngrx-slice provides PayloadAction type to do so.

  • increment: state => {/*...*/} gives actions.increment()
  • increment: (state, action: PayloadAction<{ value: number }>) => {/*...*/} gives actions.increment({ value: 5 })

Noop Reducers

Not all Actions need to be handled by Reducers . Sometimes, we have some Actions as triggers for Side Effects . For these situations, ngrx-slice provides the noopReducer<ActionPayload, SliceState>() which does nothing rather than making itself available as a generated action.

Namespaced Slice

ngrx-slice exposes a more opinionated createSlice which is createNamespacedSlice

createNamespacedSlice utilizes the name of the slice to return a more-opinionated object, aka "namespaced". For example:

import { createNamespacedSlice } from 'ngrx-slice';
export interface CounterState {
value: number;
incrementCount: number;
decrementCount: number;
}
export const initialState: CounterState = {
decrementCount: 0,
incrementCount: 0,
value: 0,
};
export const { CounterActions, CounterSelectors, CounterFeature } =
createNamespacedSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value++;
state.incrementCount++;
},
decrement: (state) => {
state.value--;
state.decrementCount++;
},
},
});

Action Type

By default, all generated actions will have the following type: [featureName_in_capitalize]: actionName and all generated async actions will have the same type with success , failure , and triggersuffixed to respective actions. For example:

CounterActions.increment(); // {type: '[Counter] increment'}
CounterActions.decrement(); // {type: '[Counter] decrement'}
CounterActions.double(); // {type: '[Counter] double'}
CounterActions.multiplyBy.trigger(); // {type: '[Counter] multiplyBy trigger'}
CounterActions.multiplyBy.success(); // {type: '[Counter] multiplyBy success'}

This behavior is customizable with sliceActionNameGetter that createSlice accepts. sliceActionNameGetter has the following signature: (featureName: string, actionName: string) => string;

External Actions

With createSlice API, internal reducers are used to generate ActionCreators , what about external actions that can change the CounterState ? We can utilize extraReducers for that

import { on } from '@ngrx/store';
import { triple } from 'path/to/external';
const { CounterActions, CounterSelectors, CounterFeature } = createSlice({
name: 'counter',
initialState,
reducers: {
/* ... */
},
extraReducers: [
on<CounterState, [typeof triple]>(triple, (state) => ({
...state,
value: state.value * 3,
})),
],
});

Because triple is an action that is external to this slice, createSlice will not generate an action for triple when it's used in extraReducers . extraReducers will be merged with reducers and return a complete reducer for StoreModule.forFeature()

Entity Example w/ Todo


Similar to @ngrx/entity , ngrx-slice also exposes a way to deal with Entity which is a function called createSliceEntityAdapter() . This function is exported from ngrx-slice/entity

The signature of createSliceEntityAdapter is exactly the same as createEntityAdapter from @ngrx/entity

  • selectId : an ID selector function. Default to: (entity) => entity.id . If your entity uses a different field as the ID rather than id , you need to provide selectId
  • sortComparer : a comparer function which will be used to determine the order of entityState.ids . Default to: false
import { createSelector } from '@ngrx/store';
import { createSlice, PayloadAction } from 'ngrx-slice';
import { createSliceEntityAdapter } from 'ngrx-slice/entity';
export interface Todo {
id: number;
text: string;
isCompleted: boolean;
}
export const todoAdapter = createSliceEntityAdapter<Todo>({
sortComparer: (a, b) => a.text.localeCompare(b.text),
});
export const {
actions: TodoActions,
selectors,
...TodoFeature
} = createSlice({
name: 'todo',
initialState: todoAdapter.getInitialState(),
reducers: {
todoAdded: todoAdapter.addOne,
toggleComplete: (state, { todo }: PayloadAction<{ todo: Todo }>) => {
todoAdapter.updateOne(state, {
id: todo.id,
changes: {
isCompleted: !todo.isCompleted,
},
});
},
},
});
export const TodoSelectors = {
...selectors,
selectAll: createSelector(
selectors.selectTodoState,
todoAdapter.getSelectors().selectAll
),
};

Same idea as createSlice , you do not have to return a new state in your reducers. Adapter methods are invoked with Immer's Draft .

You can either create your CaseReducer like usual and invoke the Adapter methods, or you can just use the Adapter methods as your CaseReducer . Like: todoAdded: todoAdapter.addOne

Special Mentions

  • Marko Stanimirović ( @MarkoStDev ) for ngrx-child-selectors and his createFeature PR
  • Tim Deschryver ( @tim_deschryver ) for ngrx-immer
  • Mark Erikson ( @acemarke ) for Redux Toolkit
GitHub