Skip to main content

Redux Fundamentals, Part 8: Modern Redux with Redux Toolkit

你将学到
  • How to simplify your Redux logic using Redux Toolkit
  • Next steps for learning and using Redux

Congratulations, you've made it to the last section of this tutorial! We've got one more topic to cover before we're done.

If you'd like a reminder of what we've covered so far, take a look at this summary:

info

Recap: What You've Learned

  • Part 1: Overview:
    • what Redux is, when/why to use it, and the basic pieces of a Redux app
  • Part 2: Concepts and Data Flow:
    • How Redux uses a "one-way data flow" pattern
  • Part 3: State, Actions, and Reducers:
    • Redux state is made of plain JS data
    • Actions are objects that describe "what happened" events in an app
    • Reducers take current state and an action, and calculate a new state
    • Reducers must follow rules like "immutable updates" and "no side effects"
  • Part 4: Store:
    • The createStore API creates a Redux store with a root reducer function
    • Stores can be customized using "enhancers" and "middleware"
    • The Redux DevTools extension lets you see how your state changes over time
  • Part 5: UI and React:
    • Redux is separate from any UI, but frequently used with React
    • React-Redux provides APIs to let React components talk to Redux stores
    • useSelector reads values from Redux state and subscribes to updates
    • useDispatch lets components dispatch actions
    • <Provider> wraps your app and lets components access the store
  • Part 6: Async Logic and Data Fetching:
    • Redux middleware allow writing logic that has side effects
    • Middleware add an extra step to the Redux data flow, enabling async logic
    • Redux "thunk" functions are the standard way to write basic async logic
  • Part 7: Standard Redux Patterns:
    • Action creators encapsulate preparing action objects and thunks
    • Memoized selectors optimize calculating transformed data
    • Request status should be tracked with loading state enum values
    • Normalized state makes it easier to look up items by IDs

As you've seen, many aspects of Redux involve writing some code that can be verbose, such as immutable updates, action types and action creators, and normalizing state. There's good reasons why these patterns exist, but writing that code "by hand" can be difficult. In addition, the process for setting up a Redux store takes several steps, and we've had to come up with our own logic for things like dispatching "loading" actions in thunks or processing normalized data. Finally, many times users aren't sure what "the right way" is to write Redux logic.

That's why the Redux team created Redux Toolkit: our official, opinionated, "batteries included" toolset for efficient Redux development.

Redux Toolkit contains packages and functions that we think are essential for building a Redux app. Redux Toolkit builds in our suggested best practices, simplifies most Redux tasks, prevents common mistakes, and makes it easier to write Redux applications.

Because of this, Redux Toolkit is the standard way to write Redux application logic. The "hand-written" Redux logic you've written so far in this tutorial is actual working code, but you shouldn't write Redux logic by hand - we've covered those approaches in this tutorial so that you understand how Redux works. However, for real applications, you should use Redux Toolkit to write your Redux logic.

When you use Redux Toolkit, all the concepts that we've covered so far (actions, reducers, store setup, action creators, thunks, etc) still exist, but Redux Toolkit provides easier ways to write that code.

tip

Redux Toolkit only covers the Redux logic - we still use React-Redux to let our React components talk to the Redux store, including useSelector and useDispatch.

So, let's see how we can use Redux Toolkit to simplify the code we've already written in our example todo application. We'll primarily be rewriting our "slice" files, but we should be able to keep all the UI code the same.

Before we continue, add the Redux Toolkit package to your app:

npm install @reduxjs/toolkit

Store Setup#

We've gone through a few iterations of setup logic for our Redux store. Currently, it looks like this:

src/rootReducer.js
import { combineReducers } from 'redux'
import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'
const rootReducer = combineReducers({
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
})
export default rootReducer
src/store.js
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'
const composedEnhancer = composeWithDevTools(applyMiddleware(thunkMiddleware))
const store = createStore(rootReducer, composedEnhancer)
export default store

Notice that the setup process takes several steps. We have to:

  • Combine the slice reducers together to form the root reducer
  • Import the root reducer into the store file
  • Import the thunk middleware, applyMiddleware, and composeWithDevTools APIs
  • Create a store enhancer with the middleware and devtools
  • Create the store with the root reducer

It would be nice if we could cut down the number of steps here.

Using configureStore#

Redux Toolkit has a configureStore API that simplifies the store setup process. configureStore wraps around the Redux core createStore API, and handles most of the store setup for us automatically. In fact, we can cut it down to effectively one step:

