1.0.1 • Published 2 years ago

@emab/maction v1.0.1

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

Maction

A set of utility functions aimed to help you create well typed actions in React for redux. This also helps to remove boilerplate code and keep your store and actions typed without having to completely restructure.

Inspiration for this comes from this blog post written by Lenz Weber, an author of redux-toolkit.

Usage

createMaction

Creates a matchable action creator.

import {createMaction} from "@emab/maction";

const increment = createMaction((value: number) => ({
    type: "INCREMENT",
    value
}))

That is it! 🎉

You don't need to define an interface or type for the action - or create an enum to record the action types. Instead, the action creator itself can be used to match the action in the reducer. It is important that the action type is unique as you'd expect (a function called createPrefix is provided to help with that).

// Component
dispatch(increment(10));

// Reducer
const reducer = (state: State, action: AnyAction): State => {
    // Use the action creator to match the action
    if (increment.matches(action)) {
        // This is now strongly typed
        return {...state, value: action.value}
    }

    return state;
}

createPrefix

A simple utility function to create namespaced action types.

import {createMaction, createPrefix} from "@emab/maction";

const withPrefix = createPrefix("COUNTER_");

const increment = createMaction((value: number) => ({
    type: withPrefix("INCREMENT"),
    value
}))

increment(10).type === "COUNTER_INCREMENT"

matchableReducerFactory

An optional utility function that can help keep your split off reducers typed in line with actions.

It returns a function that takes a matchable action as its first parameter, and a reducer function as the second. If a generic for state has been provided, you should get full type safety here. This means if you change your action creator you'll get TS warnings here without having to go digging too far!

State type should be provided as a generic argument to the factory.

import {matchableReducerFactory} from "@emab/maction";

const reduceForAction = matchableReducerFactory<State>();

// Here `increment` was created using `createMaction`
// The types for `state` and `action` will be correctly typed here
const incrementReducer = reduceForAction(increment, (state, action) => ({
    ...state,
    value: action.value
}))

const reducer = (state: State, action: AnyAction): State => {
    if (increment.matches(action)) {
        return incrementReducer(state, action);
    }
}

Comparing boilerplate

Let's look at how this can replace your existing actions. This format may be familiar:

// actions.ts
export enum ActionType {
    INCREMENT = "INCREMENT",
    DECREMENT = "DECREMENT",
    RESET = "RESET"
}

type IncrementAction = {
    type: ActionType.INCREMENT,
    value: number
}

export const incrementAction = (value: number): IncrementAction => ({
    type: ActionType.INCREMENT,
    value
})

type DecrementAction = {
    type: ActionType.DECREMENT,
    value: number
}

export const decrementAction = (value: number): DecrementAction => ({
    type: ActionType.DECREMENT,
    value
})

type ResetAction = {
    type: ActionType.RESET
}

const resetAction = (): ResetAction => ({
    type: ActionType.RESET
})

export type Action = IncrementAction | DecrementAction | ResetAction;

// reducer.ts
const incrementReducer = (state: State, action: IncrementAction): State => ({
    ...state,
    value: state.value + action.value
})

const decrementReducer = (state: State, action: DecrementAction): State => ({
    ...state,
    value: state.value - action.value
})

const reducer = (state: State, action: Action): State => {
    switch (action.type) {
        case ActionType.INCREMENT:
            return incrementReducer(state, action);
        case ActionType.DECREMENT:
        case ActionType.RESET:
            return {
                ...state,
                value: 0
            }
        default:
            return state;
    }
} 

I've used this pattern many times - it feels safe and familiar. There is a lot of boilerplate code (looking at you, actions.ts!) but it comes with type safety, and you deal with each action specifically. As pointed out in the blog post mentioned earlier, this isn't really a great approach as we've typed action as Action in our reducer despite the fact that anything could be sent as an action, not just actions from the union type we defined.

Now the same code using maction:

// actions.ts
import {createMaction} from "@emab/maction";

export const incrementAction = createMaction((value: number) => ({
    type: "INCREMENT",
    value
}))

export const decrementAction = createMaction((value: number) => ({
    type: "DECREMENT",
    value
}))

export const resetAction = createMaction(() => ({
    type: "RESET"
}));

// reducer.ts
import {matchableReducerFactory} from "maction";
import {AnyAction} from "redux";

const reduceForAction = matchableReducerFactory<State>();

const incrementReducer = reduceForAction(incrementAction, (state, action) => ({
    ...state,
    value: state.value + action.value
}))

const decrementReducer = reduceForAction(decrementAction, (state, action) => ({
    ...state,
    value: state.value - action.value
}))

const reducer = (state: State, action: AnyAction): State => {
    if (incrementAction.matches(action)) {
        return incrementReducer(state, action);
    }
    if (decrementAction.matches(action)) {
        return decrementReducer(state, action);
    }
    if (resetAction.matches(action)) {
        return {
            ...state, value: 0
        }
    }

    return state;
} 

Overall I think this pattern is much neater. You avoid a tonne of boilerplate, and it's much easier to add and remove actions.

1.0.1

2 years ago

1.0.0

2 years ago

0.1.0

2 years ago