跳到主要内容

Migrating to Modern Redux

What You'll Learn
  • How to modernize legacy "hand-written" Redux logic to use Redux Toolkit
  • How to modernize legacy React-Redux connect components to use the hooks API
  • How to modernize Redux logic and React-Redux components that use TypeScript

Overview

Redux has been around since 2015, and our recommended patterns for writing Redux code have changed significantly over the years. In the same way that React has evolved from createClass to React.Component to function components with hooks, Redux has evolved from manual store setup + hand-written reducers with object spreads + React-Redux's connect, to Redux Toolkit's configureStore + createSlice + React-Redux's hooks API.

Many users are working on older Redux codebases that have been around since before these "modern Redux" patterns existed. Migrating those codebases to today's recommended modern Redux patterns will result in codebases that are much smaller and easier to maintain.

The good news is that you can migrate your code to modern Redux incrementally, piece by piece, with old and new Redux code coexisting and working together!

This page covers the general approaches and techniques you can use to modernize an existing legacy Redux codebase.

信息

For more details on how "modern Redux" with Redux Toolkit + React-Redux hooks simplifies using Redux, see these additional resources:

Modernizing Redux Logic with Redux Toolkit

The general approach to migrating Redux logic is:

  • Replace the existing manual Redux store setup with Redux Toolkit's configureStore
  • Pick an existing slice reducer and its associated actions. Replace those with RTK's createSlice. Repeat for one reducer at a time.
  • As needed, replace existing data fetching logic with RTK Query or createAsyncThunk
  • Use RTK's other APIs like createListenerMiddleware or createEntityAdapter as needed

You should always start by replacing the legacy createStore call with configureStore. This is a one-time step, and all of the existing reducers and middleware will continue to work as-is. configureStore includes development-mode checks for common mistakes like accidental mutations and non-serializable values, so having those in place will help identify any areas of the codebase where those mistakes are happening.

信息

You can see this general approach in action in Redux Fundamentals, Part 8: Modern Redux with Redux Toolkit.

Store Setup with configureStore

A typical legacy Redux store setup file does several different steps:

  • Combining the slice reducers into the root reducer
  • Creating the middleware enhancer, usually with the thunk middleware, and possibly other middleware in development mode such as redux-logger
  • Adding the Redux DevTools enhancer, and composing the enhancers together
  • Calling createStore

Here's what those steps might look like in an existing application:

src/app/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import thunk from 'redux-thunk'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer
})

const middlewareEnhancer = applyMiddleware(thunk)

const composeWithDevTools =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const composedEnhancers = composeWithDevTools(middlewareEnhancer)

const store = createStore(rootReducer, composedEnhancers)

All of those steps can be replaced with a single call to Redux Toolkit's configureStore API.

RTK's configureStore wraps around the original createStore method, and handles most of the store setup for us automatically. In fact, we can cut it down to effectively one step:

Basic Store Setup: src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

// Automatically adds the thunk middleware and the Redux DevTools extension
const store = configureStore({
// Automatically calls `combineReducers`
reducer: {
posts: postsReducer,
users: usersReducer
}
})

That one call to configureStore did all the work for us:

  • It called combineReducers to combine postsReducer and usersReducer into the root reducer function, which will handle a root state that looks like {posts, users}
  • It called createStore to create a Redux store using that root reducer
  • It automatically added the thunk middleware and called applyMiddleware
  • It automatically added more middleware to check for common mistakes like accidentally mutating the state
  • It automatically set up the Redux DevTools Extension connection

If your store setup requires additional steps, such as adding additional middleware, passing in an extra argument to the thunk middleware, or creating a persisted root reducer, you can do that as well. Here's a larger example that shows customizing the built-in middleware and turning on Redux-Persist, which demonstrates some of the options for working with configureStore:

Detailed Example: Custom Store Setup with Persistence and Middleware

This example shows several possible common tasks when setting up a Redux store:

  • Combining the reducers separately (sometimes needed due to other architectural constraints)
  • Adding additional middleware, both conditionally and unconditionally
  • Passing an "extra argument" into the thunk middleware, such as an API service layer
  • Using the Redux-Persist library, which requires special handling for its non-serializable action types
  • Turning the devtools off in prod, and setting additional devtools options in development

None of these are required, but they do show up frequently in real-world codebases.

