Need help with immer-reducer?
Click the “chat” button below for chat support from the developer who created it, or find similar developers for support.

About the developer

esamattis
205 Stars 14 Forks MIT License 285 Commits 12 Opened issues

Description

Type-safe and terse reducers with Typescript for React Hooks and Redux

Services available

!
?

Need anything else?

Contributors list

# 23,147
GraphQL
React
gyrosco...
Redux
262 commits
# 712,978
React
TypeScr...
Redux
JavaScr...
3 commits
# 72,401
React
HTML
TypeScr...
Babel
2 commits
# 458,718
React
TypeScr...
Redux
CSS
2 commits
# 51,606
Elixir
React
annotat...
annotat...
2 commits
# 759,725
CSS
React
TypeScr...
Redux
1 commit
# 176
nextjs
postman...
graphql...
angular...
1 commit
# 193,022
C
reactjs
fullsta...
Redux
1 commit

immer-reducer

Type-safe and terse reducers with Typescript for React Hooks and Redux using Immer!

📦 Install

npm install immer-reducer

You can also install eslint-plugin-immer-reducer to help you avoid errors when writing your 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 { 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

⚓ React Hooks

Because the

useReducer()
API in React Hooks is the same as with Redux Reducers immer-reducer can be used with as is.
const initialState = {message: ""};

class ReducerClass extends ImmerReducer { setMessage(message: string) { this.draftState.message = message; } }

const ActionCreators = createActionCreators(ReducerClass); const reducerFunction = createReducerFunction(ReducerClass);

function Hello() { const [state, dispatch] = React.useReducer(reducerFunction, initialState);

return (
    <button data-testid="button" onclick="{()"> {
            dispatch(ActionCreators.setMessage("Hello!"));
        }}
    &gt;
        {state.message}
    </button>
);

}

The returned state and dispatch functions will be typed as you would expect.

🤔 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;

const setFirstNameEpic: Epic = 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) { // action is a union of action types if (isAction(action, ActionCreators.setFirstName)) { // with action of setFirstName } }

Warning: Due to how immer-reducers action generation works, adding default parameters to the methods will NOT pass it to the action payload, which can make your reducer impure and the values will not be available in middlewares.

class MyImmerReducer extends ImmerReducer {
    addItem (id: string = uuid()) {
        this.draftState.ids.push([id])
    }
}

immerActions.addItem() // generates empty payload { payload: [] }

As a workaround, create custom action creator wrappers that pass the default parameters instead.

class MyImmerReducer extends ImmerReducer {
    addItem (id) {
        this.draftState.ids.push([id])
    }
}

const actions = { addItem: () => immerActions.addItem(id) }

It is also recommended to install the ESLint plugin in the "Install" section to alert you if you accidentally encounter this issue.

📚 Examples

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

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

🃏 Tips and Tricks

You can replace the whole

draftState
with a new state if you'd like. This could be useful if you'd like to reset back to your initial state.
import {ImmerReducer} from "immer-reducer";

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

class MyImmerReducer extends ImmerReducer { // omitting other reducer methods

reset() {
    this.draftState = initialState;
}

}

📓 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

Get union of the action types generated by the ImmerReducer class

Example

type MyActions = Actions;

// 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");

function composeReducers(...reducers)

Utility that reduces actions by applying them through multiple reducers. This helps in allowing you to split up your reducer logic to multiple

ImmerReducer
s if they affect the same part of your state

Example

class MyNameReducer extends ImmerReducer {
    setFirstName(firstName: string) {
        this.draftState.firstName = firstName;
    }

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

}

class MyAgeReducer extends ImmerReducer { setAge(age: number) { this.draftState.age = 8; } }

export const reducer = composeReducers( createReducerFunction(MyNameReducer, initialState), createReducerFunction(MyAgeReducer, initialState) )

We use cookies. If you continue to browse the site, you agree to the use of cookies. For more information on our use of cookies please see our Privacy Policy.