React Redux just released version 7.1.0 which includes React hooks (FINALLY!!).
I may take this opportunity to write about how to write a type-safe React Redux store with TypeScript (No more any
).
It will be how I will implement it. There may be some better way to do it.
Here it will be what you get in the final result.
Prerequisite
- Knowledge about TypeScript, React, React Redux
- A working TypeScript React project
Basic definitions
First, we need to create some basic definitions which is needed no matter how we define our store.
// src/store/type.ts
import { Action as ReduxAction, Store as ReduxStore } from "redux";
import { ThunkAction, ThunkDispatch } from "redux-thunk";
type AnyFunction = (...args: any[]) => any;
type StringMap<T> = { [key: string]: T };
export type Action<T extends string = string, P = void> = P extends void
? ReduxAction<T>
: ReduxAction<T> & Readonly<{ payload: P }>;
export type ActionsUnion<A extends StringMap<AnyFunction>> = ReturnType<
A[keyof A]
>;
export type State = {};
export type Store = ReduxStore<State, Action> & {
dispatch: Dispatch;
};
export type Dispatch = ThunkDispatch<State, void, Action>;
export type Actions = undefined;
export type DispatchAction<T = void> = ThunkAction<
Promise<T>,
State,
void,
Action
>;
// src/store/action.ts
import { Action } from "./type";
function isPayloadAction<T extends string, P>(action: {
type: T;
payload?: P;
}): action is Action<T, P> {
return action.payload !== undefined;
}
export function createAction<T extends string>(type: T): Action<T>;
export function createAction<T extends string, P>(
type: T,
payload: P
): Action<T, P>;
export function createAction<T extends string, P>(
type: T,
payload?: P
): Action<T> | Action<T, P> {
const action = { type, payload };
return isPayloadAction(action) ? action : { type };
}
// src/store/reducer.ts
import { combineReducers, Reducer } from "redux";
import { Actions, State } from "./type";
export const reducer: Reducer<State, Actions> = combineReducers({});
// src/store/provider.tsx
import React, { ReactNode } from "react";
import { applyMiddleware, compose, createStore } from "redux";
import { Provider } from "react-redux";
import thunkMiddleware from "redux-thunk";
import { reducer } from "./reducer";
import { Store } from "./type";
const composeEnhancers =
process.env.NODE_ENV === "development"
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
: compose;
const store: Store = createStore(
reducer,
composeEnhancers(applyMiddleware(thunkMiddleware))
);
type Props = {
children: ReactNode;
};
export function StoreProvider(props: Props): JSX.Element {
const { children } = props;
return <Provider store={store}>{children}</Provider>;
}
// src/store/index.ts
export { StoreProvider } from "./provider";
That is a lot of code. Let's explain what we have done.
type.ts contains only types which will used for typings for others.
Action
is a Redux action which containstype
andpayload
.payload
may not exist on some actions.ActionsUnion
is all the actions of a reducer.State
is the type of the Redux store.Actions
is all the possible action types of Redux Store.
action.ts contains implementation on creating a Redux action. You can see that createAction
is overloaded.
Basically, it is a utility function for creating actions.
reducer.ts contains the root reducer of the Redux store.
provider.tsx contains the Redux store provider for wrapping the application.
It uses redux-thunk to allow async operation when dispatching actions.
Also, __REDUX_DEVTOOLS_EXTENSION_COMPOSE__
is to allow Redux Chrome extension for debugging.
Reducer Definitions
After creating basic definitions, it is time to create some reducers. I prefer separating reducers to their own folder.
src/
└── store/
├── action.ts
├── index.ts
├── provider.tsx
├── reducer.ts
├── type.ts
├── reducer1/
│ ├── action.ts
│ ├── index.ts
│ ├── reducer.ts
│ └── select.ts
└── reducer2/
├── action.ts
├── index.ts
├── reducer.ts
└── select.ts
Here are the definitions.
// src/store/reducer1/action.ts
import { ActionsUnion, DispatchAction } from "../type";
import { createAction } from "../action";
export enum ActionTypes {
Action1 = "Action1",
Action2 = "Action2"
}
export type Action1Options = {
foo: string;
};
export const Actions = {
action1: (options: Action1Options) =>
createAction(ActionTypes.Action1, options),
action2: () => createAction(ActionTypes.Action2)
};
export type Actions = ActionsUnion<typeof Actions>;
export function action1(options: Action1Options): DispatchAction {
return async dispatch => {
dispatch(Actions.action1(options));
};
}
export function action2(): DispatchAction {
return async dispatch => {
const response = await fetch("https://example.com"); // some async action
dispatch(Actions.action2());
};
}
// src/store/reducer1/reducer.ts
import { Actions } from "../type";
import { ActionTypes } from "./action";
export type Reducer1State = {
foo?: string;
};
const initialState: Readonly<Reducer1State> = {};
export function reducer(
state: Reducer1State = initialState,
action: Actions
): Reducer1State {
switch (action.type) {
case ActionTypes.Action1: {
const { foo } = action.payload;
return { foo };
}
case ActionTypes.Action2:
return { ...initialState };
default:
return state;
}
}
// src/store/reducer1/select.ts
import { createSelector } from "reselect";
import { State } from "../type";
import { Reducer1State } from "./reducer";
export function selectReducer1State(state: State): Reducer1State {
return state.reducer1;
}
export const selectFoo = createSelector(
selectReducer1State,
state => state.foo
);
// src/store/reducer1/index.ts
export * from "./action";
export { reducer } from "./reducer";
export * from "./select";
You will see some error because we have not update the definitions in src/store/type.ts and src/store/reducer.ts.
// src/store/type.ts
import { Action as ReduxAction, Store as ReduxStore } from "redux";
import { ThunkAction, ThunkDispatch } from "redux-thunk";
import * as reducer1 from "./reducer1";
// Skipped some code
export type State = {
reducer1: ReturnType<typeof reducer1.reducer>;
};
// Skipped some code
/*
If you have more than 1 reducer, you write like the following:
export type Actions =
| reducer1.Actions
| reducer2.Actions;
*/
export type Actions = reducer1.Actions;
// Skipped some code
// src/store/reducer.ts
import { combineReducers, Reducer } from "redux";
import { Actions, State } from "./type";
import { reducer as reducer1 } from "./reducer1";
export const reducer: Reducer<State, Actions> = combineReducers({ reducer1 });
Let's see what we have done.
action.ts
contains the codes about Redux action.
ActionTypes
are thetype
of Redux action. It must be unique in your Redux store.Actions
are the collection of action factories of this reducer.action1
&action2
are implementations on what the actions are doing when they are dispatched. They will be used for dispatching.
reducer.ts
contains the reducer implementation.
select.ts
contains the selector of the reducer. I recommend to use this pattern. It provides 2 benefits.
- Hide the implementation details of a reducer. When you select a value from Redux store, you want to use
selectFoo
instead ofstate.reducer1.foo
. This allow you to change the structure of the reducer with affect other part of the code. It also provide a central place for you to manipulate the value (e.g. provide default value, mapping the value). - It uses reselect which cached the result based on the parameters of the selector. For example, if the values in the state is not changed, it will return the cached result.
Usage
It is just standard way to use React Redux but we can now have type safety.
// src/component/component1/index.tsx
import React from "react";
import { connect } from "react-redux";
import { Dispatch, State } from "../../store/type";
import { action1, Action1Options } from "../../store/redcuer1";
const mapState = (state: State) => ({
foo: selectFoo(state)
});
const mapDispatch = (dispatch: Dispatch) => ({
action1: (options: Action1Options) => dispatch(action1(options))
});
type ComponentProps = { text: string };
type StateProps = ReturnType<typeof mapState>;
type DispatchProps = ReturnType<typeof mapDispatch>;
type Props = ComponentProps & StateProps & DispatchProps;
function Component1Impl(props: Props): JSX.Element {
const { text, foo, action1 } = props;
// Do anything you want
return <button>{text}</button>;
}
export const Component1 = connect<
StateProps,
DispatchProps,
ComponentProps,
State
>(
mapState,
mapDispatch
)(Component1Impl);
React Hook
Who don't want to use the latest features, let see how to implement React hook.
// src/store/reducer1/hook.ts
import { useSelector } from "react-redux";
import { selectFoo } from "./select";
export const useFoo = () => useSelector(selectFoo);
// src/store/reducer1/index.ts
export * from "./action";
export { reducer } from "./reducer";
export * from "./select";
export * from "./hook";
// src/component/component1/index.tsx
import React, { useCallback } from "react";
import { useDispatch } from "react-redux";
import { Dispatch, State } from "../../store/type";
import { action1, useFoo } from "../../store/redcuer1";
type Props = { text: string };
export function Component1(props: Props): JSX.Element {
const { text } = props;
const dispatch = useDispatch<Dispatch>();
const foo = useFoo();
const callback = useCallback(() => dispatch(action1("bar")), [dispatch]);
// Do anything you want
return <button>{text}</button>;
}
You can see it has much clearer code with React hook than connect
.