Custom Store Setup: src/app/store.js
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import { PersistGate } from 'redux-persist/integration/react'
import logger from 'redux-logger'

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import { api } from '../features/api/apiSlice'
import { serviceLayer } from '../features/api/serviceLayer'

import stateSanitizerForDevtools from './devtools'
import customMiddleware from './someCustomMiddleware'

// Can call `combineReducers` yourself if needed
const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer,
[api.reducerPath]: api.reducer
})

const persistConfig = {
key: 'root',
version: 1,
storage
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
// Pass previously created persisted reducer
reducer: persistedReducer,
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
// Pass in a custom `extra` argument to the thunk middleware
thunk: {
extraArgument: { serviceLayer }
},
// Customize the built-in serializability dev check
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
}).concat(customMiddleware, api.middleware)

// Conditionally add another middleware in dev
if (process.env.NODE_ENV !== 'production') {
middleware.push(logger)
}

return middleware
},
// Turn off devtools in prod, or pass options in dev
devTools:
process.env.NODE_ENV === 'production'
? false
: {
stateSanitizer: stateSanitizerForDevtools
}
})

Reducers and Actions with createSlice

A typical legacy Redux codebase has its reducer logic, action creators, and action types spread across separate files, and those files are often in separate folders by type. The reducer logic is written using switch statements and hand-written immutable update logic with object spreads and array mapping:

src/constants/todos.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
src/actions/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

export const addTodo = (id, text) => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = id => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO: {
return state.concat({
id: action.id,
text: action.text,
completed: false
})
}
case TOGGLE_TODO: {
return state.map(todo => {
if (todo.id !== action.id) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
default:
return state
}
}

Redux Toolkit's createSlice API was designed to eliminate all the "boilerplate" with writing reducers, actions, and immutable updates!

With Redux Toolkit, there's multiple changes to that legacy code:

  • createSlice will eliminate the hand-written action creators and action types entirely
  • All of the uniquely-named fields like action.text and action.id get replaced by action.payload, either as an individual value or an object containing those fields
  • The hand-written immutable updates are replaced by "mutating" logic in reducers thanks to Immer
  • There's no need for separate files for each type of code
  • We teach having all logic for a given reducer in a single "slice" file
  • Instead of having separate folders by "type of code", we recommend organizing files by "features", with related code living in the same folder
  • Ideally, the naming of the reducers and actions should use the past tense and describe "a thing that happened", rather than an imperative "do this thing now", such as todoAdded instead of ADD_TODO

Those separate files for constants, actions, and reducers, would all be replaced by a single "slice" file. The modernized slice file would look like this:

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// Give case reducers meaningful past-tense "event"-style names
todoAdded(state, action) {
const { id, text } = action.payload
// "Mutating" update syntax thanks to Immer, and no `return` needed
state.todos.push({
id,
text,
completed: false
})
},
todoToggled(state, action) {
// Look for the specific nested object to update.
// In this case, `action.payload` is the default field in the action,
// and can hold the `id` value - no need for `action.id` separately
const matchingTodo = state.todos.find(todo => todo.id === action.payload)

if (matchingTodo) {
// Can directly "mutate" the nested object
matchingTodo.completed = !matchingTodo.completed
}
}
}
})

// `createSlice` automatically generated action creators with these names.
// export them as named exports from this "slice" file
export const { todoAdded, todoToggled } = todosSlice.actions

// Export the slice reducer as the default export
export default todosSlice.reducer

When you call dispatch(todoAdded('Buy milk')), whatever single value you pass to the todoAdded action creator will automatically get used as the action.payload field. If you need to pass in multiple values, do so as an object, like dispatch(todoAdded({id, text})). Alternately, you can use the "prepare" notation inside of a createSlice reducer to accept multiple separate arguments and create the payload field. The prepare notation is also useful for cases where the action creators were doing additional work, such as generating unique IDs for each item.

While Redux Toolkit does not specifically care about your folder and file structures or action naming, these are the best practices we recommend because we've found they lead to more maintainable and understandable code.

Data Fetching with RTK Query

Typical legacy data fetching in a React+Redux app requires many moving pieces and types of code:

  • Action creators and action types that represent "request starting", "request succeeded", and "request failed" actions
  • Thunks to dispatch the actions and make the async request
  • Reducers that track loading status and store the cached data
  • Selectors to read those values from the store
  • Dispatching the thunk in a component after mounting, either via componentDidMount in a class component or useEffect in a function component

These typically would be split across many different files:

