Boilerplate free class-based reducer creator. Built with TypeScript. Works with Redux and NGRX. Has integration with immer.
Heavily inspired by awesome ngrx-actions. It's pretty much a re-write of its reducer-related functionality with stricter typings, usage of reflected typed and leaving aside Angular-only functionality. This library is framework-agnostic and should work with any Redux implementation (Redux, NGRX).
Consider using it with flux-action-class.
- Installation
- Quick start
- Integration with
immer
- Reusing reducers
- Reducer inheritance
- In depth
- How does it compare to ngrx-actions?
-
Run
npm i reducer-class immer
-
If you use TypeScript set in you tsconfig.json
"experimentalDecorators": true, "emitDecoratorMetadata": true,
-
If you use JavaScript configure your babel to support decorators and class properties
-
Run
npm i reducer-class immer reflect-metadata
-
At the top of your project root file (most probably
index.tsx
) addimport 'reflect-metadata'
-
If you use TypeScript set in you tsconfig.json
"experimentalDecorators": true, "emitDecoratorMetadata": true,
-
If you use JavaScript configure your babel to support decorators and class properties
Recommended (with flux-action-class)
import { ActionStandard } from 'flux-action-class'
import { Action, ReducerClass } from 'reducer-class'
class ActionCatEat extends ActionStandard<number> {}
class ActionCatPlay extends ActionStandard<number> {}
class ActionCatBeAwesome extends ActionStandard<number> {}
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action
addEnergy(state: IReducerCatState, action: ActionCatEat) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state: IReducerCatState, action: ActionCatPlay | ActionCatBeAwesome) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
JavaScript version
import { ActionStandard } from 'flux-action-class'
import { Action, ReducerClass } from 'reducer-class'
class ActionCatEat extends ActionStandard {}
class ActionCatPlay extends ActionStandard {}
class ActionCatBeAwesome extends ActionStandard {}
class ReducerCat extends ReducerClass {
initialState = {
energy: 100,
}
@Action(ActionCatEat)
addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state, action) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
We can not use
Action
without arguments in JavaScript because there's no compiler which provides us with metadata for type reflection.
import { Action, ReducerClass } from 'reducer-class'
class ActionCatEat {
type = 'ActionCatEat'
constructor(public payload: number) {}
}
class ActionCatPlay {
type = 'ActionCatPlay'
constructor(public payload: number) {}
}
class ActionCatBeAwesome {
type = 'ActionCatBeAwesome'
constructor(public payload: number) {}
}
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action
addEnergy(state: IReducerCatState, action: ActionCatEat) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state: IReducerCatState, action: ActionCatPlay | ActionCatBeAwesome) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
JavaScript version
import { Action, ReducerClass } from 'reducer-class'
class ActionCatEat {
type = 'ActionCatEat'
constructor(payload) {
this.payload = payload
}
}
class ActionCatPlay {
type = 'ActionCatPlay'
constructor(payload) {
this.payload = payload
}
}
class ActionCatBeAwesome {
type = 'ActionCatBeAwesome'
constructor(payload) {
this.payload = payload
}
}
class ReducerCat extends ReducerClass {
initialState = {
energy: 100,
}
@Action(ActionCatEat)
addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state, action) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
We can not use
Action
without arguments in JavaScript because there's no compiler which provides us with metadata for type reflection.
With redux-actions
import { Action, ReducerClass } from 'reducer-class'
import { createAction } from 'redux-actions'
const actionCatEat = createAction('actionTypeCatEat')
const actionCatPlay = createAction('actionTypeCatPlay')
const actionCatBeAwesome = createAction('actionTypeCatBeAwesome')
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action(actionCatEat)
addEnergy(state: IReducerCatState, action: { payload: number }) {
return {
energy: state.energy + action.payload,
}
}
@Action(actionCatPlay, actionCatBeAwesome)
wasteEnegry(state: IReducerCatState, action: { payload: number }) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
You might have noticed that we always pass actions to
Action
in this version. It's because we no longer use classes for our actions and TypeScript can not provide type metadata.
JavaScript version
import { Action, ReducerClass } from 'reducer-class'
import { createAction } from 'redux-actions'
const actionCatEat = createAction('actionTypeCatEat')
const actionCatPlay = createAction('actionTypeCatPlay')
const actionCatBeAwesome = createAction('actionTypeCatBeAwesome')
class ReducerCat extends ReducerClass {
initialState = {
energy: 100,
}
@Action(actionCatEat)
addEnergy(state, action: { payload }) {
return {
energy: state.energy + action.payload,
}
}
@Action(actionCatPlay, actionCatBeAwesome)
wasteEnegry(state, action: { payload }) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
import { Action, ReducerClass } from 'reducer-class'
const actionTypeCatEat = 'actionTypeCatEat'
const actionTypeCatPlay = 'actionTypeCatPlay'
const actionTypeCatBeAwesome = 'actionTypeCatBeAwesome'
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action(actionTypeCatEat)
addEnergy(state: IReducerCatState, action: { payload: number }) {
return {
energy: state.energy + action.payload,
}
}
@Action(actionTypeCatPlay, actionTypeCatBeAwesome)
wasteEnegry(state: IReducerCatState, action: { payload: number }) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
You might have noticed that we always pass actions to
Action
in this version. It's because we no longer use classes for our actions and TypeScript can not provide type metadata.
JavaScript version
import { Action, ReducerClass } from 'reducer-class'
const actionTypeCatEat = 'actionTypeCatEat'
const actionTypeCatPlay = 'actionTypeCatPlay'
const actionTypeCatBeAwesome = 'actionTypeCatBeAwesome'
class ReducerCat {
initialState = {
energy: 100,
}
@Action(actionTypeCatEat)
addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
@Action(actionTypeCatPlay, actionTypeCatBeAwesome)
wasteEnegry(state, action) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
If your reducer expects 3 arguments reducer-class
automatically wraps it with produce
from immer.
- Original read-only state
- Draft of the new state that you should mutate
- Action
Why 3? Read pitfall #3 from immer's official documentation.
import { ActionStandard } from 'flux-action-class'
import { Action, ReducerClass, Immutable } from 'reducer-class'
class ActionCatEat extends ActionStandard<number> {}
class ActionCatPlay extends ActionStandard<number> {}
class ActionCatBeAwesome extends ActionStandard<number> {}
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action
addEnergy(state: Immutable<IReducerCatState>, draft: IReducerCatState, action: ActionCatEat) {
draft.energy += action.payload
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state: Immutable<IReducerCatState>, draft: IReducerCatState, action: ActionCatPlay | ActionCatBeAwesome) {
draft.energy -= action.payload
// Unfortunatelly, we can not omit `return` statement here due to how TypeScript handles `void`
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void
return undefined
}
}
const reducer = ReducerCat.create()
As you can see we still return
undefined
from the reducer even though we use immer and mutate our draft. Unfortunately, we can not omitreturn
statement here due to how TypeScript handlesvoid
. We can not even writereturn
(withourundefined
), because TypeScript then presumes the method returnsvoid
.
You might have noticed a new import -
Immutable
. It's just a cool name for DeepReadonly type. You don't have to use it. The example above would work just fine if used justIReducerCatState
. Yet it's recommended to wrap it withImmutable
to ensure that you never mutate it.
Actually it makes total sense to use
Immutable
for state of regular reducers as well to make sure you never modify state directly.
So what if we want to share some logic between reducers?
Create a class with shared logic.
import { Action, ReducerClassMixin } from 'reducer-class'
interface IHungryState {
hungry: boolean
}
export class ReducerHungry<T extends IHungryState> extends ReducerClassMixin<T> {
@Action(ActionHungry)
hugry(state: T) {
return {
...state,
hungry: true,
}
}
@Action(ActionFull)
full(state: T) {
return {
...state,
hungry: false,
}
}
}
You might have noticed that made this class generic. We have to do that because we do not know what actual state we going to extend, we can only put a constraint on it to make sure it satisfies the structure we need. In other words, if we used
IHungryState
directly and returned{ hungry: true }
(not{ ...state, hungry: true }
) fromhungry
compiler wouldn't complain.
You don't have to use
ReducerClassMixin
class. It's nothing but a convenience wrapper to make sure your class carries an index signature for type-safety. Alternatively you can useIReducerClassConstraint
interface andReducerClassMethod
type.
How to use `IReducerClassConstraint` interface and `ReducerClassMethod` type instead of `ReducerClassMixin` class
import { Action, IReducerClassConstraint, ReducerClassMethod } from 'reducer-class'
interface IHungryState {
hungry: boolean
}
export class ReducerHungry<T extends IHungryState> implements IReducerClassConstraint<T> {
[methodName: string]: ReducerClassMethod<T>
@Action(ActionHungry)
hugry(state: T) {
return {
...state,
hungry: true,
}
}
@Action(ActionFull)
full(state: T) {
return {
...state,
hungry: false,
}
}
}
JavaScript version
import { Action } from 'reducer-class'
export class ReducerHungry {
@Action(ActionHungry)
hugry(state) {
return {
...state,
hungry: true,
}
}
@Action(ActionFull)
full(state) {
return {
...state,
hungry: false,
}
}
}
Use @Extend decorator.
import { Action, Extend, ReducerClass } from 'reducer-class'
import { ReducerHungry } from 'shared'
interface ICatState {
hugry: boolean
enegry: number
}
@Extend<ICatState>(ReducerHungry)
class CatReducer extends ReducerClass<ICatState> {
initialState = {
energy: 100,
}
@Action
addEnergy(state: ICatState, action: ActionCatEat) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state: ICatState, action: ActionCatPlay | ActionCatBeAwesome) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
@Extend can accept as many arguments as you want.
Now our cat reducer uses wasteEnegry
to handle actions ActionCatPlay
, ActionCatBeAwesome
, addEnergy
to handle ActionCatEat
and inherits hugry
and full
methods to handle ActionHungry
and ActionFull
from ReducerHungry
.
JavaScript version
import { Action, Extend, ReducerClass } from 'reducer-class'
import { ReducerHungry } from 'shared'
@Extend(ReducerHungry)
class CatReducer extends ReducerClass {
initialState = {
energy: 100,
}
@Action(ActionCatEat)
addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay, ActionCatBeAwesome)
wasteEnegry(state, action) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
You can use class factories.
import { Action, Extend, ReducerClass, ReducerClassMixin } from 'reducer-class'
interface IHungryState {
hungry: boolean
}
export const makeReducerHungry = <T extends IHungryState>(actionHungry, actionFull) => {
class Extender1 extends ReducerClassMixin<T> {
@Action(actionHungry)
hugry(state: T) {
return {
...state,
hungry: true,
}
}
@Action(actionFull)
full(state: T) {
return {
...state,
hungry: false,
}
}
}
return Extender1
}
interface ICatState {
hugry: boolean
enegry: number
}
@Extend<ICatState>(makeReducerHungry(ActionCatPlay, ActionCatEat))
class CatReducer extends ReducerClass<ICatState> {
initialState = {
energy: 100,
}
@Action
addEnergy(state: ICatState, action: ActionCatEat) {
return {
energy: state.energy + action.payload,
}
}
@Action
wasteEnegry(state: ICatState, action: ActionCatPlay) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
JavaScript version
import { Action, Extend, ReducerClass } from 'reducer-class'
export const makeReducerHungry = (actionHungry, actionFull) =>
class {
@Action(actionHungry)
hugry(state) {
return {
...state,
hungry: true,
}
}
@Action(actionFull)
full(state) {
return {
...state,
hungry: false,
}
}
}
@Extend(makeReducerHungry(ActionCatPlay, ActionCatEat))
class CatReducer extends ReducerClass {
initialState = {
energy: 100,
}
@Action(ActionCatEat)
addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
@Action(ActionCatPlay)
wasteEnegry(state, action) {
return {
energy: state.energy - action.payload,
}
}
}
const reducer = ReducerCat.create()
Any reducer class is still a class, therefore it can be inherited. It's different way to share some common logic and alter the final behavior for children. There's no runtime information about method visibility (private
, protected
, public
), so if you want to share some common logic without wrapping it with @Action
decorator prefix the shared method with _
.
interface ICatState {
enegry: number
}
class CatReducer extends ReducerClass<ICatState> {
initialState = {
energy: 10,
}
@Action
addEnergy(state: ICatState, action: ActionCatEat) {
return this._addEnergy(state, action)
}
// DO NOT FORGET TO PREFIX IT WITH "_"
protected _addEnergy(state: ICatState, action: ActionCatEat): ICatState {
return {
energy: state.energy + action.payload,
}
}
}
class KittenReducer extends CatReducer {
// DO NOT FORGET TO PREFIX IT WITH "_"
protected _addEnergy(state: ICatState, action: ActionCatEat): ICatState {
return {
energy: state.energy + action.payload * 10,
}
}
}
JavaScript version
class CatReducer extends ReducerClass {
initialState = {
energy: 10,
}
@Action(ActionCatEat)
addEnergy(state, action) {
return this._addEnergy(state, action)
}
// DO NOT FORGET TO PREFIX IT WITH "_"
protected _addEnergy(state, action) {
return {
energy: state.energy + action.payload,
}
}
}
class KittenReducer extends CatReducer {
// DO NOT FORGET TO PREFIX IT WITH "_"
protected _addEnergy(state, action) {
return {
energy: state.energy + action.payload * 10,
}
}
}
You can omit list of actions for @Action
if you want to run a reducer function for a single action. Works with TypeScript only! Action must be a class-based action. It can be a flux-action-class' action, a classic NGRX class-based action or any other class which has either a static property type
or a property type
on the instance of the class.
If you have declare several reducer functions corresponding to the same action reducer-class
runs all of them serially. It uses its own implementation of reduce-reducers. The order is defined by Object.keys.
import { ActionStandard } from 'flux-action-class'
import { Action, ReducerClass } from 'reducer-class'
class ActionCatEat extends ActionStandard<number> {}
class ActionCatSleep extends ActionStandard<number> {}
interface IReducerCatState {
energy: number
}
class ReducerCat extends ReducerClass<IReducerCatState> {
initialState = {
energy: 100,
}
@Action(ActionCatEat, ActionCatSleep)
addEnergy(state: IReducerCatState, action: ActionCatEat | ActionCatSleep) {
return {
energy: state.energy + action.payload,
}
}
@Action
addMoreEnergy(state: IReducerCatState, action: ActionCatSleep) {
return {
energy: state.energy + action.payload * 2,
}
}
}
const reducer = ReducerCat.create()
const res1 = reducer(undefined, new ActionCatSleep(10))
console.log(res1.energy) // logs 130: 100 - initial value, 10 is added by addEnergy, 10 * 2 is added by addMoreEnergy
const res2 = reducer(res1, new ActionCatEat(5))
console.log(res2) // logs 135: 130 - previous value, 5 is added by addEnergy
It iterates over its arguments and copies their methods and corresponding metadata to a prototype of our target reducer class.
How does it compare to ngrx-actions?
- Stricter typings. Now you'll never forget to add initial state, return a new state from your reducer and accidentally invoke
immer
as a result and etc. @Action
can be used to automatically reflect a corresponding action from the type.ngrx-actions
doesn't allow matching several reducers to the same action, whilereducer-class
allows you to do that and merges them for you.reducer-class
is built with both worlds, Angular and Redux, in mind. It means equal support for all of them!reducer-class
works with function-based action creators and supports redux-actions out-of-the-box.