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)