A Pattern for Type-Safe Redux in TypeScript

Article summary

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>
    </>
  );
};
Conversation
  • Mark Erikson says:

    Hi, I’m a Redux maintainer. I would actually recommend changing most of the code that’s in this article.

    First, you should specifically be using our official Redux Toolkit package, which is our recommended approach for writing Redux logic:

    https://redux-toolkit.js.org

    In particular, it has a `configureStore` API that sets up the thunk middleware and the Redux DevTools extension by default, a `createSlice` API that automatically generates action creators for you, and a `createAsyncThunk` API that does the work of dispatching actions based on promises. `createSlice` also uses Immer inside, which lets you write “mutating” update logic in your reducers that gets turned into safe correct immutable updates.

    We also have an official Redux+TS template for Create-React-App, which comes with RTK already set up:

    https://github.com/reduxjs/cra-template-redux-typescript/

    Beyond that, we specifically recommend inferring the root state type based on the return value of the root reducer:

    https://redux-toolkit.js.org/usage/usage-with-typescript#getting-the-state-type

    And we recommend _against_ trying to limit the types of all actions that can be dispatched, since it doesn’t match how Redux actually works and doesn’t provide any real additional safety. Instead, each slice reducer should simply make sure that it has correct types defined for the set of actions it does know how to handle.

    Finally, there’s no value in memoizing those functions via `useCallback`, because they’re being passed directly to plain HTML buttons and inputs. It doesn’t matter if it’s a different reference or not, because there’s no optimizations going on in the children based on props comparisons.

    • Jordan Nelson Jordan Nelson says:

      Hi Mark,

      I appreciate the time you spent reading this article and writing such an extensive comment.

      I did actually start off using Redux Toolkit for the editor application I mentioned in this article. However, it was quickly evident that RTK’s reliance on Immer was a deal breaker for my use case.

      The test documents being edited in my case had 120k+ elements stored as properties in a normalized structure/object. RTK was not performant enough when handling changes to the individual elements in the document because of its reliance on the automatic diffing capability of Immer.

      I found that managing changes to the document state in the reducer via object spreads was far more performant as it only required the runtime to copy the references from the previous version of state. I wanted this example to be simple and more widely-accessible so I didn’t include those details about the specific project use case.

      I wanted to like RTK but unfortunately in this case I didn’t work out for me. I think I will re-evaluate using it in the future on a different project.

      One of the great things about software development is that there are many valid and reasonable ways to accomplish the same task. Thanks again for your comments.

  • Allforabit says:

    The article is about redux and not redux toolkit, and the code looks very clean and succinct to me. One of the nice things about redux is it’s low level and unopinionated unlike redux toolkit. Some people may prefer to use a different style and not use Immer for instance. Redux toolkit in general looks great but surely it should be treated as a sperate (albeit related) thing to redux.

  • Comments are closed.