Advanced Tutorial: Redux Toolkit in Practice
In the Intermediate Tutorial, you saw how to use Redux Toolkit in a typical basic React app, as well as how to convert some existing plain Redux code to use RTK instead. You also saw how to write "mutative" immutable updates in reducer functions, and how to write a "prepare callback" to generate an action payload.
In this tutorial, you'll see how to use Redux Toolkit as part of a larger "real world" app that is bigger than a todo list example. This tutorial will show several concepts:
- How to convert a "plain React" app to use Redux
- How async logic like data fetching fits into RTK
- How to use RTK with TypeScript
In the process, we'll look at a few examples of TypeScript techniques you can use to improve your code, and we'll see how to use the new React-Redux hooks APIs as an alternative to the traditional connect
API.
Note: This is not a complete tutorial on how to use TypeScript in general or with Redux specifically, and the examples shown here do not try to achieve 100% complete type safety. For further information, please refer to community resources such as the React TypeScript Cheatsheet and the React/Redux TypeScript Guide.
In addition, this tutorial does not mean you must convert your React app logic completely to Redux. It's up to you to decide what state should live in React components, and what should be in Redux. This is just an example of how you could convert logic to use Redux if you choose to.
The complete source code for the converted application from this tutorial is available at github.com/reduxjs/rtk-github-issues-example. We'll be walking through the conversion process as shown in this repo's history. Links to meaningful individual commits will be highlighted in quote blocks, like this:
- Commit message here
Reviewing the Starting Example Application
The example application for this tutorial is a Github Issues viewer app. It allows the user to enter the names of a Github org and repository, fetch the current list of open issues, page through the issues list, and view the contents and comments of a specific issue.
The starting commit for this application is a plain React implementation that uses function components with hooks for state and side effects like data fetching. The code is already written in TypeScript, and the styling is done via CSS Modules.
Let's start by viewing the original plain React app in action:
React Codebase Source Overview
The codebase is already laid out in a "feature folder" structure, The main pieces are:
/api
: fetching functions and TS types for the Github Issues API/app
: main<App>
component/components
: components that are reused in multiple places/features
/issueDetails:
components for the Issue Details page/issuesList
: components for the Issues List display/repoSearch
: components for the Repo Search form
/utils
: various string utility functions
Setting Up the Redux Store
Since this app doesn't yet use Redux at all, the first step is to install Redux Toolkit and React-Redux. Since this is a TypeScript app, we'll also need to add @types/react-redux
as well. Add those packages to the project via either Yarn or NPM.
Next, we need to set up the usual pieces: a root reducer function, the Redux store, and the <Provider>
to make that store available to our component tree.
In the process, we're going to set up "Hot Module Replacement" for our app. That way, whenever we make a change to the reducer logic or the component tree, Create-React-App will rebuild the app and swap the changed code into our running app, without having to completely refresh the page.
Creating the Root Reducer
First, we'll create the root reducer function. We don't have any slices yet, so it will just return an empty object.
However, we're going to want to know what the TypeScript type is for that root state object, because we need to declare what the type of the state
variable is whenever our code needs to access the Redux store state (such as in mapState
functions, useSelector
selectors, and getState
in thunks).
We could manually write a TS type with the correct types for each state slice, but we'd have to keep updating that type every time we make any change to the state structure in our slices. Fortunately, TS is usually pretty good at inferring types from the code we've already written. In this case, we can define a type that says "this type is whatever gets returned from rootReducer
", and TS will automatically figure out whatever that contains as the code is changed. If we export that type, other parts of the app can use it, and we know that it's up to date. All we have to do is use the built-in TS ReturnType
utility type, and feed in "the type of the rootReducer
function" as its generic argument.
app/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit'const rootReducer = combineReducers({})export type RootState = ReturnType<typeof rootReducer>export default rootReducer
Store Setup and HMR
Next, we'll create the store instance, including hot-reloading the root reducer. By using the module.hot
API for reloading, we can re-import the new version of the root reducer function whenever it's been recompiled, and tell the store to use the new version instead.
app/store.ts
import { configureStore } from '@reduxjs/toolkit'import rootReducer from './rootReducer'const store = configureStore({reducer: rootReducer})if (process.env.NODE_ENV === 'development' && module.hot) {module.hot.accept('./rootReducer', () => {const newRootReducer = require('./rootReducer').defaultstore.replaceReducer(newRootReducer)})}export type AppDispatch = typeof store.dispatchexport default store
The require('./rootReducer').default
looks a bit odd. That's because we're mixing CommonJS synchronous import syntax with ES modules, so the "default export" is in a object field called default
. We could probably also have used import()
and handled the reducer replacement asynchronously as well.
Provider
Rendering the Now that the store has been created, we can add it to the React component tree.
As with the root reducer, we can hot-reload the React component tree whenever a component file changes. The best way is to write a function that imports the <App>
component and renders it, call that once on startup to show the React component tree as usual, and then reuse that function any time a component is changed.
index.tsx
import React from 'react'import ReactDOM from 'react-dom'import { Provider } from 'react-redux'import store from './app/store'import './index.css'const render = () => {const App = require('./app/App').defaultReactDOM.render(<Provider store={store}><App /></Provider>,document.getElementById('root'))}render()if (process.env.NODE_ENV === 'development' && module.hot) {module.hot.accept('./app/App', render)}
Converting the Main App Display
With the main store setup done, we can now start converting the actual app logic to use Redux.
Evaluating the Existing App State
Currently, the top-level <App>
component uses React useState
hooks to store several pieces of info:
- The selected Github org and repo
- The current issues list page number
- Whether we're viewing the issues list, or the details for a specific issue
Meanwhile, the <RepoSearchForm>
component also uses state hooks to store the work-in-progress values for the controlled form inputs.
The Redux FAQ has some rules of thumb on when it makes sense to put data into Redux. In this case, it's reasonable to extract the state values from <App>
and put those into the Redux store. While there's only one component that uses them now, a larger app might have multiple components that care about those values. Since we've set up HMR, it would also be helpful to persist those values if we make future edits to the component tree.
On the other hand, while we could put the WIP form values into the Redux store, there's no real benefit to doing so. Only the <RepoSearchForm>
component cares about those values, and none of the other rules of thumb apply here. In general, most form state probably shouldn't be kept in Redux. So, we'll leave that alone.
Creating the Initial State Slices
The first step is to look at the data that is currently being kept in <App>
, and turn that into the types and initial state values for our "issues display" slice. From there, we can define reducers to update them appropriately.
Let's look at the source for the whole slice, and then break down what it's doing:
features/issuesDisplay/issuesDisplaySlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'interface CurrentDisplay {displayType: 'issues' | 'comments'issueId: number | null}interface CurrentDisplayPayload {displayType: 'issues' | 'comments'issueId?: number}interface CurrentRepo {org: stringrepo: string}type CurrentDisplayState = {page: number} & CurrentDisplay &CurrentRepolet initialState: CurrentDisplayState = {org: 'rails',repo: 'rails',page: 1,displayType: 'issues',issueId: null}const issuesDisplaySlice = createSlice({name: 'issuesDisplay',initialState,reducers: {displayRepo(state, action: PayloadAction<CurrentRepo>) {const { org, repo } = action.payloadstate.org = orgstate.repo = repo},setCurrentPage(state, action: PayloadAction<number>) {state.page = action.payload},setCurrentDisplayType(state, action: PayloadAction<CurrentDisplayPayload>) {const { displayType, issueId = null } = action.payloadstate.displayType = displayTypestate.issueId = issueId}}})export const {displayRepo,setCurrentDisplayType,setCurrentPage} = issuesDisplaySlice.actionsexport default issuesDisplaySlice.reducer
State Contents Type Declarations
The org and repo values are simple strings, and the current issues page is just a number. We will use a union of string constants to indicate if we're showing the issues list or the details of a single issue, and if it's the details, we need to know the issue ID number.
We can define types for a couple of those pieces by themselves for reuse in the action types later, and also combine them into a larger type for the entire state we plan to track.
The "current display" part requires a bit of extra work, because the type listed for the state includes a page number, but the UI won't include one when it dispatches an action to switch to the issues list. So, we define a separate type for that action's contents.
Declaring Types for Slice State and Actions
createSlice
tries to infer types from two sources:
- The state type is based on the type of the
initialState
field - Each reducer needs to declare the type of the action it expects to handle
The state type is used as the type for the state
parameter in each of the case reducers and the return type for the generated reducer function, and the action types are used for the corresponding generated action creators. (Alternately, if you define a "prepare callback" alongside a reducer, the prepare callback's arguments are used for the action creator too, and the return value from the callback must match the declared type for the action the reducer expects.)
The main type you will use when declaring action types in reducers is PayloadAction<PayloadType>
. createAction
uses this type as its return value.
Let's look at a specific reducer as an example:
setCurrentPage(state, action: PayloadAction<number>) {state.page = action.payload},
We don't have to declare a type for state
, because createSlice
already knows that this should be the same type as our initialState
: the CurrentDisplayState
type.
We declare that the action object is a PayloadAction
, where action.payload
is a number
. Then, when we assign state.page = action.payload
, TS knows that we're assigning a number to a number, and it works correctly. If we were to try calling issuesDisplaySlice.actions.setCurrentPage()
, we would need to pass a number in as the argument, because that number will become the payload in the action.
Similarly, for displayRepo(state, action: PayloadAction<CurrentRepo>)
, TS knows that action.payload
is an object with org
and repo
string fields, and we can assign them to the state. (Remember that these "mutative" assignments are only safe and possible because createSlice
uses Immer inside!)
Using the Slice Reducer
As with other examples, we then need to import and add the issues display slice reducer to our root reducer:
app/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit'+import issuesDisplayReducer from 'features/issuesDisplay/issuesDisplaySlice'-const rootReducer = combineReducers({})+const rootReducer = combineReducers({+ issuesDisplay: issuesDisplayReducer+})
Converting the Issues Display
Now that the issues display slice is hooked up to the store, we can update <App>
to use that instead of its internal component state.
We need to make three groups of changes to the App
component:
- The
useState
declarations need to be removed - The corresponding state values need to be read from the Redux store
- Redux actions need to be dispatched as the user interacts with the component
Traditionally, the last two aspects would be handled via the React-Redux connect
API. We'd write a mapState
function to retrieve the data and a mapDispatch
function to hold the action creators, pass those to connect
, get everything as props, and then call this.props.setCurrentPage()
to dispatch that action type.
However, React-Redux now has a hooks API, which allows us to interact with the store more directly. useSelector
lets us read data from the store and subscribe to updates, and useDispatch
gives us a reference to the store's dispatch
method. We'll use those throughout the rest of this tutorial.
First, we'll import the necessary functions, plus the RootState
type we declared earlier, and remove the hardcoded default org and repo strings.
app/App.tsx
import React, { useState } from 'react'+import { useSelector, useDispatch } from 'react-redux'+import { RootState } from './rootReducer'import { RepoSearchForm } from 'features/repoSearch/RepoSearchForm'import { IssuesListPage } from 'features/issuesList/IssuesListPage'import { IssueDetailsPage } from 'features/issueDetails/IssueDetailsPage'-const ORG = 'rails'-const REPO = 'rails'+import {+ displayRepo,+ setCurrentDisplayType,+ setCurrentPage+} from 'features/issuesDisplay/issuesDisplaySlice'import './App.css'
Next, at the top of App
, we'll remove the old useState
hooks, and replace them with a call to useDispatch
and useSelector
:
const App: React.FC = () => {- const [org, setOrg] = useState(ORG)- const [repo, setRepo] = useState(REPO)- const [page, setPage] = useState(1)- const [currentDisplay, setCurrentDisplay] = useState<CurrentDisplay>({- type: 'issues'- })+ const dispatch = useDispatch()+ const { org, repo, displayType, page, issueId } = useSelector(+ (state: RootState) => state.issuesDisplay+ )
We pass a "selector" function into useSelector
, which is just a function that accepts our Redux store state as its parameter and returns some result. We declare that the type of the state
argument is the RootState
type we defined over in the root reducer, so that TS knows what fields are inside state
. We can retrieve the state.issuesDisplay
slice as one piece, and destructure the result object into multiple variables inside the component.
We now have mostly the same data variables inside the component as we did before - they're just coming from the Redux store instead of useState
hooks.
The last step is to dispatch Redux actions whenever the user does something, instead of calling the useState
setters:
const setOrgAndRepo = (org: string, repo: string) => {- setOrg(org)- setRepo(repo)+ dispatch(displayRepo({ org, repo }))}const setJumpToPage = (page: number) => {- setPage(page)+ dispatch(setCurrentPage(page))}const showIssuesList = () => {- setCurrentDisplay({ type: 'issues' })+ dispatch(setCurrentDisplayType({ displayType: 'issues' }))}const showIssueComments = (issueId: number) => {- setCurrentDisplay({ type: 'comments', issueId })+ dispatch(setCurrentDisplayType({ displayType: 'comments', issueId }))}
Unlike typical connect
+ mapDispatch
usage, here we call dispatch()
directly, and do so by calling an action creator with the correct payload
value and passing the resulting action to dispatch
.
Let's see if this works!
If you're thinking "hey, this looks and behaves exactly like the previous example"... then that's great! That means we've correctly converted the first bit of logic to Redux so far. If you want to confirm that there's Redux logic running, try clicking the "Open in New Window" button and inspect the store in the Redux DevTools Extension.
Converting the Issues List Page
Our next task is to convert the <IssuesListPage>
component to fetch and store issues via Redux. Currently, <IssuesListPage>
is storing all data in useState
hooks, including the fetched issues. It fetches the issues by making an AJAX call in a useEffect
hook.
As mentioned at the start, there's nothing actually wrong with this! Having React components fetch and store their own data is totally fine. But, for the purposes of this tutorial, we want to see how the Redux conversion process looks.
Reviewing the Issues List Component
Here's the initial chunk of <IssuesListPage>
:
export const IssuesListPage = ({org,repo,page = 1,setJumpToPage,showIssueComments}: ILProps) => {const [issuesResult, setIssues] = useState<IssuesResult>({pageLinks: null,pageCount: 1,issues: []})const [numIssues, setNumIssues] = useState<number>(-1)const [isLoading, setIsLoading] = useState<boolean>(false)const [issuesError, setIssuesError] = useState<Error | null>(null)const { issues, pageCount } = issuesResultuseEffect(() => {async function fetchEverything() {async function fetchIssues() {const issuesResult = await getIssues(org, repo, page)setIssues(issuesResult)}async function fetchIssueCount() {const repoDetails = await getRepoDetails(org, repo)setNumIssues(repoDetails.open_issues_count)}try {await Promise.all([fetchIssues(), fetchIssueCount()])setIssuesError(null)} catch (err) {console.error(err)setIssuesError(err)} finally {setIsLoading(false)}}setIsLoading(true)fetchEverything()}, [org, repo, page])// omit rendering}
The useEffect
callback defines an outer async function fetchEverything()
and calls it immediately. This is because we can't declare the useEffect
callback itself as async. React expects that the return value from a useEffect
callback will be a cleanup function. Since all async functions return a Promise
automatically, React would see that Promise
instead, and that would prevent React from actually cleaning up correctly.
Inside, we define two more async functions to fetch issues and the open issues count, and call them both. We then wait for both functions to resolve successfully. (There's a few other ways we could have organized this logic, but this was sufficient for the example.)
Thinking in Thunks
What is a "Thunk"?
The Redux core (ie, createStore
) is completely synchronous. When you call store.dispatch()
, the store runs the root reducer, saves the return value, runs the subscriber callbacks, and returns, with no pause. By default, any asynchronicity has to happen outside of the store.
But, what if you want to have async logic interact with the store by dispatching or checking the current store state? That's where Redux middleware come in. They extend the store, and allow you to:
- Execute extra logic when any action is dispatched (such as logging the action and state)
- Pause, modify, delay, replace, or halt dispatched actions
- Write extra code that has access to
dispatch
andgetState
- Teach
dispatch
how to accept other values besides plain action objects, such as functions and promises, by intercepting them and dispatching real action objects instead
The most common Redux middleware is redux-thunk
. The word "thunk" means "a function that delays a calculation until later". In our case, adding the thunk middleware to our Redux store lets us pass functions directly to store.dispatch()
. The thunk middleware will see the function, prevent it from actually reaching the "real" store, and call our function and pass in dispatch
and getState
as arguments. So, a "thunk function" looks like this:
function exampleThunkFunction(dispatch, getState) {// do something useful with dispatching or the store state here}// normally an error, but okay if the thunk middleware is addedstore.dispatch(exampleThunkFunction)
Inside of a thunk function, you can write any code you want. The most common usage would be fetching some data via an AJAX call, and dispatching an action to load that data into the Redux store. The async/await
syntax makes it easier to write thunks that do AJAX calls.
Normally, we don't write action objects directly in our code - we use action creator functions to make them, and use them like dispatch(addTodo())
. In the same way, we typically write "thunk action creator" functions that return the thunk functions, like:
function exampleThunk() {return function exampleThunkFunction(dispatch, getState) {// do something useful with dispatching or the store state here}}// normally an error, but okay if the thunk middleware is addedstore.dispatch(exampleThunk())
Why Use Thunks?
You might be wondering what the point of all this is. There's a few reasons to use thunks:
- Thunks allow us to write reusable logic that interacts with a Redux store, but without needing to reference a specific store instance.
- Thunks enable us to move more complex logic outside of our components
- From a component's point of view, it doesn't care whether it's dispatching a plain action or kicking off some async logic - it just calls
dispatch(doSomething())
and moves on. - Thunks can return values like promises, allowing logic inside the component to wait for something else to finish.
For further explanations, see these articles explaining thunks in the redux-thunk
documentation.
There are many other kinds of Redux middleware that add async capabilities. The most popular are redux-saga
, which uses generator functions, and redux-observable
, which uses RxJS observables. For some comparisons, see the Redux FAQ entry on "how do I choose an async middleware?".
However, while sagas and observables are useful, most apps do not need the power and capabilities they provide. So, thunks are the default recommended approach for writing async logic with Redux.
Writing Thunks in Redux Toolkit
Writing thunk functions requires that the redux-thunk
middleware be added to the store as part of the setup process. Redux Toolkit's configureStore
function does automatically - thunk
is one of the default middleware.
However, Redux Toolkit does not currently provide any special functions or syntax for writing thunk functions. In particular, they cannot be defined as part of a createSlice()
call. You have to write them separate from the reducer logic.
In a typical Redux app, thunk action creators are usually defined in an "actions" file, alongside the plain action creators. Thunks typically dispatch plain actions, such as dispatch(dataLoaded(response.data))
.
Because we don't have separate "actions" files, it makes sense to write these thunks directly in our "slice" files. That way, they have access to the plain action creators from the slice, and it's easy to find where the thunk function lives.
Logic for Fetching Github Repo Details
Adding a Reusable Thunk Function Type
Since the thunk middleware is already set up, we don't have to do any work there. However, the TypeScript types for thunks are kind of long and confusing, and we'd normally have to repeat the same type declaration for every thunk function we write.
Before we go any further, let's add a type declaration we can reuse instead.
app/store.ts
-import { configureStore } from '@reduxjs/toolkit'+import { configureStore, Action } from '@reduxjs/toolkit'+import { ThunkAction } from 'redux-thunk'-import rootReducer from './rootReducer'+import rootReducer, { RootState } from './rootReducer'export type AppDispatch = typeof store.dispatch+export type AppThunk = ThunkAction<void, RootState, unknown, Action<string>>
The AppThunk
type declares that the "action" that we're using is specifically a thunk function. The thunk is customized with some additional type parameters:
- Return value: the thunk doesn't return anything
- State type for
getState
: returns ourRootState
type - "Extra argument": the thunk middleware can be customized to pass in an extra value, but we aren't doing that in this app
- Action types accepted by
dispatch
: any action whosetype
is a string.
There are many cases where you would want different type settings here, but these are probably the most common settings. This way, we can avoid repeating that same type declaration every time we write a thunk.
Adding the Repo Details Slice
Now that we have that type, we can write a slice of state for fetching details on a repo.
features/repoSearch/repoDetailsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'import { AppThunk } from 'app/store'import { RepoDetails, getRepoDetails } from 'api/githubAPI'interface RepoDetailsState {openIssuesCount: numbererror: string | null}const initialState: RepoDetailsState = {openIssuesCount: -1,error: null}const repoDetails = createSlice({name: 'repoDetails',initialState,reducers: {getRepoDetailsSuccess(state, action: PayloadAction<RepoDetails>) {state.openIssuesCount = action.payload.open_issues_countstate.error = null},getRepoDetailsFailed(state, action: PayloadAction<string>) {state.openIssuesCount = -1state.error = action.payload}}})export const {getRepoDetailsSuccess,getRepoDetailsFailed} = repoDetails.actionsexport default repoDetails.reducerexport const fetchIssuesCount = (org: string,repo: string): AppThunk => async dispatch => {try {const repoDetails = await getRepoDetails(org, repo)dispatch(getRepoDetailsSuccess(repoDetails))} catch (err) {dispatch(getRepoDetailsFailed(err.toString()))}}
The first part of this should look straightforward. We declare our slice state shape, the initial state value, and write a slice with reducers that store the open issues count or an error string, then export the action creators and reducer.
Down at the bottom, we have our first data fetching thunk. The important things to notice here are:
- The thunk is defined separately from the slice, since RTK currently has no special syntax for defining thunks as part of a slice.
- We declare the thunk action creator as an arrow function, and use the
AppThunk
type we just created. You can use either arrow functions or thefunction
keyword to write thunk functions and thunk action creators, so we could also have written this asfunction fetchIssueCount() : AppThunk
instead. - We use the
async/await
syntax for the thunk function itself. Again, this isn't required, butasync/await
usually results in simpler code than nested Promise.then()
chains. - Inside the thunk, we dispatch the plain action creators that were generated by the
createSlice
call.
While not shown, we also add the slice reducer to our root reducer.
Async Error Handling Logic in Thunks
There is one potential flaw with the fetchIssuesCount()
thunk as written. The try/catch
block will currently catch any errors thrown
by getRepoDetails()
(such as an actual failed AJAX call), but it will also catch any errors that occur inside the dispatch of getRepoDetailsSuccess()
. In both cases, it will end up dispatch getRepoDetailsFailed()
. This may not be the desired way to handle errors, as it might show a misleading reason for what the actual error was.
There are some possible ways to restructure the code to avoid this problem. First, the await
could be switched to a standard promise chain, with separate callbacks passed in for the success and failure cases:
getRepoDetails(org, repo).then(// success callbackrepoDetails => dispatch(getRepoDetailsSuccess(repoDetails)),// error callbackerr => dispatch(getRepoDetailsFailed(err.toString())))
Or, the thunk could be rewritten to only dispatch if no errors were caught:
let repoDetailstry {repoDetails = await getRepoDetails(org, repo)} catch (err) {dispatch(getRepoDetailsFailed(err.toString()))return}dispatch(getRepoDetailsSuccess(repoDetails))}
For sake of simplicity, we'll stick with the logic as-is for the rest of the tutorial.
Fetching Repo Details in the Issues List
Now that the repo details slice exists, we can use it in the <IssuesListPage>
component.
features/issuesList/IssuesListPage.tsx
import React, { useState, useEffect } from 'react'+import { useSelector, useDispatch } from 'react-redux'-import { getIssues, getRepoDetails, IssuesResult } from 'api/githubAPI'+import { getIssues, IssuesResult } from 'api/githubAPI'+import { fetchIssuesCount } from 'features/repoSearch/repoDetailsSlice'+import { RootState } from 'app/rootReducer'// omit codeexport const IssuesListPage = ({org,repo,page = 1,setJumpToPage,showIssueComments}: ILProps) => {+ const dispatch = useDispatch()const [issuesResult, setIssues] = useState<IssuesResult>({pageLinks: null,pageCount: 1,issues: []})- const [numIssues, setNumIssues] = useState<number>(-1)const [isLoading, setIsLoading] = useState<boolean>(false)const [issuesError, setIssuesError] = useState<Error | null>(null)+ const openIssueCount = useSelector(+ (state: RootState) => state.repoDetails.openIssuesCount+ )useEffect(() => {async function fetchEverything() {async function fetchIssues() {const issuesResult = await getIssues(org, repo, page)setIssues(issuesResult)}- async function fetchIssueCount() {- const repoDetails = await getRepoDetails(org, repo)- setNumIssues(repoDetails.open_issues_count)- }try {- await Promise.all([fetchIssues(), fetchIssueCount()])+ await Promise.all([+ fetchIssues(),+ dispatch(fetchIssuesCount(org, repo))+ ])setIssuesError(null)} catch (err) {console.error(err)setIssuesError(err)} finally {setIsLoading(false)}}setIsLoading(true)fetchEverything()- }, [org, repo, page])+ }, [org, repo, page, dispatch])
In <IssuesListPage>
, we import the new fetchIssuesCount
thunk, and rewrite the component to read the open issues count value from the Redux store.
Inside our useEffect
, we drop the fetchIssueCount
function, and dispatch fetchIssueCount
instead.
Logic for Fetching Issues for a Repo
Next up, we need to replace the logic for fetching a list of open issues.
features/issuesList/issuesSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'import { Links } from 'parse-link-header'import { Issue, IssuesResult, getIssue, getIssues } from 'api/githubAPI'import { AppThunk } from 'app/store'interface IssuesState {issuesByNumber: Record<number, Issue>currentPageIssues: number[]pageCount: numberpageLinks: Links | nullisLoading: booleanerror: string | null}const issuesInitialState: IssuesState = {issuesByNumber: {},currentPageIssues: [],pageCount: 0,pageLinks: {},isLoading: false,error: null}function startLoading(state: IssuesState) {state.isLoading = true}function loadingFailed(state: IssuesState, action: PayloadAction<string>) {state.isLoading = falsestate.error = action.payload}const issues = createSlice({name: 'issues',initialState: issuesInitialState,reducers: {getIssueStart: startLoading,getIssuesStart: startLoading,getIssueSuccess(state, { payload }: PayloadAction<Issue>) {const { number } = payloadstate.issuesByNumber[number] = payloadstate.isLoading = falsestate.error = null},getIssuesSuccess(state, { payload }: PayloadAction<IssuesResult>) {const { pageCount, issues, pageLinks } = payloadstate.pageCount = pageCountstate.pageLinks = pageLinksstate.isLoading = falsestate.error = nullissues.forEach(issue => {state.issuesByNumber[issue.number] = issue})state.currentPageIssues = issues.map(issue => issue.number)},getIssueFailure: loadingFailed,getIssuesFailure: loadingFailed}})export const {getIssuesStart,getIssuesSuccess,getIssueStart,getIssueSuccess,getIssueFailure,getIssuesFailure} = issues.actionsexport default issues.reducerexport const fetchIssues = (org: string,repo: string,page?: number): AppThunk => async dispatch => {try {dispatch(getIssuesStart())const issues = await getIssues(org, repo, page)dispatch(getIssuesSuccess(issues))} catch (err) {dispatch(getIssuesFailure(err.toString()))}}export const fetchIssue = (org: string,repo: string,number: number): AppThunk => async dispatch => {try {dispatch(getIssueStart())const issue = await getIssue(org, repo, number)dispatch(getIssueSuccess(issue))} catch (err) {dispatch(getIssueFailure(err.toString()))}}
This slice is a bit longer, but it's the same basic approach as before: write the slice with reducers that handle API call results, then write thunks that do the fetching and dispatch actions with those results. The only new and interesting bits in this slice are:
- Our "start fetching" and "fetch failed" reducer logic is the same for both the single issue and multiple issue fetch cases. So, we write those functions outside the slice once, then reuse them multiple times with different names inside the
reducers
object. - The Github API returns an array of issue entries, but we want to store the data in a "normalized" structure to make it easy to look up an issue by its number. In this case, we use a plain object as a lookup table, by declaring that it is a
Record<number, Issue>
.
Fetching Issues in the Issues List
Now we can finish converting the <IssuesListPage>
component by swapping out the issues fetching logic.
Let's look at the changes.
features/issuesList/IssuesListPage.tsx
-import React, { useState, useEffect } from 'react'+import React, { useEffect } from 'react'import { useSelector, useDispatch } from 'react-redux'-import { getIssues, IssuesResult } from 'api/githubAPI'import { fetchIssuesCount } from 'features/repoSearch/repoDetailsSlice'import { RootState } from 'app/rootReducer'import { IssuesPageHeader } from './IssuesPageHeader'import { IssuesList } from './IssuesList'import { IssuePagination, OnPageChangeCallback } from './IssuePagination'+import { fetchIssues } from './issuesSlice'// omit codeconst dispatch = useDispatch()- const [issuesResult, setIssues] = useState<IssuesResult>({- pageLinks: null,- pageCount: 1,- issues: []- })- const [isLoading, setIsLoading] = useState<boolean>(false)- const [issuesError, setIssuesError] = useState<Error | null>(null)+ const {+ currentPageIssues,+ isLoading,+ error: issuesError,+ issuesByNumber,+ pageCount+ } = useSelector((state: RootState) => state.issues)const openIssueCount = useSelector((state: RootState) => state.repoDetails.openIssuesCount)- const { issues, pageCount } = issuesResult+ const issues = currentPageIssues.map(+ issueNumber => issuesByNumber[issueNumber]+ )useEffect(() => {- async function fetchEverything() {- async function fetchIssues() {- const issuesResult = await getIssues(org, repo, page)- setIssues(issuesResult)- }-- try {- await Promise.all([- fetchIssues(),- dispatch(fetchIssuesCount(org, repo))- ])- setIssuesError(null)- } catch (err) {- console.error(err)- setIssuesError(err)- } finally {- setIsLoading(false)- }- }-- setIsLoading(true)-- fetchEverything()+ dispatch(fetchIssues(org, repo, page))+ dispatch(fetchIssuesCount(org, repo))}, [org, repo, page, dispatch])
We remove the remaining useState
hooks from <IssuesListPage>
, add another useSelector
to retrieve the actual issues data from the Redux store, and construct the list of issues to render by mapping over the "current page issue IDs" array to look up each issue object by its ID.
In our useEffect
, we delete the rest of the data fetching logic that's directly in the component, and just dispatch both data fetching thunks.
This simplifies the logic in the component, but it didn't remove the work being done - it just moved it elsewhere. Again, it's not that either approach is "right" or "wrong" - it's just a question of where you want the data and the logic to live, and which approach is more maintainable for your app and situation.
Converting the Issue Details Page
The last major chunk of work left in the conversion is the <IssueDetailsPage>
component. Let's take a look at what it does.
Reviewing the Issue Details Component
Here's the current first half of <IssueDetailsPage>
, containing the state and data fetching:
export const IssueDetailsPage = ({org,repo,issueId,showIssuesList}: IDProps) => {const [issue, setIssue] = useState<Issue | null>(null)const [comments, setComments] = useState<Comment[]>([])const [commentsError, setCommentsError] = useState<Error | null>(null)useEffect(() => {async function fetchIssue() {try {setCommentsError(null)const issue = await getIssue(org, repo, issueId)setIssue(issue)} catch (err) {setCommentsError(err)}}fetchIssue()}, [org, repo, issueId])useEffect(() => {async function fetchComments() {if (issue !== null) {const comments = await getComments(issue.comments_url)setComments(comments)}}fetchComments()}, [issue])// omit rendering}
It's very similar to <IssuesListPage>
. We store the current displayed Issue
, the fetched comments, and a potential error. We have useEffect
hooks that fetch the current issue by its ID, and fetch the comments whenever the issue changes.
Fetching the Current Issue
We conveniently already have the Redux logic for fetching a single issue - we wrote that already as part of issuesSlice.ts
. So, we can immediately jump straight to using that here in <IssueDetailsPage>
.
features/issueDetails/IssueDetailsPage.tsx
import React, { useState, useEffect } from 'react'+import { useSelector, useDispatch } from 'react-redux'import ReactMarkdown from 'react-markdown'import classnames from 'classnames'import { insertMentionLinks } from 'utils/stringUtils'-import { getIssue, getComments, Issue, Comment } from 'api/githubAPI'+import { getComments, Comment } from 'api/githubAPI'import { IssueLabels } from 'components/IssueLabels'+import { RootState } from 'app/rootReducer'+import { fetchIssue } from 'features/issuesList/issuesSlice'export const IssueDetailsPage = ({org,repo,issueId,showIssuesList}: IDProps) => {- const [issue, setIssue] = useState<Issue | null>(null)const [comments, setComments] = useState<Comment[]>([])- const [commentsError, setCommentsError] = useState<Error | null>(null)+ const [commentsError] = useState<Error | null>(null)+ const dispatch = useDispatch()+ const issue = useSelector(+ (state: RootState) => state.issues.issuesByNumber[issueId]+ )useEffect(() => {- async function fetchIssue() {- try {- setCommentsError(null)- const issue = await getIssue(org, repo, issueId)- setIssue(issue)- } catch (err) {- setCommentsError(err)- }- }- fetchIssue()+ if (!issue) {+ dispatch(fetchIssue(org, repo, issueId))+ }+ // Since we may have the issue already, ensure we're scrolled to the top+ window.scrollTo({ top: 0 })- }, [org, repo, issueId])+ }, [org, repo, issueId, issue, dispatch])
We continue the usual pattern. We drop the existing useState
hooks, pull in useDispatch
and the necessary state via useSelector
, and dispatch the fetchIssue
thunk to fetch data.
Interestingly, there's actually a bit of a change in behavior here. The original React code was storing the fetched issues in <IssuesListPage>
, and <IssueDetailsPage>
was always having to do a separate fetch for its own issue. Because we're now storing issues in the Redux store, most of the time the listed issue should be already cached, and we don't even need to fetch it. Now, it's totally possible to do something similar with just React - all we'd have to do is pass the issue down from the parent component. Still, having that data in Redux makes it easier to do the caching.
(As an interesting side note: the original code always caused the page to jump back to the top, because the issue didn't exist during the first render, so there was no content. If the issue does exist and we render it right away, the page may retain the scroll position from the issues list, so we have to enforce scrolling back to the top.)
Logic for Fetching Comments
We have one more slice left to write - we need to fetch and store comments for the current issue.
features/issueDetails/commentsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'import { Comment, getComments, Issue } from 'api/githubAPI'import { AppThunk } from 'app/store'interface CommentsState {commentsByIssue: Record<number, Comment[] | undefined>loading: booleanerror: string | null}interface CommentLoaded {issueId: numbercomments: Comment[]}const initialState: CommentsState = {commentsByIssue: {},loading: false,error: null}const comments = createSlice({name: 'comments',initialState,reducers: {getCommentsStart(state) {state.loading = truestate.error = null},getCommentsSuccess(state, action: PayloadAction<CommentLoaded>) {const { comments, issueId } = action.payloadstate.commentsByIssue[issueId] = commentsstate.loading = falsestate.error = null},getCommentsFailure(state, action: PayloadAction<string>) {state.loading = falsestate.error = action.payload}}})export const {getCommentsStart,getCommentsSuccess,getCommentsFailure} = comments.actionsexport default comments.reducerexport const fetchComments = (issue: Issue): AppThunk => async dispatch => {try {dispatch(getCommentsStart())const comments = await getComments(issue.comments_url)dispatch(getCommentsSuccess({ issueId: issue.number, comments }))} catch (err) {dispatch(getCommentsFailure(err))}}
The slice should look pretty familiar at this point. Our main bit of state is a lookup table of comments keyed by an issue ID. After the slice, we add a thunk to fetch the comments for a given issue, and dispatch the action to save the resulting array in the slice.
Fetching the Issue Comments
The final step is to swap the comments fetching logic in <IssueDetailsPage>
.
features/issueDetails/IssueDetailsPage.tsx
-import React, { useState, useEffect } from 'react'+import React, { useEffect } from 'react'-import { useSelector, useDispatch } from 'react-redux'+import { useSelector, useDispatch, shallowEqual } from 'react-redux'import ReactMarkdown from 'react-markdown'import classnames from 'classnames'import { insertMentionLinks } from 'utils/stringUtils'-import { getComments, Comment } from 'api/githubAPI'import { IssueLabels } from 'components/IssueLabels'import { RootState } from 'app/rootReducer'import { fetchIssue } from 'features/issuesList/issuesSlice'import { IssueMeta } from './IssueMeta'import { IssueComments } from './IssueComments'+import { fetchComments } from './commentsSlice'export const IssueDetailsPage = ({org,repo,issueId,showIssuesList}: IDProps) => {- const [comments, setComments] = useState<Comment[]>([])- const [commentsError] = useState<Error | null>(null)-const dispatch = useDispatch()const issue = useSelector((state: RootState) => state.issues.issuesByNumber[issueId])+ const { commentsLoading, commentsError, comments } = useSelector(+ (state: RootState) => {+ return {+ commentsLoading: state.comments.loading,+ commentsError: state.comments.error,+ comments: state.comments.commentsByIssue[issueId]+ }+ },+ shallowEqual+ )// omit effectuseEffect(() => {- async function fetchComments() {- if (issue) {- const comments = await getComments(issue.comments_url)- setComments(comments)- }- }- fetchComments()+ if (issue) {+ dispatch(fetchComments(issue))+ }- }, [issue])+ }, [issue, dispatch])
We add another useSelector
hook to pull out the current comments data. In this case, we need three different pieces: the loading flag, a potential error, and the actual comments array for this issue.
However, this leads to a performance problem. Every time this selector runs, it returns a new object: {commentsLoading, commentsError, comments}
. Unlike connect
, useSelector
relies on reference equality by default. So, returning a new object will cause this component to rerender every time an action is dispatched, even if the comments are the same!
There's a few ways to fix this:
- We could write those as separate
useSelector
calls - We could use a memoized selector, such as
createSelector
from Reselect - We can use the React-Redux
shallowEqual
function to compare the results, so that the re-render only happens if the object's contents have changed.
In this case, we'll add shallowEqual
as the comparison function for useSelector
.
Summary
And with that, we're done! The entire Github Issues app should now be fetching its data via thunks, storing the data in Redux, and interacting with the store via React-Redux hooks. We have Typescript types for our Github API calls, the API types are being used for the Redux state slices, and the store state types are being used in our React components.
There's more that could be done to add more type safety if we wanted (like trying to constrain which possible action types can be passed to dispatch
), but this gives us a reasonable "80% solution" without too much extra effort.
Hopefully you now have a solid understanding of how Redux Toolkit looks in a real world application.
Let's wrap this up with one more look at the complete source code and the running app:
Now, go out there and build something cool!