3 Comments

A Pattern for Type-Safe Redux in TypeScript

I had the pleasure this year of leading an effort to re-write a complex editor application for a manufacturing test environment. The project dealt with highly complex, structured documents, so we felt that Redux would be a great storage solution.

During the effort, I was able to refine work I had previously done to provide a clean and type-safe set of Redux state, action creators, reducers, and selectors. After a less-than-stellar experience with Redux in the past, this pattern has been a joy to work with. It provides a high level of confidence that things are configured properly with the built-in type safety.

In this example, we’ll be setting up Redux to support an editor application. It will be very simple, so we’ll only store the document contents, whether the document is loaded, and whether the document has been modified.

Redux Setup

1. State

First, we’ll set up our application state. In our example, we have two sections of state: one section stores the document itself, and the other section stores some meta-information that the editor interface uses.

Our document state is very simple and consists of a single string document property. In the real world, this can be something more complex (such as a normalized object structure).

We can define it as follows:

app/redux/state/document-state.ts

export type DocumentState = {
  document: string | undefined;
};

export const initialDocumentState: DocumentState = {
  document: undefined,
};

Our editor state in this example is also very simple and keeps track of whether the document is loaded and if it has been modified. We'll define it as follows:

app/redux/state/editor-state.ts

export type EditorState = {
  isLoaded: boolean;
  isDirty: boolean;
};

export const initialEditorState: EditorState = {
  isLoaded: false,
  isDirty: false,
};

We'll combine the two sections of state into a single object representing our entire Redux state:

app/redux/state/index.ts

import { DocumentState, initialDocumentState } from "app/redux/state/document-state";
import { EditorState, initialEditorState } from "app/redux/state/editor-state";

export type State = {
  Document: DocumentState;
  Editor: EditorState;
};

export const initialState: State = {
  Document: initialDocumentState,
  Editor: initialEditorState,
};

2. Action Creators

Next, we'll create action creators that allow us to have some interactivity in our application. We'll define some helper types that support configuring our action types as we build up the action creators themselves. The ActionType type allows us to extract the actions themselves out of the action definitions:

app/redux/types/action-types.ts

export type ValueOf = T[keyof T];

export type ActionType<TActions extends { [k: string]: any }> = ReturnType<ValueOf<TActions>>;

We'll define a simple "changed" action for the document:

app/redux/actions/document-actions.ts

import { ActionType } from "app/redux/types/action-types";

export const DocumentActions = {
  changed: (document: string) =>
    ({
      type: "Document/changed",
      payload: { document },
    } as const),
  };

export type DocumentActions = ActionType<typeof DocumentActions>;

We'll define the arguments to the action creator and the payload inline. The as const combined with the ActionType type will allow us to extract the types directly from the action creators, which provides us type safety and auto-completion in the reducers later without duplicated effort.

We'll define another set of action creators, this time for our editor:

app/redux/actions/editor-actions.ts

import { ActionType } from "app/redux/types/action-types";

export const EditorActions = {
  loaded: (document: string) =>
    ({
      type: "Editor/loaded",
      payload: {
      document,
    },
  } as const),
  saved: () =>
    ({
      type: "Editor/saved",
    } as const),
  };

export type EditorActions = ActionType<typeof EditorActions>;

We'll combine the two sections into a single Actions const that we can use later in our application:

app/redux/actions/index.ts

import { Action } from "redux";
import { ThunkAction } from "redux-thunk";

import { DocumentActions } from "app/redux/actions/document-actions";
import { EditorActions } from "app/redux/actions/editor-actions";

export const Actions = {
  Document: DocumentActions,
  Editor: EditorActions,
};

export type Actions =
  | DocumentActions
  | EditorActions;

export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, State, null | undefined, Action<Actions["type"]>>;

AppThunk will provide us type-safe access in a Redux Thunk. This provides the dispatch function in our thunk with knowledge of our action creators (see next code example below). Sometimes we want to call multiple dispatches or do some other asynchronous operation like saving or loading a file.

3. Thunks

Then, we'll define some simple Thunks for loading and saving our document. Note that the dispatch call will enforce type safety of the actions that we defined earlier.

app/redux/thunks/index.ts

import { AppThunk, Actions } from "app/redux/actions";

export const loadDocument = (): AppThunk<Promise<void>> = async (dispatch) => {
  // load document from external resource
  const document = await fetch("https://example.com/api/document");

  dispatch(Actions.Editor.loaded(document));
};

