DEV Community

Mike Wu
Mike Wu

Posted on

Mapped Reducer Actions in TS

You should have a working knowledge of react, reducers, and typescript to get the most out of this post. I wrote this before finding out about mapDispatchToProps. I still decided to post anyway in case someone not using Redux wanted to implement it themselves in TS.

The idea is to automatically bind a dispatch to a group of action creators.

So instead of manually dispatching:

import {createAddItemAction} from 'items/actions.ts' const {dispatch} = useDispatch() dispatch(createAddItemAction({position: 5})) 

It'd be nicer to provide a method that already has dispatch in its scope:

const {addItem} = useEditor() addItem({position: 5}) 

I'll be referring to such a method as a mapped built action.

You might also see them referred to as bound action creators, or mapped dispatch to props. Personally I prefer built actions, it implies they're all good to go.

Sounds simple enough, right? Something like this will do:

const addItem = dispatch => position => dispatch(createAddItemAction({position}) 

But What if we had multiple action creators, and we were passing these into a context value? A ton of repetition.

The rest of the examples will be in Typescript; if you can think of a better way to type the functions - let me know!

Binding multiple action creators

Starting with a standard action creator

item/actions.ts

export const REMOVE_ITEM = 'REMOVE_ITEM' export interface RemoveItemAction { type: typeof REMOVE_ITEM category: Category position: number } const remove = (args: {in: Category; at: number}): RemoveItemAction => ({ type: REMOVE_ITEM, category: args.in, position: args.at, }) 

We'll also export a build function together with the actions that calls a withDispatch function

item/actions.ts

 //... insert, and addNew action creators are also defined here const remove = (args: {in: Category; at: number}): RemoveItemAction => ({ //... }) export const build = withDispatch({remove, insert, addNew}) 

withDispatch does most of the heavily lifting. Its job is to take an object of action creators, and return an object of built actions.

reducer.ts

// takes a bunch of actions ({remove, insert, addNew}) export function withDispatch<T extends ActionCreators>(actions: T) { return (dispatch: React.Dispatch<Action>): T => { const wrapped = {} as any return Object.entries(actions).reduce((acc, [key, createAction]) => { acc[key] = (...args: any[]) => dispatch(createAction(...args)) return acc }, wrapped) } } 

And we'll define our action types too

reducer.ts

export type Action = CategoryAction | ItemAction | MenuAction export interface ActionCreators { [prop: string]: (...args: any[]) => Action 

Where the binding happens

We'll bind the dispatch where we use the reducer. I'm using it in a context provider here.

editor/state/index.ts

import {reducer, initialState} from 'menu/editor/state/reducer' import {build as buildItemActions} from './item/actions' type EditorContextValue = typeof initialState & { item: ReturnType<typeof buildItemActions> } const EditorContext = createContext( (undefined as unknown) as EditorContextValue, ) export function EditorProvider(props: {children: ReactNode}) { const [state, dispatch] = useReducer(reducer, initialState) return ( <EditorContext.Provider value={{ ...state, item: buildItemActions(dispatch) }} > {props.children} </EditorContext.Provider> ) } 

Don't combine everything into a single context like this by the way, it will trigger unnecessary re-renders. See the solution on Github.

Sending the action

Finally, in our component we can just send the action

 import {useEditor} from './state' export default function MenuEditor(props: {restaurantId: number}) { const { item, categories } = useEditor() const category = category[0] item.remove({in: category, at: 2} // Calling our built action //... } 

Did I just re-invent the wheel? Sure did, but it's your wheel now too, enjoy!

Top comments (0)