I wrote Typed Redux after using TypeScript with Redux for the first time. It was a mistake writing the article so soon after learning both technologies. Here is my updated approach.
It's nice to keep your store code in one directory tree. The structure I use:
store
├── actions.ts
├── index.ts
├── reducers.ts
└── users
├── actions.ts
├── events.ts
├── index.ts
├── reducers.ts
└── selectors.ts
In the directory tree above, users
is a resource directory.
Before diving into designing the store, it's helpful to see how the store is used. For example, to wire up the UserIndex
page of an imaginary website.
// route/UserIndex/index.tsx
// Step 1: define connected props
import { User } from "models/user"
interface StoreProps {
users: User[];
}
// Step 2: use selectors to map the needed props
import { ReduxState } from "store"
import { getRoute } from "store/route"
import { getUsersByAccount } from "store/users"
function mapState(state: ReduxState): StoreProps {
const route = getRoute(state)
const accountId = route.params["account"]
const usersByAccount = getUsersByAccount(state)
return {
users: usersByAccount[accountId],
}
}
// Step 3: connect the props and needed actions
import { connect } from "react-redux"
import { inviteUser } from "store/users"
const enhance = connect(mapState, {
inviteUser,
})
// Step 4: export the new connected component
import UserIndex from "./UserIndex"
export default enhance(UserIndex)
You can see each resource directory needs to export selectors that start with get
and action verbs. The main store module needs to export an interface defining the complete app state.
You need to define what goes in the store. The business goals of the project should define the domain models. For example a financial app could have Trade
and Quote
while a communication platform may include Person
and Discussion
.
// models/user.ts
export interface User {
id: string;
name: string;
}
I always need a lookup collection as well.
// models/lookup.ts
export type Lookup<T> = Record<string, T>
The first step is to define your reducers and selectors.
// store/users/reducers.ts
import { User } from "models/user"
import { Lookup } from "models/lookup"
export type Users = Lookup<User>
// To be continued ...
Include the new reducer state in the main app state.
// store/reducers.ts
import { Users } from "store/users/reducers"
export interface ReduxState {
users: Users;
}
Import back into the users resource directory to have type-safe selection.
// store/users/selectors.ts
import { ReduxState } from "store/reducers"
import { Users } from "store/users/reducers"
export function getUsers(state: ReduxState): Users {
return state.users
}
Test the TypeScript compiler by changing the keys or introducing a typo. You should be confident the reducer shape is reflected in the app state.
Instead of action type constants, TypeScript offers discriminated unions. By providing different type
string constants in interface definitions, we can use type-safe guard conditions.
// store/users/events.ts
import { User } from "models/user"
export interface UserSnapshot {
type: "UserSnapshot";
payload: {
user: User;
};
}
Combine every action event into one combined union type.
// store/events.ts
import { UserSnapshot } from "store/users/events"
export type ReduxAction = UserSnapshot | ...
Import the action union type into the reducer and access the payload properties safely.
// store/users/reducers.ts
...
// To be continued ...
import { ReduxAction } from "store/events"
export function users(state = {}, action: ReduxAction): Users {
if (action.type == "UserSnapshot") {
const { user } = action.payload
return {
...state,
[user.id]: user,
}
}
return state
}
Test the TypeScript compiler by removing UserSnapshot
from the union type. Add it back and try accessing a property on the action payload that doesn't exist, or exists for a different action.
You can dispatch synchronous actions by returning a ReduxAction
.
// store/users/actions.ts
import { User } from "models/user"
export function setUserSnapshot(user: User): ReduxAction {
return {
type: "UserSnapshot",
payload: {
user,
},
}
}
Most apps use async action creators. I use the redux-thunk
middleware, it's simple and works.
// store/events.ts
export interface ReduxDispatch {
(action: ReduxAction): void;
}
export interface ReduxThunk {
(dispatch: ReduxDispatch): void;
}
// store/users/actions.ts
import { ReduxThunk, ReduxDispatch } from "store/events"
export function searchUserByName(name: string): ReduxThunk {
return function(dispatch: ReduxDispatch): void {
fetch(`/api/users/${name}`)
.then(response => response.json())
.then(data => {
const user: User = {
id: data.id,
name: data.name,
}
return user
})
.then(user => {
dispatch(setUserSnapshot(user))
})
.catch(err => {
console.error(err)
})
}
}
Each resource gets its own directory within the store.
store
├── users
│ ├── actions.ts
│ ├── events.ts
│ ├── index.ts
│ ├── reducers.ts
│ └── selectors.ts
You should export the actions and selectors in the index.
// store/users/index.ts
export {
searchUserByName,
} from "./actions"
export {
getUsers,
} from "./selectors"
Refer back to the Connected Containers section. We have gone full circle.
The final step is to combine each resource reducer into the app reducer.
import { combineReducers } from "redux"
import { users } from "store/users/reducers"
const reducer = combineReducers({
users,
})
import { createStore, applyMiddleware } from "redux"
import thunk from "redux-thunk"
export const store = createStore(reducer, applyMiddleware(thunk))
export { ReduxState } from "store/reducers"
In the main entry point, wrap your root component or router in the redux store provider component.
// main.tsx
import { Provider } from "react-redux"
import { store } from "store"
import { checkAuthState } from "store/session"
function main() {
store.dispatch(checkAuthState())
const app = <Provider store={store}>
<Router />
</Provider>
ReactDOM.render(app, document.getElementById("view"))
}
It's a tedious but effective way to make sure your app data is always in the right place and up-to-date. Some final tips:
usersById
and usersByName
. reselect
to sort and memoize in your container state mapping function.If you have comments, tweet them. Thanks for reading.