src/store.js
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'
const store = configureStore({
reducer: {
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
}
})
export default store

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

  • It combined todosReducer and filtersReducer into the root reducer function, which will handle a root state that looks like {todos, filters}
  • It created a Redux store using that root reducer
  • It automatically added the thunk middleware
  • It automatically added more middleware to check for common mistakes like accidentally mutating the state
  • It automatically set up the Redux DevTools Extension connection

We can confirm this works by opening up our example todo application and using it. All of our existing feature code works fine! Since we're dispatching actions, dispatching thunks, reading state in the UI, and looking at the action history in the DevTools, all those pieces must be working correctly. All we've done is switched out the store setup code.

Let's see what happens now if we accidentally mutate some of the state. What if we change the "todos loading" reducer so that it directly changes the state field, instead of immutably making a copy?

src/features/todos/todosSlice
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
// ❌ WARNING: example only - don't do this in a normal reducer!
state.status = 'loading'
return state
}
default:
return state
}
}

Uh-oh. Our whole app just crashed! What happened?

Immutability check middleware error

This error message is a good thing - we caught a bug in our app! configureStore specifically added an extra middleware that automatically throws an error whenever it sees an accidental mutation of our state (in development mode only). That helps catch mistakes we might make while writing our code.

Package Cleanup#

Redux Toolkit already includes several of the packages we're using, like redux, redux-thunk, and reselect, and re-exports those APIs. So, we can clean up our project a bit.

First, we can switch our createSelector import to be from '@reduxjs/toolkit' instead of 'reselect'. Then, we can remove the separate packages we have listed in our package.json:

npm uninstall redux redux-thunk reselect

To be clear, we're still using these packages and need to have them installed. However, because Redux Toolkit depends on them, they'll be installed automatically when we install @reduxjs/toolkit, so we don't need to have the other packages specifically listed in our package.json file.

Writing Slices#

As we've added new features to our app, the slice files have gotten bigger and more complicated. In particular, the todosReducer has gotten harder to read because of all the nested object spreads for immutable updates, and we've written multiple action creator functions.

Redux Toolkit has a createSlice API that will help us simplify our Redux reducer logic and actions. createSlice does several important things for us:

  • We can write the case reducers as functions inside of an object, instead of having to write a switch/case statement
  • The reducers will be able to write shorter immutable update logic
  • All the action creators will be generated automatically based on the reducer functions we've provided

Using createSlice#

createSlice takes an object with three main options fields:

  • name: a string that will be used as the prefix for generated action types
  • initialState: the initial state of the reducer
  • reducers: an object where the keys are strings, and the values are "case reducer" functions that will handle specific actions

Let's look at a small standalone example first.

createSlice example
import { createSlice } from '@reduxjs/toolkit'
const initialState = []
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
// ✅ This "mutating" code is okay inside of createSlice!
state.push(action.payload)
},
todoToggled(state, action) {
const todo = state.find(todo => todo.id === action.payload)
todo.completed = !todo.completed
},
todosLoading(state, action) {
return {
...state,
status: 'loading'
}
}
}
})
export const { todoAdded, todoToggled, todosLoading } = todosSlice.actions
export default todosSlice.reducer

There's several things to see in this example:

  • We write case reducer functions inside the reducers object, and give them readable names
  • createSlice will automatically generate action creators that correspond to each case reducer function we provide
  • createSlice automatically returns the existing state in the default case
  • createSlice allows us to safely "mutate" our state!
  • But, we can also make immutable copies like before if we want to

The generated action creators will be available as slice.actions.todoAdded, and we typically destructure and export those individually like we did with the action creators we wrote earlier. The complete reducer function is available as slice.reducer, and we typically export default slice.reducer, again the same as before.

So what do these auto-generated action objects look like? Let's try calling one of them and logging the action to see:

console.log(todoToggled(42))
// {type: 'todos/todoToggled', payload: 42}

createSlice generated the action type string for us, by combining the slice's name field with the todoToggled name of the reducer function we wrote. By default, the action creator accepts one argument, which it puts into the action object as action.payload.

Inside of the generated reducer function, createSlice will check to see if a dispatched action's action.type matches one of the names it generated. If so, it will run that case reducer function. This is exactly the same pattern that we wrote ourselves using a switch/case statement, but createSlice does it for us automatically.

It's also worth talking about the "mutation" aspect in more detail.

Immutable Updates with Immer#

Earlier, we talked about "mutation" (modifying existing object/array values) and "immutability" (treating values as something that cannot be changed).

warning

In Redux, our reducers are never allowed to mutate the original / current state values!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

So if we can't change the originals, how do we return an updated state?

tip

Reducers can only make copies of the original values, and then they can mutate the copies.

// This is safe, because we made a copy
return {
...state,
value: 123
}

