Jordan Nelson

A Pattern for Type-Safe Redux in TypeScript

In this guide, we'll explore how to set up a type-safe Redux environment using TypeScript. Redux is a popular state management library, and when combined with TypeScript, it can provide a robust and type-safe solution for managing application state.

We'll walk through setting up Redux for an editor application. This example will be simple, focusing on storing document contents, tracking whether the document is loaded, and checking if it has been modified.

Redux Setup

1. State

Let's start by defining our application state. We'll have two sections: one for the document itself and another for meta-information used by the editor interface.

The document state is straightforward, consisting of a single string property. In a real-world scenario, this could be more complex, like a normalized object structure.

Here's how we define it:

app/redux/state/document-state.ts


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

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

The editor state tracks whether the document is loaded and if it has been modified. Here's the definition:

app/redux/state/editor-state.ts


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

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

We combine these two sections 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 to add interactivity to our application. We'll define helper types to configure our action types as we build the action creators. The ActionType type helps extract the actions from the action definitions:

app/redux/types/action-types.ts


export type ValueOf = T[keyof T];

export type ActionType = ReturnType>;

Here's 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;

We define the arguments to the action creator and the payload inline. The as const combined with the ActionType type allows us to extract types directly from the action creators, providing type safety and auto-completion in reducers without duplicated effort.

Here's another set of action creators 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;

We combine these sections into a single Actions const for use 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 = ThunkAction>;

AppThunk provides type-safe access in a Redux Thunk, giving the dispatch function knowledge of our action creators. This is useful for calling multiple dispatches or performing asynchronous operations like saving or loading a file.

3. Thunks

We'll define simple Thunks for loading and saving our document. The dispatch call enforces type safety of the actions we defined earlier.

app/redux/thunks/index.ts


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

export const loadDocument = (): AppThunk> => 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> => async (dispatch) => {
  // save document to external resource using an await

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

4. Reducers

Now, we'll define reducers to mutate our state. We'll create a type to specify the section in state we're handling, providing type safety:

app/redux/types/reducer-types.ts


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

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

Here's a reducer for actions related to the document:

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 safely type the state. Using AppReducer, the actions are typed according to our earlier definitions, providing auto-completion in our editor when building case statements. Any reducer can access all action types, allowing changes across different state sections to handle actions appropriately.

Here's 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;
  }
};

We then combine our reducers:

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

We'll create 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) => {
  return Redux.createStore(
    Reducers.Reducer,
    (state as any) || {},
    composeWithDevTools(
      Redux.applyMiddleware(
        Thunk as ThunkMiddleware>,
      )
    )
  );
};

Usage

Now we can use our 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>
    </>
  );
};

Updated and derived from the original version of the article authored by me while working at Atomic Object.