Lesson 14. Better hooks with TypeScript 3. Typing reducers and actions
When using reducers with the useReducer hook, there are two things you need to
provide a type for: the state, and the actions.
Let’s take the example of lesson 11, where we want to fetch information about a
planet, and store in the state the status of the operation: a loading flag, an
error flag, and the result.
This is how we can type the state:
interface Planet {name: string}interface State {loading: booleanerror: booleanplanet: Planet | null}
We can then use the State type to define our initial state:
const initialState: State = {loading: false,error: false,planet: null,}
To type the actions, a first way to do can be to define one type of each action, then another one to include all these types:
interface LoadAction {type: 'load'}interface SuccessAction {type: 'success'planet: Planet}interface ErrorAction {type: 'error'}type Action = LoadAction | SuccessAction | ErrorAction
Or you can declare all actions types in Action without using intermediary
types:
type Action =| { type: 'load' }| { type: 'success'; planet: Planet }| { type: 'error' }
Unless I need to be able to use action types individually, my preference goes to this second solution since it is more concise.
Once you’ve defined types for the state and the actions, you can use them to define the reducer:
const reducer = (state: State, action: Action): State => {switch (action.type) {case 'load':return { ...state, loading: true, error: false }case 'success':return { ...state, loading: false, planet: action.planet }case 'error':return { ...state, loading: false, error: true }}}
Now, since you gave types to parameters and the function’s return value, if you make a mistake in an action type or a state attribute TypeScript will throw an error during type checking. For instance:
case "errors":// ^^^^^^^^// Type '"errors"' is not comparable to type '"load" | "success" | "error"'. ts(2678)return { ...state, loading: false, errors: true };// ^^^^^^// Type '{ loading: false; errors: boolean; error: boolean; planet: Planet | null; }'// is not assignable to type 'State'.// Object literal may only specify known properties, but 'errors' does not exist in// type 'State'. Did you mean to write 'error'?
As our reducer and our initial state are typed, we don’t need to specify any
other explicit type when we use useReducer:
const [state, dispatch] = useReducer(reducer, initialState)
stateis of typeState,dispatchwon’t accept a parameter which is not of theActiontype.
Remember how in lesson 11 we saw how defining functions to create actions made your code cleaner and prevented mistakes as opposed to creating actions by hand?
const fetchStart = () => ({ type: 'load' })const fetchSuccess = (planet) => ({ type: 'success', planet })const fetchError = () => ({ type: 'error' })
With TypeScript, you can still use these functions, and give them the right action as return type:
const fetchStart = (): LoadAction => ({ type: 'load' })const fetchSuccess = (planet: Planet): SuccessAction => ({type: 'success',planet,})const fetchError = (): ErrorAction => ({ type: 'error' })
But now that the dispatch action is typed accordingly to the Action type you
defined, it doesn’t bring as much value as before. It is totally fine to create
actions by hand at the dispatch time:
dispatch({ type: 'load' })
Again, if you make a mistake in the action type or other attributes, TypeScript will throw an error:
dispatch({ type: 'start' })// ^^^^// Type '"start"' is not assignable to type '"load" | "success" | "error"'. ts(2322)
Here is the final code for our small example:
interface Planet {name: string}interface State {loading: booleanerror: booleanplanet: Planet | null}type Action =| { type: 'load' }| { type: 'success'; planet: Planet }| { type: 'error' }const initialState: State = {loading: false,error: false,planet: null,}const reducer = (state: State, action: Action): State => {switch (action.type) {case 'load':return { ...state, loading: true, error: false }case 'success':return { ...state, loading: false, planet: action.planet }case 'error':return { ...state, loading: false, error: true }}}const PlanetInfo = () => {const [state, dispatch] = useReducer(reducer, initialState)const { loading, error, planet } = stateuseEffect(() => {let active = truedispatch({ type: 'load' })fetch('https://swapi.dev/api/planets/1/').then((res) => res.json()).then((planet) => {if (active) {dispatch({ type: 'success', planet })}}).catch((error) => {if (active) {console.error(error)dispatch({ type: 'error' })}})return () => {active = false}}, [])if (loading) {return <p>Loading…</p>} else if (error) {return <p>An error occurred.</p>} else if (planet) {return <p>Planet: {planet.name}</p>} else {return null}}export default function App() {return <PlanetInfo />}
In my opinion, reducers and actions are the place where TypeScript makes the more sense when using hooks. By giving precise types to your state and actions, the code becomes much easier to understand and maintain.
Now that we have seen how TypeScript improves how to use the hooks provided by React, let’s see how it will improve your custom hooks.