src/constants/todos.js
export const FETCH_TODOS_STARTED = 'FETCH_TODOS_STARTED'
export const FETCH_TODOS_SUCCEEDED = 'FETCH_TODOS_SUCCEEDED'
export const FETCH_TODOS_FAILED = 'FETCH_TODOS_FAILED'
src/actions/todos.js
import axios from 'axios'
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

export const fetchTodosStarted = () => ({
type: FETCH_TODOS_STARTED
})

export const fetchTodosSucceeded = todos => ({
type: FETCH_TODOS_SUCCEEDED,
todos
})

export const fetchTodosFailed = error => ({
type: FETCH_TODOS_FAILED,
error
})

export const fetchTodos = () => {
return async dispatch => {
dispatch(fetchTodosStarted())

try {
// Axios is common, but also `fetch`, or your own "API service" layer
const res = await axios.get('/todos')
dispatch(fetchTodosSucceeded(res.data))
} catch (err) {
dispatch(fetchTodosFailed(err))
}
}
}
src/reducers/todos.js
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_STARTED: {
return {
...state,
status: 'loading'
}
}
case FETCH_TODOS_SUCCEEDED: {
return {
...state,
status: 'succeeded',
todos: action.todos
}
}
case FETCH_TODOS_FAILED: {
return {
...state,
status: 'failed',
todos: [],
error: action.error
}
}
default:
return state
}
}
src/selectors/todos.js
export const selectTodosStatus = state => state.todos.status
export const selectTodos = state => state.todos.todos
src/components/TodosList.js
import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchTodos } from '../actions/todos'
import { selectTodosStatus, selectTodos } from '../selectors/todos'

export function TodosList() {
const dispatch = useDispatch()
const status = useSelector(selectTodosStatus)
const todos = useSelector(selectTodos)

useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])

// omit rendering logic here
}

Many users may be using the redux-saga library to manage data fetching, in which case they might have additional "signal" action types used to trigger the sagas, and this saga file instead of thunks:

src/sagas/todos.js
import { put, takeEvery, call } from 'redux-saga/effects'
import {
FETCH_TODOS_BEGIN,
fetchTodosStarted,
fetchTodosSucceeded,
fetchTodosFailed
} from '../actions/todos'

// Saga to actually fetch data
export function* fetchTodos() {
yield put(fetchTodosStarted())

try {
const res = yield call(axios.get, '/todos')
yield put(fetchTodosSucceeded(res.data))
} catch (err) {
yield put(fetchTodosFailed(err))
}
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* fetchTodosSaga() {
yield takeEvery(FETCH_TODOS_BEGIN, fetchTodos)
}

All of that code can be replaced with Redux Toolkit's "RTK Query" data fetching and caching layer!

RTK Query replaces the need to write any actions, thunks, reducers, selectors, or effects to manage data fetching. (In fact, it actually uses all those same tools internally.) Additionally, RTK Query takes care of tracking loading state, deduplicating requests, and managing cache data lifecycles (including removing expired data that is no longer needed).

To migrate, set up a single RTK Query "API slice" definition and add the generated reducer + middleware to your store:

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/'
}),
endpoints: build => ({})
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

// Import the API object
import { api } from '../features/api/apiSlice'
// Import any other slice reducers as usual here
import usersReducer from '../features/users/usersSlice'

export const store = configureStore({
reducer: {
// Add the generated RTK Query "API slice" caching reducer
[api.reducerPath]: api.reducer,
// Add any other reducers
users: usersReducer
},
// Add the RTK Query API middleware
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(api.middleware)
})

Then, add "endpoints" that represents the specific data you want to fetch and cache, and export the auto-generated React hooks for each endpoint:

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/'
}),
endpoints: build => ({
// A query endpoint with no arguments
getTodos: build.query({
query: () => '/todos'
}),
// A query endpoint with an argument
userById: build.query({
query: userId => `/users/${userId}`
}),
// A mutation endpoint
updateTodo: build.mutation({
query: updatedTodo => ({
url: `/todos/${updatedTodo.id}`,
method: 'POST',
body: updatedTodo
})
})
})
})

export const { useGetTodosQuery, useUserByIdQuery, useUpdateTodoMutation } = api

Finally, use the hooks in your components:

src/features/todos/TodoList.js
import { useGetTodosQuery } from '../api/apiSlice'

export function TodoList() {
const { data: todos, isFetching, isSuccess } = useGetTodosQuery()

// omit rendering logic here
}

