Skip to content
Type-safe and terse Redux reducers with Typescript
Branch: master
Clone or download
Latest commit 5299e74 Mar 27, 2019
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.vscode vscode thing Jun 14, 2018
__dtslint__ isActionFrom takes now a class as the argument Mar 27, 2019
__tests__ isActionFrom takes now a class as the argument Mar 27, 2019
src isActionFrom takes now a class as the argument Mar 27, 2019
.gitignore new build setup Nov 4, 2018
.prettierrc Initial Jun 12, 2018
.travis.yml Create .travis.yml Sep 30, 2018
CHANGELOG.md Add changelog file Dec 2, 2018
LICENSE Update LICENSE Nov 9, 2018
README.md
jest.config.js
package.json 0.7.0 Mar 27, 2019
tsconfig.build.json new build setup Nov 4, 2018
tsconfig.dtslint.json new build setup Nov 4, 2018
tsconfig.json fix type tests Nov 5, 2018
tslint.json

README.md

immer-reducer

Create terse type-safe Redux reducers using Immer and Typescript!

Read an introductory blog post here.

📦 Install

npm install immer-reducer

💪 Motivation

Turn this 💩 💩 💩

interface SetFirstNameAction {
    type: "SET_FIRST_NAME";
    firstName: string;
}

interface SetLastNameAction {
    type: "SET_LAST_NAME";
    lastName: string;
}

type Action = SetFirstNameAction | SetLastNameAction;

function reducer(action: Action, state: State): State {
    switch (action.type) {
        case "SET_FIRST_NAME":
            return {
                ...state,
                user: {
                    ...state.user,
                    firstName: action.firstName,
                },
            };
        case "SET_LAST_NAME":
            return {
                ...state,
                user: {
                    ...state.user,
                    lastName: action.lastName,
                },
            };
        default:
            return state;
    }
}

Into this!

import {ImmerReducer} from "immer-reducer";

class MyImmerReducer extends ImmerReducer<State> {
    setFirstName(firstName: string) {
        this.draftState.user.firstName = firstName;
    }

    setLastName(lastName: string) {
        this.draftState.user.lastName = lastName;
    }
}

🔥🔥 Without losing type-safety! 🔥🔥

Oh, and you get the action creators for free! 🤗 🎂

📖 Usage

Generate Action Creators and the actual reducer function for Redux from the class with

import {createStore} from "redux";
import {createActionCreators, createReducerFunction} from "immer-reducer";

const initialState: State = {
    user: {
        firstName: "",
        lastName: "",
    },
};

const ActionCreators = createActionCreators(MyImmerReducer);
const reducerFunction = createReducerFunction(MyImmerReducer, initialState);

const store = createStore(reducerFunction);

Dispatch some actions

store.dispatch(ActionCreators.setFirstName("Charlie"));
store.dispatch(ActionCreators.setLastName("Brown"));

expect(store.getState().user.firstName).toEqual("Charlie");
expect(store.getState().user.lastName).toEqual("Brown");

🌟 Typed Action Creators!

The generated ActionCreator object respect the types used in the class

const action = ActionCreators.setFirstName("Charlie");
action.payload; // Has the type of string

ActionCreators.setFirstName(1); // Type error. Needs string.
ActionCreators.setWAT("Charlie"); // Type error. Unknown method

If the reducer class where to have a method which takes more than one argument the payload would be array of the arguments

// In the Reducer class:
// setName(firstName: string, lastName: string) {}
const action = ActionCreators.setName("Charlie", "Brown");
action.payload; // will have value ["Charlie", "Brown"] and type [string, string]

The reducer function is also typed properly

const reducer = createReducerFunction(MyImmerReducer);

reducer(initialState, ActionCreators.setFirstName("Charlie")); // OK
reducer(initialState, {type: "WAT"}); // Type error
reducer({wat: "bad state"}, ActionCreators.setFirstName("Charlie")); // Type error

🤔 How

Under the hood the class is deconstructed to following actions:

{
    type: "IMMER_REDUCER:MyImmerReducer#setFirstName",
    payload: "Charlie",
}
{
    type: "IMMER_REDUCER:MyImmerReducer#setLastName",
    payload: "Brown",
}
{
    type: "IMMER_REDUCER:MyImmerReducer#setName",
    payload: ["Charlie", "Brown"],
    args: true
}

So the class and method names become the Redux Action Types and the method arguments become the action payloads. The reducer function will then match these actions against the class and calls the appropriate methods with the payload array spread to the arguments.

🚫 The format of the action.type string is internal to immer-reducer. If you need to detect the actions use the provided type guards.

The generated reducer function executes the methods inside the produce() function of Immer enabling the terse mutatable style updates.

🔄 Integrating with the Redux ecosystem

To integrate for example with the side effects libraries such as redux-observable and redux-saga, you can access the generated action type using the type property of the action creator function.

With redux-observable

// Get the action name to subscribe to
const setFirstNameActionTypeName = ActionCreators.setFirstName.type;

// Get the action type to have a type safe Epic
type SetFirstNameAction = ReturnType<typeof ActionCreators.setFirstName>;

const setFirstNameEpic: Epic<SetFirstNameAction> = action$ =>
  action$
    .ofType(setFirstNameActionTypeName)
    .pipe(
      // action.payload - recognized as string
      map(action => action.payload.toUpperCase()),
      ...
    );

With redux-saga

function* watchFirstNameChanges() {
    yield takeEvery(ActionCreators.setFirstName.type, doStuff);
}

// or use the isActionFrom() to get all actions from a specific ImmerReducer
// action creators object
function* watchImmerActions() {
    yield takeEvery(
        (action: Action) => isActionFrom(action, MyImmerReducer),
        handleImmerReducerAction,
    );
}

function* handleImmerReducerAction(action: Actions<typeof MyImmerReducer>) {
    // `action` is a union of action types
    if (isAction(action, ActionCreators.setFirstName)) {
        // with action of setFirstName
    }
}

📚 Examples

Here's a more complete example with redux-saga and redux-render-prop:

https://github.com/epeli/typescript-redux-todoapp

📓 Helpers

The module exports following helpers

function isActionFrom(action, ReducerClass)

Type guard for detecting whether the given action is generated by the given reducer class. The detected type will be union of actions the class generates.

Example

if (isActionFrom(someAction, ActionCreators)) {
    // someAction now has type of
    // {
    //     type: "setFirstName";
    //     payload: string;
    // } | {
    //     type: "setLastName";
    //     payload: string;
    // };
}

function isAction(action, actionCreator)

Type guard for detecting specific actions generated by immer-reducer.

Example

if (isAction(someAction, ActionCreators.setFirstName)) {
    someAction.payload; // Type checks to `string`
}

type Actions<ImmerReducerClass>

Get union of the action types generated by the ImmerReducer class

Example

type MyActions = Actions<typeof MyImmerReducer>;

// Is the same as
type MyActions =
    | {
          type: "setFirstName";
          payload: string;
      }
    | {
          type: "setLastName";
          payload: string;
      };

function setPrefix(prefix: string)

The default prefix in the generated action types is IMMER_REDUCER. Call this customize it for your app.

Example

setPrefix("MY_APP");
You can’t perform that action at this time.