Strongly-typed React Redux Code with TypeScript
Redux is a popular library used to manage state in React apps. How can we make our Redux code strongly-typed with TypeScript - particularly when we have asynchronous code in the mix? Let’s find out by going through an example of a store that manages a list of people …
UPDATE: A more up-to-date post on React, Redux and TypeScript can be found here.
State
Let’s start with the stores state object:
interface IPeopleState {
readonly people: IPerson[];
readonly loading: boolean;
readonly posting: boolean;
}
export interface IAppState {
readonly peopleState: IPeopleState;
}
const initialPeopleState: IPeopleState = {
people: [],
loading: false,
posting: false,
};
So, our app just contains an array of people. We have flags in the state to indicate when people are being loaded from the server and when a new person is being posted to the server. We’ve declared the state as readonly
so that we don’t accidentally directly mutate the state in our code.
Actions
A change to state is initiated by an action. We have 4 actions in our example:
- GettingPeople. This is triggered when a request is made to get the array of people from the server
- GotPeople. This is triggered when the response has been received with the array of people from the server
- PostingPerson. This is triggered when a request is made to the server to add a new person
- PostedPerson. This is triggered when the response has been received from the server for the new person
Here’s the code:
export interface IGettingPeopleAction
extends Action<"GettingPeople"> {}
export interface IGotPeopleAction
extends Action<"GotPeople"> {
people: IPerson[];
}
export interface IPostingPersonAction
extends Action<"PostingPerson"> {
type: "PostingPerson";
}
export interface IPostedPersonAction
extends Action<"PostedPerson"> {
result: IPostPersonResult;
}
export type PeopleActions =
| IGettingPeopleAction
| IGotPeopleAction
| IPostingPersonAction
| IPostedPersonAction;
So, our action types extend the generic Action
type which is in the core Redux library, passing in the string literal that the type
property should have. This ensures we set the type
property correctly when consuming the actions in our code.
Notice the PeopleActions
union type that references all 4 actions. We’ll later use this in the reducer to ensure we are interacting with the correct actions.
Action creators
Actions creators do what they say on the tin and we have 2 in our example. Our action creator is asynchronous in our example, so, we are using Redux Thunk. This is where the typing gets a little tricky …
The first action creator gets the people array from the server asynchronously dispatching 2 actions along the way:
export const getPeopleActionCreator: ActionCreator<ThunkAction<
// The type of the last action to be dispatched - will always be promise<T> for async actions
Promise<IGotPeopleAction>,
// The type for the data within the last action
IPerson[],
// The type of the parameter for the nested function
null,
// The type of the last action to be dispatched
IGotPeopleAction
>> = () => {
return async (dispatch: Dispatch) => {
const gettingPeopleAction: IGettingPeopleAction = {
type: "GettingPeople",
};
dispatch(gettingPeopleAction);
const people = await getPeopleFromApi();
const gotPeopleAction: IGotPeopleAction = {
people,
type: "GotPeople",
};
return dispatch(gotPeopleAction);
};
};
ActionCreator
is a generic type from the core Redux library that takes in the type to be returned from the action creator. Our action creator returns a function that will eventually return IGotPeopleAction
. We use the generic ThunkAction
from the Redux Thunk library for the type of the nested asynchronous function which has 4 parameters that have commented explanations.
The second action creator is similar but this time the asynchronous function that calls the server has a parameter:
export const postPersonActionCreator: ActionCreator<ThunkAction<
// The type of the last action to be dispatched - will always be promise<T> for async actions
Promise<IPostedPersonAction>,
// The type for the data within the last action
IPostPersonResult,
// The type of the parameter for the nested function
IPostPerson,
// The type of the last action to be dispatched
IPostedPersonAction
>> = (person: IPostPerson) => {
return async (dispatch: Dispatch) => {
const postingPersonAction: IPostingPersonAction = {
type: "PostingPerson",
};
dispatch(postingPersonAction);
const result = await postPersonFromApi(
person
);
const postPersonAction: IPostedPersonAction = {
type: "PostedPerson",
result,
};
return dispatch(postPersonAction);
};
};
So, the typing is fairly tricky and there may well be an easier way!
Reducers
The typing for the reducer is a little more straightforward but has some interesting bits:
const peopleReducer: Reducer<
IPeopleState,
PeopleActions
> = (state = initialPeopleState, action) => {
switch (action.type) {
case "GettingPeople": {
return {
...state,
loading: true,
};
}
case "GotPeople": {
return {
...state,
people: action.people,
loading: false,
};
}
case "PostingPerson": {
return {
...state,
posting: true,
};
}
case "PostedPerson": {
return {
...state,
posting: false,
people: state.people.concat(
action.result.person
),
};
}
default:
neverReached(action); // when a new action is created, this helps us remember to handle it in the reducer
}
return state;
};
// tslint:disable-next-line:no-empty
const neverReached = (never: never) => {};
const rootReducer = combineReducers<IAppState>({
peopleState: peopleReducer,
});
We use the generic Reducer
type from the core Redux library passing in our state type along with the PeopleActions
union type.
The switch
statement on the action type
property is strongly-typed, so, if we mistype a value, a compilation error will be raised. The action
argument within the branches within the switch
statement has its type narrowed to the specific action that is relevant to the branch.
Notice that we use the never
type in the default
switch
branch to signal to the TypeScript compiler that it shouldn’t be possible to reach this branch. This is useful as our app grows and need to implement new actions because it will remind us to handle the new action in the reducer.
Store
Typing the store is straightforward. We use the generic Store
type from the core Redux library passing in type of our app state which is IAppState
in our example:
export function configureStore(): Store<
IAppState
> {
const store = createStore(
rootReducer,
undefined,
applyMiddleware(thunk)
);
return store;
}
Connecting components
Moving on to connecting components now. Our example component is a function-based and uses the super cool useEffect
hook to load the people array when the component has mounted:
interface IProps {
getPeople: () => Promise<IGotPeopleAction>;
people: IPerson[];
peopleLoading: boolean;
postPerson: (
person: IPostPerson
) => Promise<IPostedPersonAction>;
personPosting: boolean;
}
const App: FC<IProps> = ({
getPeople,
people,
peopleLoading,
postPerson,
personPosting,
}) => {
useEffect(() => {
getPeople();
}, []);
const handleClick = () => {
postPerson({
name: "Tom",
});
};
return (
<div>
{peopleLoading && <div>Loading...</div>}
<ul>
{people.map((person) => (
<li key={person.id}>{person.name}</li>
))}
</ul>
{personPosting ? (
<div>Posting...</div>
) : (
<button onClick={handleClick}>
Add
</button>
)}
</div>
);
};
const mapStateToProps = (store: IAppState) => {
return {
people: store.peopleState.people,
peopleLoading: store.peopleState.loading,
personPosting: store.peopleState.posting,
};
};
const mapDispatchToProps = (
dispatch: ThunkDispatch<any, any, AnyAction>
) => {
return {
getPeople: () =>
dispatch(getPeopleActionCreator()),
postPerson: (person: IPostPerson) =>
dispatch(postPersonActionCreator(person)),
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);
The mapStateToProps
function is straightforward and uses our IAppState
type so that the references to the stores state is strongly-typed.
The mapDispatchToProps
function is tricky and is more loosely-typed. The function takes in the dispatch function but in our example we are dispatching action creators that are asynchronous. So, we are using the generic ThunkDispatch
type from the Redux Thunk core library which takes in 3 parameters for the asynchronous function result type, asynchronous function parameter type as well as the last action created type. However, we are using dispatch
for 2 different action creators that have different types. This is why we pass the any
type to ThunkDispatch
and AnyAction
for the action type.
Wrap up
We can make our redux code strongly-typed in a fairly straightforward manner. The way that TypeScript narrows the action type in reducers is really smart and the use of never
is a nice touch. Typing asynchronous action creators is a bit of a challenge and there may well be a better approach but it does the job pretty well.
If you to learn more about using TypeScript with React, you may find my course useful: