typescript: usereducer revisited

2021-09-16

 | 

~4 min read

 | 

673 words

Last time I wrote about using React’s useReducer with Typescript I ended up relying on Type Guards to get my actions typed and ended with an claim that I’d come back some day when I understood action creators a bit better.

Today’s that day! Well, kind of. While I do use Action Creators in this post, the primary focus was on how to get typing for my reducer to work as expected.

Building on the work I’ve done with HTTP requests, I wanted to see what a reducer would look like inside of a component.1

So, without further ado, let’s write a small reducer and call out some of the lessons learned:

import { Reducer, useReducer } from "react";

export enum RemoteDataState {
  NotStarted = "Not Started",
  Loading = "Loading",
  Success = "Success",
  Fail = "Fail"
}

export type RemoteData<T, E = string> =
  | { state: RemoteDataState.NotStarted }
  | { state: RemoteDataState.Loading }
  | { state: RemoteDataState.Success; data: T }
  | { state: RemoteDataState.Fail; message: E };

type AvailState = RemoteData<unknown>;
const initialAvailState: AvailState = {
  state: RemoteDataState.NotStarted
};

enum AvailAction {
  resetAvailability = "resetAvailability "
}

function resetAvailability() {
  return {
    type: AvailAction.resetAvailability
  } as const;
}

type AvailActions = { type: AvailAction };

export default function App() {
  const [avail, availDispatch] = useReducer<Reducer<AvailState, AvailActions>>(
    (prevState, action) => {
      switch (action.type) {
        case AvailAction.resetAvailability: {

          return { state: RemoteDataState.NotStarted };
        }
      default:
        return prevState
      }
    },
    initialAvailState
  );

  return (
    /*...*/
  );
}

The first time I wrote this, my reducer was just a plain function with typed arguments:

const [avail, availDispatch] = useReducer(
  (prevState: AvailState, action: AvailAction) => {
    switch (action.type) {
      case AvailAction.resetAvailability: {
        return { state: RemoteDataState.NotStarted }
      }
      default:
        return prevState
    }
  },
  initialAvailState,
)

Typescript, however, complained. Specifically:

No overload matches this call.
  Overload 1 of 5, '(reducer: ReducerWithoutAction<any>, initializerArg: any, initializer?: undefined): [any, DispatchWithoutAction]', gave the following error.
    Argument of type '(prevState: AvailState, action: AvailAction) => { state: RemoteDataState; } | undefined' is not assignable to parameter of type 'ReducerWithoutAction<any>'.
  Overload 2 of 5, '(reducer: (prevState: RemoteData<unknown, string>, action: AvailAction) => { state: RemoteDataState; } | undefined, initialState: never, initializer?: undefined): [...]', gave the following error.
    Argument of type 'RemoteData<unknown, string>' is not assignable to parameter of type 'never'.
      Type '{ state: RemoteDataState.NotStarted; }' is not assignable to type 'never'.ts(2769)

Digging into the type file for React, my useReducer was receiving this type:

function useReducer<R extends ReducerWithoutAction<any>, I>(
  reducer: R,
  initializerArg: I,
  initializer: (arg: I) => ReducerStateWithoutAction<R>,
): [ReducerStateWithoutAction<R>, DispatchWithoutAction]

My reducer was getting typed as a ReducerWithoutAction despite the fact that I was passing actions!

I was effectively getting:

type ReducerWithoutAction<S> = (prevState: S) => S

instead of:

type Reducer<S, A> = (prevState: S, action: A) => S

So, the simplest solution was just to type the return type on my reducer:

const [avail, availDispatch] = useReducer(
-    (prevState: AvailState, action: AvailAction) => {
+    (prevState: AvailState, action: AvailAction): AvailState => {
      switch (action.type) {

Once I did that, Typescript could understand that my returned object was in fact of type AvailState and all was rosy.

But, useReducer is a generic and it can receive types up front to simplify further. That’s why I ended with the final form above:

const [avail, availDispatch] =
  useReducer<Reducer<AvailState, AvailActions>>(/*...*/)

The exercise was a good reminder of some of the limitations of Typescript, how to dig through Definitely Typed to get to an answer (a lesson I seem to be getting a lot lately), and how we can use Generic typings to aid in strong typing.

Footnotes

  • 1 I ultimately decided not to follow this pattern. In my case, however, this was because:
    1. I already had Redux setup and it was managing much of the state already
    2. I would end up having to duplicate certain behaviors every time I wanted to use a useReducer like this which I currently have bundled in a thunk for post requests.

Related Posts
  • 2021 Daily Journal


  • Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!