As you've seen throughout this tutorial, we can write immutable updates by hand by using JavaScript's array / object spread operators and other functions that return copies of the original values. However, writing immutable update logic by hand is hard, and accidentally mutating state in reducers is the single most common mistake Redux users make.

That's why Redux Toolkit's createSlice function lets you write immutable updates an easier way!

createSlice uses a library called Immer inside. Immer uses a special JS tool called a Proxy to wrap the data you provide, and lets you write code that "mutates" that wrapped data. But, Immer tracks all the changes you've tried to make, and then uses that list of changes to return a safely immutably updated value, as if you'd written all the immutable update logic by hand.

So, instead of this:

function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}

You can write code that looks like this:

function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}

That's a lot easier to read!

But, here's something very important to remember:

warning

You can only write "mutating" logic in Redux Toolkit's createSlice and createReducer because they use Immer inside! If you write mutating logic in reducers without Immer, it will mutate the state and cause bugs!

Immer still lets us write immutable updates by hand and return the new value ourselves if we want to. You can even mix and match. For example, removing an item from an array is often easier to do with array.filter(), so you could call that and then assign the result to state to "mutate" it:

// can mix "mutating" and "immutable" code inside of Immer:
state.todos = state.todos.filter(todo => todo.id !== action.payload)

Converting the Todos Reducer#

Let's start converting our todos slice file to use createSlice instead. We'll pick a couple specific cases from our switch statement first to show how the process works.

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'
const initialState = {
status: 'idle',
entities: {}
}
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
}
}
})
export const { todoAdded, todoToggled } = todosSlice.actions
export default todosSlice.reducer

The todos reducer in our example app is still using normalized state that is nested in a parent object, so the code here is a bit different than the miniature createSlice example we just looked at. Remember how we had to write a lot of nested spread operators to toggle that todo earlier? Now that same code is a lot shorter and easier to read.

Let's add a couple more cases to this reducer.

src/features/todos/todosSlice.js
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted(state, action) {
delete state.entities[action.payload]
}
}
})
export const { todoAdded, todoToggled, todoColorSelected, todoDeleted } =
todosSlice.actions
export default todosSlice.reducer

The action creators for todoAdded and todoToggled only need to take a single parameter, like an entire todo object or a todo ID. But, what if we need to pass in multiple parameters, or do some of that "preparation" logic we talked about like generating a unique ID?

createSlice lets us handle those situations by adding a "prepare callback" to the reducer. We can pass an object that has functions named reducer and prepare. When we call the generated action creator, the prepare function will be called with whatever parameters were passed in. It should then create and return an object that has a payload field (or, optionally, meta and error fields), matching the Flux Standard Action convention.

Here, we've used a prepare callback to let our todoColorSelected action creator accept separate todoId and color arguments, and put them together as an object in action.payload.

Meanwhile, in the todoDeleted reducer, we can use the JS delete operator to remove items from our normalized state.

We can use these same patterns to go rewrite the rest of our reducers in todosSlice.js and filtersSlice.js.

Here's how our code looks with all the slices converted:

Writing Thunks#

We've seen how we can write thunks that dispatch "loading", "request succeeded", and "request failed" actions. We had to write action creators, action types, and reducers to handle those cases.

Because this pattern is so common, Redux Toolkit has a createAsyncThunk API that will generate these thunks for us. It also generates the action types and action creators for those different request status actions, and dispatches those actions automatically based on the resulting Promise.

Using createAsyncThunk#

Let's replace our fetchTodos thunk by generating a thunk with createAsyncThunk.

createAsyncThunk accepts two arguments:

  • A string that will be used as the prefix for the generated action types
  • A "payload creator" callback function that should return a Promise. This is often written using the async/await syntax, since async functions automatically return a promise.
src/features/todos/todosSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// omit imports and state
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})
// omit exports

We pass 'todos/fetchTodos' as the string prefix, and a "payload creator" function that calls our API and returns a promise containing the fetched data. Inside, createAsyncThunk will generate three action creators and action types, plus a thunk function that automatically dispatches those actions when called. In this case, the action creators and their types are:

  • fetchTodos.pending: todos/fetchTodos/pending
  • fetchTodos.fulfilled: todos/fetchTodos/fulfilled
  • fetchTodos.rejected: todos/fetchTodos/rejected

However, these action creators and types are being defined outside of the createSlice call. We can't handle those inside of the createSlice.reducers field, because those generate new action types too. We need a way for our createSlice call to listen for other action types that were defined elsewhere.