export const saveDocument = (): AppThunk<Promise<void>> = async (dispatch) => {
  // save document to external resource using an await

  dispatch(Actions.Editor.saved());
};

4. Reducers

Next, we'll define some reducers to actually mutate our state. We will define a type that will allow us to more easily specify the section in state that we are handling, which will also provide type safety:

app/redux/types/reducer-types.ts

import { Actions } from "app/redux/actions";

export type AppReducer<TState extends keyof State, TAdditionalActions = Actions> = (
  state: State[TState] | undefined,
  action: Actions | TAdditionalActions
) => State[TState];

For our first reducer, we'll handle actions related to the document itself:

app/redux/reducers/document-reducer.ts

import { AppReducer } from "app/redux/types/reducer-types";
import { InitialState } from "app/redux/state";

export const documentReducer: AppReducer<"Document"> = (
  state = InitialState.Document,
  action
): typeof state => {
  switch (action.type) {
    case "Editor/loaded":
      return {
        ...state,
        document: action.payload.document,
      };

    case "Document/changed":
      return {
        ...state,
        document: action.payload.document,
      };

    default:
      return state;
  };

By specifying the type argument "Document" to the AppReducer type, we'll safely type the state. In addition, by using our AppReducer, the actions will be typed appropriately to what we defined earlier. We'll have auto-completion in our editor when building out our case statements. Note that any reducer has access to all action types, which allow changes across different sections of state to appropriately handle our actions.

Now, we'll define another reducer:

app/redux/reducers/editor-reducer.ts

import { AppReducer } from "app/redux/types/reducer-types";
import { InitialState } from "app/redux/state";

export const editorReducer: AppReducer<"Editor"> = (
  state = InitialState.Editor,
  action
  ): typeof state => {
    switch (action.type) {
      case "Editor/loaded":
        return {
          ...state,
          isLoaded: true,
          isDirty: false,
        };

      case "Document/changed":
        return {
          ...state,
          isDirty: true,
        };

      case "Editor/saved":
        return {
          ...state,
          isDirty: false,
      }

      default:
        return state;
      }
  };

Then we'll combine our reducers together:

app/redux/reducers/index.ts:

import { documentReducer } from "app/redux/reducers/document-reducer";
import { editorReducer } from "app/redux/reducers/editor-reducer";

export const Reducer = combineReducers({
  Document: documentReducer,
  Editor: editorReducer,
});

5. Selectors

Next, we'll create some simple selectors to get the document and editor state:

app/redux/selectors/index.ts

import { State } from "app/redux/state";

export const Selectors = {
  isLoaded: (state: State) => state.editor.isLoaded,
  canSave: (state: State) => !state.editor.isDirty,
  document: (state: State) => state.document.document,
};

6. Store

Finally, we'll configure Redux with our state and reducers:

app/redux/store/index.ts

import * as Redux from "redux";
import Thunk, { ThunkMiddleware } from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";

import * as Reducers from "app/redux/reducers";
import { State } from "app/redux/state";

export const configureStore = (state?: Partial<State>) => {
  return Redux.createStore(
    Reducers.Reducer,
    (state as any) || {},
    composeWithDevTools(
      Redux.applyMiddleware(
        Thunk as ThunkMiddleware<State, Action<Actions["type"]>>,
      )
    )
  );
};

Usage

Now we can use our new simple Redux setup with hooks:

app/editor/index.ts

import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Actions } from "app/redux/actions";

export const Editor: React.FC = () => {
  const dispatch = useDispatch();

  const document = useSelector(Selectors.document);
  const isLoaded = useSelector(Selectors.isLoaded);
  const canSave = useSelector(Selectors.canSave);

  const handleLoadButtonClicked = React.useCallback(() => {
    dispatch(loadDocument);
  }, [dispatch]);

  const handleSaveButtonClicked = React.useCallback(() => {
    dispatch(saveDocument);
  }, [dispatch]);

  const handleDocumentChanged = React.useCallback((value: string) => {
    dispatch(Actions.Document.changed(value));
  }, [dispatch]);

  return (
    <>
      <button onClick={handleLoadButtonClicked} disabled={isLoaded}>
        Load
      </button>
      <button onClick={handleSaveButtonClicked} disabled={!canSave}>
        Save
      </button>

      <textarea onChange={handleDocumentChanged}>{document}</textarea>
    </>
  );
};