Data Fetching with createAsyncThunk

We specifically recommend using RTK Query for data fetching. However, some users have told us they aren't ready to make that step yet. In that case, you can at least cut down on some of the boilerplate of hand-written thunks and reducers using RTK's createAsyncThunk. It automatically generates the action creators and action types for you, calls the async function you provide to make the request, and dispatches those actions based on the promise lifecycle. The same example with createAsyncThunk might look like this:

src/features/todos/todosSlice
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
// Just make the async request here, and return the response.
// This will automatically dispatch a `pending` action first,
// and then `fulfilled` or `rejected` actions based on the promise.
// as needed based on the
const res = await axios.get('/todos')
return res.data
})

export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// any additional "normal" case reducers here.
// these will generate new action creators
},
extraReducers: builder => {
// Use `extraReducers` to handle actions that were generated
// _outside_ of the slice, such as thunks or in other slices
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
// Pass the generated action creators to `.addCase()`
.addCase(fetchTodos.fulfilled, (state, action) => {
// Same "mutating" update syntax thanks to Immer
state.status = 'succeeded'
state.todos = action.payload
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed'
state.todos = []
state.error = action.error
})
}
})

export default todosSlice.reducer

You'd also still need to write any selectors, and dispatch the fetchTodos thunk yourself in a useEffect hook.

Reactive Logic with createListenerMiddleware

Many Redux apps have "reactive"-style logic that listens for specific actions or state changes, and runs additional logic in response. These behaviors are often implemented using the redux-saga or redux-observable libraries.

These libraries are used for a wide variety of tasks. As a basic example, a saga and an epic that listen for an action, wait one second, and then dispatch an additional action might look like this:

src/sagas/ping.js
import { delay, put, takeEvery } from 'redux-saga/effects'