createSlice also accepts an extraReducers option, where we can have the same slice reducer listen for other action types. This field should be a callback function with a builder parameter, and we can call builder.addCase(actionCreator, caseReducer) to listen for other actions.

So, here we've called builder.addCase(fetchTodos.pending, caseReducer). When that action is dispatched, we'll run the reducer that sets state.status = 'loading', same as it did earlier when we wrote that logic in a switch statement. We can do the same thing for fetchTodos.fulfilled, and handle the data we received from the API.

As one more example, let's convert saveNewTodo. This thunk takes the text of the new todo object as its parameter, and saves it to the server. How do we handle that?

src/features/todos/todosSlice.js
// omit imports
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit case reducers
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, (state, action) => {
const todo = action.payload
state.entities[todo.id] = todo
})
}
})
// omit exports and selectors

The process for saveNewTodo is the same as we saw for fetchTodos. We call createAsyncThunk, and pass in the action prefix and a payload creator. Inside the payload creator, we make an async API call, and return a result value.

In this case, when we call dispatch(saveNewTodo(text)), the text value will be passed in to the payload creator as its first argument.

While we won't cover createAsyncThunk in more detail here, a few other quick notes for reference:

  • You can only pass one argument to the thunk when you dispatch it. If you need to pass multiple values, pass them in a single object
  • The payload creator will receive an object as its second argument, which contains {getState, dispatch}, and some other useful values
  • The thunk dispatches the pending action before running your payload creator, then dispatches either fulfilled or rejected based on whether the Promise you return succeeds or fails

Normalizing State#

We previously saw how to "normalize" state, by keeping items in an object keyed by item IDs. This gives us the ability to look up any item by its ID without having to loop through an entire array. However, writing the logic to update normalized state by hand was long and tedious. Writing "mutating" update code with Immer makes that simpler, but there's still likely to be a lot of repetition - we might be loading many different types of items in our app, and we'd have to repeat the same reducer logic each time.

Redux Toolkit includes a createEntityAdapter API that has prebuilt reducers for typical data update operations with normalized state. This includes adding, updating, and removing items from a slice. createEntityAdapter also generates some memoized selectors for reading values from the store.

Using createEntityAdapter#

Let's replace our normalized entity reducer logic with createEntityAdapter.

Calling createEntityAdapter gives us an "adapter" object that contains several premade reducer functions, including:

  • addOne / addMany: add new items to the state
  • upsertOne / upsertMany: add new items or update existing ones
  • updateOne / updateMany: update existing items by supplying partial values
  • removeOne / removeMany: remove items based on IDs
  • setAll: replace all existing items

We can use these functions as case reducers, or as "mutating helpers" inside of createSlice.

The adapter also contains:

  • getInitialState: returns an object that looks like { ids: [], entities: {} }, for storing a normalized state of items along with an array of all item IDs
  • getSelectors: generates a standard set of selector functions

Let's see how we can use these in our todos slice:

src/features/todos/todosSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
// omit some imports
const todosAdapter = createEntityAdapter()
const initialState = todosAdapter.getInitialState({
status: 'idle'
})
// omit thunks
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit some reducers
// Use an adapter reducer function to remove a todo by ID
todoDeleted: todosAdapter.removeOne,
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
// Use an adapter function as a "mutating" update helper
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
// Use another adapter function as a reducer to add a todo
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})
// omit selectors

The different adapter reducer functions take different values depending on the function, all in action.payload. The "add" and "upsert" functions take a single item or an array of items, the "remove" functions take a single ID or array of IDs, and so on.

getInitialState allows us to pass in additional state fields that will be included. In this case, we've passed in a status field, giving us a final todos slice state of {ids, entities, status}, much like we had before.

We can also replace some of our todos selector functions as well. The getSelectors adapter function will generate selectors like selectAll, which returns an array of all items, and selectById, which returns one item. However, since getSelectors doesn't know where our data is in the entire Redux state tree, we need to pass in a small selector that returns this slice out of the whole state tree. Let's switch to using these instead. Since this is the last major change to our code, we'll include the whole todos slice file this time to see what the final version of the code looks like using Redux Toolkit:

src/features/todos/todosSlice.js
import {
createSlice,
createSelector,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
import { StatusFilters } from '../filters/filtersSlice'
const todosAdapter = createEntityAdapter()
const initialState = todosAdapter.getInitialState({
status: 'idle'
})
// Thunk functions
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted: todosAdapter.removeOne,
allTodosCompleted(state, action) {
Object.values(state.entities).forEach(todo => {
todo.completed = true
})
},
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})
export const {
allTodosCompleted,
completedTodosCleared,
todoAdded,
todoColorSelected,
todoDeleted,
todoToggled
} = todosSlice.actions
export default todosSlice.reducer
export const { selectAll: selectTodos, selectById: selectTodoById } =
todosAdapter.getSelectors(state => state.todos)
export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
selectTodos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)
export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}
const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)
export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

