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.
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,
};
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 dispatch
es or performing asynchronous operations like saving or loading a file.
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());
};
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,
});
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,
};
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>,
)
)
);
};
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>
</>
);
};