(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>>()
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[] }
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
}
}
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)
}
}
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
})
)
Then we only have to import useDispatch
from the store, instead of Redux:
// components/mycomponent.tsx
import { useDispatch } from '../store'
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))
}
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
})
)
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)