We call todosAdapter.getSelectors, and pass in a state => state.todos selector that returns this slice of state. From there, the adapter generates a selectAll selector that takes the entire Redux state tree, as usual, and loops over state.todos.entities and state.todos.ids to give us the complete array of todo objects. Since selectAll doesn't tell us what we're selecting, we can use ES6 destructuring syntax to rename the function to selectTodos. Similarly, we can rename selectById to selectTodoById.

Notice that our other selectors still use selectTodos as an input. That's because it's still returning an array of todo objects this whole time, no matter whether we were keeping the array as the entire state.todos, keeping it as a nested array, or storing it as a normalized object and converting to an array. Even as we've made all these changes to how we stored our data, the use of selectors allowed us to keep the rest of our code the same, and the use of memoized selectors has helped the UI perform better by avoiding unnecessary rerenders.

你学到了#

Congratulations! You've completed the "Redux Fundamentals" tutorial!

You should now have a solid understanding of what Redux is, how it works, and how to use it correctly:

  • Managing global app state
  • Keeping the state of our app as plain JS data
  • Writing action objects that describe "what happened" in the app
  • Using reducer functions that look at the current state and an action, and create a new state immutably in response
  • Reading the Redux state in our React components with useSelector
  • Dispatching actions from React components with useDispatch

In addition, you've seen how Redux Toolkit simplifies writing Redux logic, and why Redux Toolkit is the standard approach for writing real Redux applications. By seeing how to write Redux code "by hand" first, it should be clear what the Redux Toolkit APIs like createSlice are doing for you, so that you don't have to write that code yourself.

info

For more info on Redux Toolkit, including usage guides and API references, see:

Let's take one final look at the completed todo application, including all the code that's been converted to use Redux Toolkit:

And we'll do a final recap of the key points you learned in this section:

总结
  • Redux Toolkit (RTK) is the standard way to write Redux logic
    • RTK includes APIs that simplify most Redux code
    • RTK wraps around the Redux core, and includes other useful packages
  • configureStore sets up a Redux store with good defaults
    • Automatically combines slice reducers to create the root reducer
    • Automatically sets up the Redux DevTools Extension and debugging middleware
  • createSlice simplifies writing Redux actions and reducers
    • Automatically generates action creators based on slice/reducer names
    • Reducers can "mutate" state inside createSlice using Immer
  • createAsyncThunk generates thunks for async calls
    • Automatically generates a thunk + pending/fulfilled/rejected action creators
    • Dispatching the thunk runs your payload creator and dispatches the actions
    • Thunk actions can be handled in createSlice.extraReducers
  • createEntityAdapter provides reducers + selectors for normalized state
    • Includes reducer functions for common tasks like adding/updating/removing items
    • Generates memoized selectors for selectAll and selectById

Next Steps for Learning and Using Redux#

Now that you've completed this tutorial, we have several suggestions for what you should try next to learn more about Redux.

This "Fundamentals" tutorial focused on the low-level aspects of Redux: writing action types and immutable updates by hand, how a Redux store and middleware work, and why we use patterns like action creators and normalized state. In addition, our todo example app is fairly small, and not meant as a realistic example of building a full app.

However, our "Redux Essentials" tutorial specifically teaches you how to build a "real-world" type application. It focuses on "how to use Redux the right way" using Redux Toolkit, and talks about more realistic patterns that you'll see in larger apps. It covers many of the same topics as this "Fundamentals" tutorial, such as why reducers need to use immutable updates, but with a focus on building a real working application. We strongly recommend reading through the "Redux Essentials" tutorial as your next step.

At the same time, the concepts we've covered in this tutorial should be enough to get you started building your own applications using React and Redux. Now's a great time to try working on a project yourself to solidify these concepts and see how they work in practice. If you're not sure what kind of a project to build, see this list of app project ideas for some inspiration.

The Recipes section has information on a number of important concepts, like how to structure your reducers, and our Style Guide page has important information on our recommended patterns and best practices.

If you'd like to know more about why Redux exists, what problems it tries to solve, and how it's meant to be used, see Redux maintainer Mark Erikson's posts on The Tao of Redux, Part 1: Implementation and Intent and The Tao of Redux, Part 2: Practice and Philosophy.

If you're looking for help with Redux questions, come join the #redux channel in the Reactiflux server on Discord.

Thanks for reading through this tutorial, and we hope you enjoy building applications with Redux!