export function* ping() {
yield delay(1000)
yield put({ type: 'PONG' })
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* pingSaga() {
yield takeEvery('PING', ping)
}
src/epics/ping.js
import { filter, mapTo } from 'rxjs/operators'
import { ofType } from 'redux-observable'

const pingEpic = action$ =>
action$.pipe(ofType('PING'), delay(1000), mapTo({ type: 'PONG' }))
src/app/store.js
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { combineEpics, createEpicMiddleware } from 'redux-observable';

// skip reducers

import { pingEpic } from '../sagas/ping'
import { pingSaga } from '../epics/ping

function* rootSaga() {
yield pingSaga()
}

const rootEpic = combineEpics(
pingEpic
);

const sagaMiddleware = createSagaMiddleware()
const epicMiddleware = createEpicMiddleware()

const middlewareEnhancer = applyMiddleware(sagaMiddleware, epicMiddleware)

const store = createStore(rootReducer, middlewareEnhancer)

sagaMiddleware.run(rootSaga)
epicMiddleware.run(rootEpic)

The RTK "listener" middleware is designed to replace sagas and observables, with a simpler API, smaller bundle size, and better TS support.

The saga and epic examples could be replaced with the listener middleware, like this:

src/app/listenerMiddleware.js
import { createListenerMiddleware } from '@reduxjs/toolkit'

// Best to define this in a separate file, to avoid importing
// from the store file into the rest of the codebase
export const listenerMiddleware = createListenerMiddleware()

export const { startListening, stopListening } = listenerMiddleware
src/features/ping/pingSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { startListening } from '../../app/listenerMiddleware'

const pingSlice = createSlice({
name: 'ping',
initialState,
reducers: {
pong(state, action) {
// state update here
}
}
})

export const { pong } = pingSlice.actions
export default pingSlice.reducer

// The `startListening()` call could go in different files,
// depending on your preferred app setup. Here, we just add
// it directly in a slice file.
startListening({
// Match this exact action type based on the action creator
actionCreator: pong,
// Run this effect callback whenever that action is dispatched
effect: async (action, listenerApi) => {
// Listener effect functions get a `listenerApi` object
// with many useful methods built in, including `delay`:
await listenerApi.delay(1000)
listenerApi.dispatch(pong())
}
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import { listenerMiddleware } from './listenerMiddleware'

// omit reducers

export const store = configureStore({
reducer: rootReducer,
// Add the listener middleware _before_ the thunk or dev checks
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})

Migrating TypeScript for Redux Logic

Legacy Redux code that uses TypeScript typically follows very verbose patterns for defining types. In particular, many users in the community have decided to manually define TS types for each individual action, and then created "action type unions" that try to limit what specific actions can actually be passed to dispatch.

We specifically and strongly recommend against these patterns!

src/actions/todos.ts
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

// ❌ Common pattern: manually defining types for each action object
interface AddTodoAction {
type: typeof ADD_TODO
text: string
id: string
}

interface ToggleTodoAction {
type: typeof TOGGLE_TODO
id: string
}

// ❌ Common pattern: an "action type union" of all possible actions
export type TodoActions = AddTodoAction | ToggleTodoAction

export const addTodo = (id: string, text: string): AddTodoAction => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = (id: string): ToggleTodoAction => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.ts
import { ADD_TODO, TOGGLE_TODO, TodoActions } from '../constants/todos'

interface Todo {
id: string
text: string
completed: boolean
}

export type TodosState = Todo[]

const initialState: TodosState = []

export default function todosReducer(
state = initialState,
action: TodoActions
) {
switch (action.type) {
// omit reducer logic
default:
return state
}
}
src/app/store.ts
import { createStore, Dispatch } from 'redux'

import { TodoActions } from '../actions/todos'
import { CounterActions } from '../actions/counter'
import { TodosState } from '../reducers/todos'
import { CounterState } from '../reducers/counter'

// omit reducer setup

export const store = createStore(rootReducer)

// ❌ Common pattern: an "action type union" of all possible actions
export type RootAction = TodoActions | CounterActions
// ❌ Common pattern: manually defining the root state type with each field
export interface RootState {
todos: TodosState
counter: CounterState
}

// ❌ Common pattern: limiting what can be dispatched at the types level
export type AppDispatch = Dispatch<RootAction>

Redux Toolkit is designed to drastically simplify TS usage, and our recommendations include inferring types as much as possible!

Per our standard TypeScript setup and usage guidelines, start with setting up the store file to infer AppDispatch and RootState types directly from the store itself. That will correctly include any modifications to dispatch that were added by middleware, such as the ability to dispatch thunks, and update the RootState type any time you modify a slice's state definition or add more slices.

app/store.ts
import { configureStore } from '@reduxjs/toolkit'
// omit any other imports

const store = configureStore({
reducer: {
todos: todosReducer,
counter: counterReducer
}
})

// Infer the `RootState` and `AppDispatch` types from the store itself

// Inferred state type: {todos: TodosState, counter: CounterState}
export type RootState = ReturnType<typeof store.getState>

// Inferred dispatch type: Dispatch & ThunkDispatch<RootState, undefined, UnknownAction>
export type AppDispatch = typeof store.dispatch

Each slice file should declare and export a type for its own slice state. Then, use the PayloadAction type to declare the type of any action argument inside of createSlice.reducers. The generated action creators will then also have the correct type for the argument they accept, and the type of action.payload that they return.

src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Todo {
id: string
text: string
completed: boolean
}

// Declare and export a type for the slice's state
export type TodosState = Todo[]

const initialState: TodosState = []

const todosSlice = createSlice({
name: 'todos',
// The `state` argument type will be inferred for all case reducers
// from the type of `initialState`
initialState,
reducers: {
// Use `PayloadAction<YourPayloadTypeHere>` for each `action` argument
todoAdded(state, action: PayloadAction<{ id: string; text: string }>) {
// omit logic
},
todoToggled(state, action: PayloadAction<string>) {
// omit logic
}
}
})

Modernizing React Components with React-Redux

The general approach to migrating React-Redux usage in components is:

  • Migrate an existing React class component to be a function component
  • Replace the connect wrapper with uses of the useSelector and useDispatch hooks inside the component

You can do this on an individual per-component basis. Components with connect and with hooks can coexist at the same time.

This page won't cover the process of migrating class components to function components, but will focus on the changes specific to React-Redux.

Migrating connect to Hooks

A typical legacy component using React-Redux's connect API might look like this:

src/features/todos/TodoListItem.js
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

// A `mapState` function, possibly using values from `ownProps`,
// and returning an object with multiple separate fields inside
const mapStateToProps = (state, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

// Several possible variations on how you might see `mapDispatch` written:

// 1) a separate function, manual wrapping of `dispatch`
const mapDispatchToProps = dispatch => {
return {
todoDeleted: id => dispatch(todoDeleted(id)),
todoToggled: id => dispatch(todoToggled(id))
}
}

// 2) A separate function, wrapping with `bindActionCreators`
const mapDispatchToProps2 = dispatch => {
return bindActionCreators(
{
todoDeleted,
todoToggled
},
dispatch
)
}

// 3) An object full of action creators
const mapDispatchToProps3 = {
todoDeleted,
todoToggled
}

// The component, which gets all these fields as props
function TodoListItem({ todo, activeTodoId, todoDeleted, todoToggled }) {
// rendering logic here
}

// Finished with the call to `connect`
export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

With the React-Redux hooks API, the connect call and mapState/mapDispatch arguments are replaced by hooks!

  • Each individual field returned in mapState becomes a separate useSelector call
  • Each function passed in via mapDispatch becomes a separate callback function defined inside the component
src/features/todos/TodoListItem.js
import { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
todoAdded,
todoToggled,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

export function TodoListItem({ todoId }) {
// Get the actual `dispatch` function with `useDispatch`
const dispatch = useDispatch()

// Select values from the state with `useSelector`
const activeTodoId = useSelector(selectActiveTodoId)
// Use prop in scope to select a specific value
const todo = useSelector(state => selectTodoById(state, todoId))

// Create callback functions that dispatch as needed, with arguments
const handleToggleClick = () => {
dispatch(todoToggled(todoId))
}

const handleDeleteClick = () => {
dispatch(todoDeleted(todoId))
}

// omit rendering logic
}

One thing that's different is that connect optimized rendering performance by preventing the wrapped component from rendering unless its incoming stateProps+dispatchProps+ownProps had changed. The hooks cannot do that, since they're inside the component. If you need to prevent React's normal recursive rendering behavior, wrap the component in React.memo(MyComponent) yourself.

Migrating TypeScript for Components

One of the major downsides with connect is that it is very hard to type correctly, and the type declarations end up being extremely verbose. This is due to it being a Higher-Order Component, and also the amount of flexibility in its API (four arguments, all optional, each with multiple possible overloads and variations).

The community came up with multiple variations on how to handle this, with varying levels of complexity. On the low end, some usages required typing state in mapState(), and then calculating the types of all the props for the component:

Simple connect TS example
import { connect } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

type TodoListItemProps = TodoListItemOwnProps &
ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps

function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

The use of typeof mapDispatch as an object in particular was dangerous, because it would fail if thunks were included.

Other community-created patterns required significantly more overhead, including declaring mapDispatch as a function and calling bindActionCreators in order to pass through a dispatch: Dispatch<RootActions> type, or manually calculating the types of all the props received by the wrapped component and passing those as generics to connect.

One slightly-better alternative was the ConnectedProps<T> type that was added to @types/react-redux in v7.x, which enabled inferring the type of all the props that would be passed to the component from connect. This did require splitting up the call to connect into two parts for the inference to work right:

ConnectedProps<T> TS example
import { connect, ConnectedProps } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

// Call the first part of `connect` to get the function that accepts the component.
// This knows the types of the props returned by `mapState/mapDispatch`
const connector = connect(mapStateToProps, mapDispatchToProps)
// The `ConnectedProps<T> util type can extract "the type of all props from Redux"
type PropsFromRedux = ConnectedProps<typeof connector>

// The final component props are "the props from Redux" + "props from the parent"
type TodoListItemProps = PropsFromRedux & TodoListItemOwnProps

// That type can then be used in the component
function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

// And the final wrapped component is generated and exported
export default connector(TodoListItem)

The React-Redux hooks API is much simpler to use with TypeScript! Instead of dealing with layers of component wrapping, type inference, and generics, the hooks are simple functions that take arguments and return a result. All that you need to pass around are the types for RootState and AppDispatch.

Per our standard TypeScript setup and usage guidelines, we specifically teach setting up "pre-typed" aliases for the hooks, so that those have the correct types baked in, and only use those pre-typed hooks in the app.

First, set up the hooks:

src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

Then, use them in your components:

src/features/todos/TodoListItem.tsx
import { useAppSelector, useAppDispatch } from '../../app/hooks'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemProps {
todoId: string
}

function TodoListItem({ todoId }: TodoListItemProps) {
// Use the pre-typed hooks in the component
const dispatch = useAppDispatch()
const activeTodoId = useAppSelector(selectActiveTodoId)
const todo = useAppSelector(state => selectTodoById(state, todoId))

// omit event handlers and rendering logic
}

Further Information

See these docs pages and blog posts for more details: