A Guide To Redux Toolkit With TypeScript
If you are a React developer working on a complex application, you will need to use global state management for your app at some point. React Redux is one of the most popular libraries for state management used by many developers. However, React Redux has a complex setup process that I’ve found inefficient, not to mention it requires a lot of boilerplate code. The official developer of Redux developed the Redux Toolkit to simplify the process.
This article is for those with enough knowledge of React and TypeScript to work with Redux.
About Redux
Redux is the global state management library for React applications. If you have used useState()
hooks for managing your app state, you will find it hard to access the state when you need it in the other parts of the application. With useState()
hooks, the state can be passed from the parent component to the child, and you will be stuck with the problem of prop drilling if you need to pass it to multiple children. That’s where Redux comes in to manage the application state.
Introducing Redux Toolkit
Redux Toolkit is a set of opinionated and standardised tools that simplify application development using the Redux state management library.
The primary benefit of using Redux Toolkit is that it removes the overhead of writing a lot of boilerplates like you’d have to do with plain Redux.
It eliminates the need to write standard Redux setup code, such as defining actions, reducers, and store configuration, which can be a significant amount of code to write and maintain.
Jerry Navi has a great tutorial that shows the full Redux setup process.
Why I Prefer Redux Toolkit Over Redux
The Redux Toolkit has several key features which make me use this library over plain Redux:
-
Defining reducers
With Redux Toolkit, you can specify a slice with a few lines of code to define a reducer instead of defining actions and reducers separately, like Redux. -
Immutability helpers
Redux Toolkit includes a set of utility functions that make it easy to update objects and arrays in an immutable way. This makes writing code that follows the Redux principles of immutability simpler. -
Built-in middleware
Redux Toolkit includes built-in middleware that can handle asynchronous request tasks. -
DevTools integration
Redux Toolkit includes integration with the Redux DevTools browser extension, which makes it easier to debug and analyse Redux code.
Using Redux Toolkit To Build A Project Issue Tracker
I think the best way to explain the value and benefits of using Redux Toolkit is simply to show them to you in a real-world context. So, let’s develop an app with it that is designed to create and track GitHub issues.
You can follow along with the code examples as we go and reference the full code anytime by grabbing it from GitHub. There is also a live deployment of this example that you can check out.
Start creating a new React app with the following command:
yarn create react-app project_issue_tracker --template typescript
This generates a folder for our project with the basic files we need for development. The –template typescript
part of the command is used to add TypeScript to the stack.
Now, let’s install the dependencies packages required for our project and build the primary UI for the application before we implement Redux Toolkit. First, navigate to the project_issue_tracker
project folder we just created:
cd project_issue_tracker
Then run the following command to install Material UI and Emotion, where the former is a design library we can use to style components, and the latter enables writing CSS in JavaScript files.
yarn add @mui/material @emotion/react @emotion/styled
Now we can install Redix Toolkit and Redux itself:
yarn add @reduxjs/toolkit react-redux
We have everything we need to start developing! We can start by building the user interface.
Developing The User Interface
In this section, we will be developing the UI of the app. Open the main project folder and create a new components
subfolder directly in the root. Inside this new folder, create a new file called ProjectCard.tsx
. This is where we will write the code for a ProjectCard
component that contains information about an open issue in the project issue tracker.
Let’s import some design elements from the Material UI package we installed to the new /components/ProjectCard.tsx
file to get us started:
import React from "react";
import { Typography, Grid, Stack, Paper} from "@mui/material";
interface IProps {
issueTitle: string
}
const ProjectCard : React.FC<IProps> = ({ issueTitle }) => {
return(
<div className="project_card">
<Paper elevation={1} sx={{p: '10px', m:'1rem'}}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Stack spacing={2}>
<Typography variant="h6" sx={{fontWeight: 'bold'}}>
Issue Title: {issueTitle}
</Typography>
<Stack direction='row' spacing={2}>
<Typography variant="body1">
Opened: yesterday
</Typography>
<Typography variant="body1">
Priority: medium
</Typography>
</Stack>
</Stack>
</Grid>
</Grid>
</Paper>
</div>
)
}
export default ProjectCard;
This creates the project card that displays an issue title, issue priority level, and the time the issue was “opened.” Notice that we are using an issueTitle
prop that will be passed to the ProjectCard
component to render the issue with a provided title.
Now, let’s create the component for the app’s HomePage
to display all the issues. We’ll add a small form to the page for submitting new issues that contain a text field for entering the issue name and a button to submit the form. We can do that by opening up the src/HomePage.tsx
file in the project folder and importing React’s useState
hook, a few more styled elements from Material UI, and the ProjectCard
component we set up earlier:
import React, { useState } from "react";
import { Box, Typography, TextField, Stack, Button } from "@mui/material";
import ProjectCard from "./components/ProjectCard";
const HomePage = () => {
const [textInput, setTextInput] = useState('');
const handleTextInputChange = (e:any) => {
setTextInput(e.target.value);
};
return(
<div className="home_page">
<Box sx={{ml: '5rem', mr: '5rem'}}>
<Typography variant="h4" sx={{textAlign: 'center'}}>
Project Issue Tracker
</Typography>
<Box sx={{display: 'flex'}}>
<Stack spacing={2}>
<Typography variant="h5">
Add new issue
</Typography>
<TextField
id="outlined-basic"
label="Title"
variant="outlined"
onChange={handleTextInputChange}
value={textInput}
/>
<Button variant="contained">Submit</Button>
</Stack>
</Box>
<Box sx={{ml: '1rem', mt: '3rem'}}>
<Typography variant="h5" >
Opened issue
</Typography>
<ProjectCard issueTitle="Bug: Issue 1" />
<ProjectCard issueTitle="Bug: Issue 2" />
</Box>
</Box>
</div>
)
}
export default HomePage;
This results in a new HomePage
component that a user can interact with to add new issues by entering an issue name in a form text input. When the issue is submitted, a new ProjectCard
component is added to the HomePage
, which acts as an index for viewing all open issues.
The only thing left for the interface is to render the HomePage
, which we can do by adding it to the App.tsx
file. The full code is available here on GitHub.
Using Redux Toolkit
Now that our UI is finalised, we can move on to implementing Redux Toolkit to manage the state of this app. We will use Redux Toolkit to manage the state of the ProjectCard
list by storing all the issues in a store that can be accessed from anywhere in the application.
Before we move to the actual implementation, let’s understand a few Redux Toolkit concepts to help understand what we’re implementing:
-
createSlice
This function makes it easy to define the reducer, actions, and theinitialState
under one object. Unlike the plain redux, you don’t need to use a switch for actions and need to define the actions separately. This function accepts an object as a name (i.e., the name of the slice) and the initial state of the store and the reducer, where you define all the reducers along with their action types. -
configureStore
This function is an abstraction for the ReduxcreateStore()
function. It removes the dependency of defining reducers separately and creating a store again. This way, the store is configured automatically and can be passed to theProvider
. -
createAsyncThunk
This function simplifies making asynchronous calls. It automatically dispatches many different actions for managing the state of the calls and provides a standardised way to handle errors.
Let’s implement all of this! We will create the issueReducer
with an addIssue()
action that adds any new submitted issue to the projectIssues
store. This can be done by creating a new file in src/redux/
called IssueReducer.ts
with this code:
// Part 1
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
// Part 2
export interface IssueInitialState {
projectIssues: string[]
}
const initialState: IssueInitialState = {
projectIssues: []
}
// Part 3
export const issueSlice = createSlice({
name: 'issue',
initialState,
reducers: {
addIssue: (state, action: PayloadAction<string>) => {
state.projectIssues = [...state.projectIssues, action.payload]
}
}
})
// Part 4
export const { addIssue } = issueSlice.actions
export default issueSlice.reducer
Let’s understand each part of the code. First, we are importing the necessary functions from the Redux @reduxjs/toolkit
package.
Then, we create the type definition of our initial state and initialise the initialState
for the issueReducer
. The initialState
has a projectIssues[]
list that will be used to store all the submitted issues. We can have as many properties defined in the initialState
as we need for the application.
Thirdly, we are defining the issueSlice
using Redux Toolkit’s createSlice
function, which has the logic of the issueReducer
as well as the different actions associated with it. createSlice
accepts an object with a few properties, including:
-
name
: the name of the slice, -
initialState
: the initial state of the reducer function, -
reducers
: an object that accepts different actions we want to define for our reducer.
The slice name for the issueReducer
is issueSlice
. The initalState
of it is defined, and a single adIssue
action is associated with it. The addIssue
action is dispatched whenever a new issue is submitted. We can have other actions defined, too, if the app requires it, but this is all we need for this example.
Finally, in the last part of the code, we export the actions associated with our reducer and the issueSlice
reducer. We have fully implemented our issueReducer
, which stores all the submitted issues by dispatching the addIssue
action.
Now let’s configure the issueReducer
in our store so we can use it in the app. Create a new file in src/redux/
called index.ts
, and add the following code:
import { configureStore } from "@reduxjs/toolkit";
import IssueReducer from "./IssueReducer";
export const store = configureStore({
reducer: {
issue: IssueReducer
}
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
This code configures and creates the store using the configureStore()
function that accepts a reducer where we can pass all of the different reducers.
We are done adding the reducer and configuring the store with Redux Toolkit. Let’s do the final step of passing the store to our app. Start by updating the App.tsx
file to pass the store using the Provider
:
import React from 'react';
import { Provider } from "react-redux"
import { store } from './redux';
import HomePage from './HomePage';
function App() {
return (
<div className="App">
<Provider store={store}>
<HomePage />
</Provider>
</div>
);
}
export default App;
Here, you can see that we are importing the store and directly passing through the Provider
. We don’t need to write anything extra to create a store or configure DevTools like we would using plain Redux. This is definitely one of the ways Redux Toolkit streamlines things.
OK, we have successfully set up a store and a reducer for our app with Redux Toolkit. Let’s use our app now and see if it works. To quickly sum things up, the dispatch()
function is used to dispatch any actions to the store, and useSelector()
is used for accessing any state properties.
We will dispatch the addIssue
action when the form button is clicked:
const handleClick = () => {
dispatch(addIssue(textInput))
}
To access the projectIssue
list stored in our reducer store, we can make use of useSelector()
like this:
const issueList = useSelector((state: RootState) => state.issue.projectIssues)
Finally, we can render all the issues by map()
-ping the issueList
to the ProjectCard
component:
{
issueList.map((issue) => {
return(
<ProjectCard issueTitle={issue} />
)
})
}
The final code for HomePage.tsx
looks like this:
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "./redux/index"
import { Box, Typography, TextField, Stack, Button } from "@mui/material";
import ProjectCard from "./components/ProjectCard";
import { addIssue } from "./redux/IssueReducer";
const HomePage = () => {
const dispatch = useDispatch();
const issueList = useSelector((state: RootState) => state.issue.projectIssues)
const [textInput, setTextInput] = useState('');
const handleTextInputChange = (e:any) => {
setTextInput(e.target.value);
};
const handleClick = () => {
dispatch(addIssue(textInput))
}
return(
<div className="home_page">
<Box sx={{ml: '5rem', mr: '5rem'}}>
<Typography variant="h4" sx={{textAlign: 'center'}}>
Project Issue Tracker
</Typography>
<Box sx={{display: 'flex'}}>
<Stack spacing={2}>
<Typography variant="h5">
Add new issue
</Typography>
<TextField
id="outlined-basic"
label="Title"
variant="outlined"
onChange={handleTextInputChange}
value={textInput}
/>
<Button variant="contained" onClick={handleClick}>Submit</Button>
</Stack>
</Box>
<Box sx={{ml: '1rem', mt: '3rem'}}>
<Typography variant="h5" >
Opened issue
</Typography>
{
issueList.map((issue) => {
return(
<ProjectCard issueTitle={issue} />
)
})
}
</Box>
</Box>
</div>
)
}
export default HomePage;
Now, when we add and submit an issue using the form, that issue will be rendered on the homepage.
This section covered how to define any reducer and how they’re used in the app. The following section will cover how Redux Toolkit makes asynchronous calls a relatively simple task.
Making Asynchronous Calls With Redux Toolkit
We implemented our store to save and render any newly added issue to our app. What if we want to call GitHub API for any repository and list all the issues of it in our app? In this section, we will see how to use the createAsyncThunk()
API with the slice to get data and render all the repository issues using an API call.
I always prefer to use the createAsyncThunk()
API of the redux toolkit because it standardises the way different states are handled, such as loading
, error
, and fulfilled
. Another reason is that we don’t need to add extra configurations for the middleware.
Let’s add the code for creating a GithubIssue
reducer first before we break it down to understand what’s happening. Add a new GithubIssueReducer.ts
file in the /redux
folder and add this code:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const fetchIssues = createAsyncThunk<string[], void, { rejectValue: string }>(
"githubIssue/fetchIssues",
async (_, thunkAPI) => {
try {
const response = await fetch("https://api.github.com/repos/github/hub/issues");
const data = await response.json();
const issues = data.map((issue: { title: string }) => issue.title);
return issues;
} catch (error) {
return thunkAPI.rejectWithValue("Failed to fetch issues.");
}
}
);
interface IssuesState {
issues: string[];
loading: boolean;
error: string | null;
}
const initialState: IssuesState = {
issues: [],
loading: false,
error: null,
};
export const issuesSliceGithub = createSlice({
name: 'github_issues',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchIssues.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchIssues.fulfilled, (state, action) => {
state.loading = false;
state.issues = action.payload;
})
.addCase(fetchIssues.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Something went wrong';
});
},
});
export default issuesSliceGithub.reducer;
Let’s understand the fetchIssues
part first:
- We are using the
createAsyncThunk()
API provided by the Redux Toolkit. It helps create asynchronous actions and handles the app’s loading and error states. - The action type name is the first argument passed to
createAsyncThunk()
. The specific action type name we have defined isgithubIssue/fetchIssues
. - The second argument is a function that returns a
Promise
, which resolves to the value that dispatches the action. This is when the asynchronous function fetches data from a GitHub API endpoint and maps the response data to a list of issue titles. - The third argument is an object that contains configuration options for the async thunk. In this case, we have specified that the async thunk will not be dispatched with any arguments (hence the
void
type) and that if thePromise
returned by the async function is rejected, the async thunk will return an action with a rejected status along with arejectValue
property that contains the string “Failed to fetch issues.”
When this action is dispatched, the API calls will be made, and the githubIssuesList
data will be stored. We can follow this exact same sequence of steps to make any API calls we need.
The second section of the code is similar to what we used when we created the issueSlice
, but with three differences:
-
extraReducers
This object contains the reducers logic for the reducers not defined in thecreateSlice
reducers object. It takes a builder object where different cases can be added usingaddCase
for specific action types. -
addCase
This method on the builder object creates a new case for the reducer function. -
API call states
The callback function passed to theaddCase
method is dispatched bycreateAsyncThunk()
, which updates the different store objects based on the API call states (pending
,fulfilled
, anderror
).
We can now use the GithubIssue
reducer actions and the store in our app. Let’s add the GithubIssueReducer
to our store first. Update the /redux/index.ts
file with this code:
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import IssueReducer from "./IssueReducer";
import GithubIssueReducer from "./GithubIssueReducer";
export const store = configureStore({
reducer: {
issue: IssueReducer,
githubIssue: GithubIssueReducer
}
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch = () => useDispatch<AppDispatch>()
We just added the GithubIssueReducer
to our store with the name mapped to githubIssue
. We can now use this reducer in our HomePage
component to dispatch the fetchIssues()
and populate our page with all the issues received from the GitHub API repo.
import React, { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { useAppDispatch, RootState, AppDispatch } from "./redux/index";
import { Box, Typography, TextField, Stack, Button } from "@mui/material";
import ProjectCard from "./components/ProjectCard";
import { addIssue } from "./redux/IssueReducer";
import { fetchIssues } from "./redux/GithubIssueReducer";
const HomePage = () => {
const dispatch: AppDispatch = useAppDispatch();
const [textInput, setTextInput] = useState('');
const githubIssueList = useSelector((state: RootState) => state.githubIssue.issues)
const loading = useSelector((state: RootState) => state.githubIssue.loading);
const error = useSelector((state: RootState) => state.githubIssue.error);
useEffect(() => {
dispatch(fetchIssues())
}, [dispatch]);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
const handleTextInputChange = (e:any) => {
setTextInput(e.target.value);
};
const handleClick = () => {
console.log(textInput)
dispatch(addIssue(textInput))
}
return(
<div className="home_page">
<Box sx={{ml: '5rem', mr: '5rem'}}>
<Typography variant="h4" sx={{textAlign: 'center'}}>
Project Issue Tracker
</Typography>
<Box sx={{display: 'flex'}}>
<Stack spacing={2}>
<Typography variant="h5">
Add new issue
</Typography>
<TextField
id="outlined-basic"
label="Title"
variant="outlined"
onChange={handleTextInputChange}
value={textInput}
/>
<Button variant="contained" onClick={handleClick}>Submit</Button>
</Stack>
</Box>
<Box sx={{ml: '1rem', mt: '3rem'}}>
<Typography variant="h5" >
Opened issue
</Typography>
{
githubIssueList?.map((issue : string) => {
return(
<ProjectCard issueTitle={issue} />
)
})
}
</Box>
</Box>
</div>
)
}
export default HomePage;
This updates the code in HomePage.tsx
with two minor changes:
- We dispatch
fetchIssue
and use thecreateAsync()
action to make the API calls under theuseEffect
hook. - We use the
loading
anderror
states when the component renders.
Now, when loading the app, you will first see the “Loading” text rendered, and once the API call is fulfilled, the issuesList
will be populated with all the titles of GitHub issues fetched from the repo.
Once again, the complete code for this project can be found on GitHub. You can also check out a live deployment of the app, which displays all the issues fetched from GitHub.
Conclusion
There we have it! We used Redux Toolkit in a React TypeScript application to build a fully functional project issue tracker that syncs with GitHub and allows us to create new issues directly from the app.
We learned many of the foundational concepts of Redux Toolkit, such as defining reducers, immutability helpers, built-in middleware, and DevTools integration. I hope you feel powered to use Redux Toolkit effectively in your projects. With Redux Toolkit, you can improve the performance and scalability of your React applications by effectively managing the global state.
Further Reading on Smashing Magazine
- How Redux Reducers Work, Fortune Ikechi
- What Is Redux: A Designer’s Guide, Linton Ye
- Dynamic Static Typing In TypeScript, Stefan Baumgartner
- A Deep Dive Into Serverless UI With TypeScript, Ikeh Akinyemi
- Setting Up Redux For Use In A Real-World Application, Jerry Navi