DEV Community

Marton Sari
Marton Sari

Posted on • Edited on

Easy type safety with useDispatch and useSelector

(Update: the snippet in this article now has a package: react-redux-typed-hooks)

(Update 2: just use the types provided by @types/react-redux:

import * as RR from 'react-redux'

type StoreEvent = ReviewStoreEvent
interface Store {
  reviews: ReviewStore
}

export const useSelector: RR.TypedUseSelectorHook<Store> = RR.useSelector
eport const useDispatch = () => RR.useDispatch<Dispatch<StoreEvent>>()
Enter fullscreen mode Exit fullscreen mode

And turn on typescript's strict mode to make sure you're using the typed hooks!)

Adding types to Redux can be done in various ways with varying level of overhead and type safety. Some suggestions use enum type definitions for actions instead of string identifiers, some other sources use action creators. Both approaches suffer from these drawbacks:

  • It adds overhead; in case of action creators, you don't see the event shape immediately in the code.
  • It still doesn't prevent the developer from passing an arbitrary action object to the dispatch call.

(For proper term usage, from here I'll use the word event instead of action.)

Wouldn't it be nice if we could use the good old plain event objects, yet being fully safe from typos, or any kind of non-existent or misshaped events? And if we're at that, can we get the same level of type safety when selecting a chunk from the store with useSelector?

The answer is yes, and here I'll show how to do this.

As David Khourshid highlights it in his excellent post, in TypeScript, discriminated unions are a very good way to define well-formed store and event objects. Let's say we have a FruitStore and a corresponding event type:

export interface FruitStore {
  status: 'init' | 'loading' | 'loaded';
  pageSize: 25 | 50 | 100;
  data: FruitRecord[];
}

export type FruitStoreEvent =
  | { type: 'FRUITS_LOADING' }
  | { type: 'FRUITS_LOADED'; data: FruitRecord[] }
Enter fullscreen mode Exit fullscreen mode

And we have a reducer too, of course:

const initial: FruitStore = {
  status: 'init',
  pageSize: 25,
  data: []
}

export default (
  state: FruitStore = initial,
  event: FruitStoreEvent
): FruitStore => {
  switch (event.type) {
    case 'FRUITS_LOADING':
      return {
        ...state,
        status: 'loading'
      }
    case 'FRUITS_LOADED':
      return {
        ...state,
        status: 'loaded',
        data: event.data
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

The challenge now is to enforce dispatch calls to only receive well-formed events. If you import useDispatch directly from react-redux, there's no way to have any restriction on what kind of events are sent. In order to enforce proper types in the dispatch calls, we introduce our own useDispatch hook in the store:

import { useDispatch as _useDispatch } from 'react-redux'

export function useDispatch() {
  const dispatch = _useDispatch()
  return (event: FruitStoreEvent) => {
    dispatch(event)
  }
}
Enter fullscreen mode Exit fullscreen mode

As we probably will have more than one reducers, it's better to put this hook in the main Redux file, and have an aggregated event type:

// store/index.ts

import { createStore, combineReducers } from 'redux'
import { useDispatch as _useDispatch } from 'react-redux'

import fruits, { FruitStoreEvent } from './fruits'
import others, { OtherStoreEvent } from './others'

type StoreEvent = FruitStoreEvent | OtherStoreEvent

export function useDispatch() {
  const dispatch = _useDispatch()
  return (event: StoreEvent) => {
    dispatch(event)
  }
}

export default createStore(
  combineReducers({
    fruits,
    others
  })
)
Enter fullscreen mode Exit fullscreen mode

Then we only have to import useDispatch from the store, instead of Redux:

// components/mycomponent.tsx

import { useDispatch } from '../store'
Enter fullscreen mode Exit fullscreen mode

We're done with the dispatch side!

Now let's add types to useSelector too. This is a bit tricky, because we don't know what type comes out from the useSelector callback; but if we add type to the store root, TypeScript will know, and we can forward that information to our hook's return type with generics:

import { useSelector as _useSelector } from 'react-redux'

interface Store {
  fruits: FruitStore;
  others: OtherStore;
}

export function useSelector<T>(fn: (store: Store) => T): T {
  return fn(_useSelector(x => x))
}
Enter fullscreen mode Exit fullscreen mode

Now our store variables are properly typed.

Let's put everything together:

// store/index.ts

import { createStore, combineReducers } from 'redux'
import {
  useDispatch as _useDispatch,
  useSelector as _useSelector
} from 'react-redux'

import fruits, { FruitStore, FruitStoreEvent } from './fruits'
import others, { OtherStore, OtherStoreEvent } from './others'

type StoreEvent = FruitStoreEvent | OtherStoreEvent

interface Store {
  fruits: FruitStore;
  others: OtherStore;
}

export function useDispatch() {
  const dispatch = _useDispatch()
  return (event: StoreEvent) => {
    dispatch(event)
  }
}

export function useSelector<T>(fn: (store: Store) => T): T {
  return fn(_useSelector(x => x))
}

export default createStore(
  combineReducers({
    fruits,
    others
  })
)
Enter fullscreen mode Exit fullscreen mode

And that's it. The only thing we have to watch out is to import useDispatch and useSelector from our store, not from Redux.

Top comments (0)