State Management Without the Boilerplate
React Redux remains one of the most widely adopted global state management solutions. Yet the setup overhead has always been a friction point: separate action creators, switch-based reducers, verbose store configuration, and enough boilerplate to make you question whether the state really needed to be global in the first place. The official Redux team answered that pain with Redux Toolkit, an opinionated, standardized wrapper around core Redux that eliminates most of that ceremony.
This article walks through a practical implementation using TypeScript: a GitHub issue tracker. The complete source is available on GitHub, and a live demo is also deployed for reference.
Core Redux Toolkit Benefits
The toolkit's primary value is removing the boilerplate that plain Redux demands. Beyond that, several design decisions make it the preferred choice for new projects:
- Declarative reducers. A slice definition combines initial state, actions, and the reducer itself in one concise object, eliminating the need for separate action constants and switch statements.
- Immutability utilities. Built-in helper functions simplify immutable updates to objects and arrays.
- Async middleware included. Standard async handling is bundled, no extra wiring required.
- DevTools integration. Redux DevTools works out of the box with no additional configuration.
Scaffolding the Project
A new React application with TypeScript templates is generated with a single command:
yarn create react-app project_issue_tracker --template typescript
Next, install Material UI and Emotion for component styling:
yarn add @mui/material @emotion/react @emotion/styled
Then add Redux Toolkit and Redux itself:
yarn add @reduxjs/toolkit react-redux
Building the Issue Tracker UI
A components/ProjectCard.tsx file renders each issue's title, priority, and opening time. Material UI components handle the design system:
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;
The HomePage component imports React's useState hook, additional Material UI elements, and the ProjectCard component. A small form with a text field and submit button allows users to add new issues, and each submission results in a new ProjectCard being appended to the list of open issues:
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;
The page content appears when the HomePage is wired into the app's root component.
Core Toolkit Concepts
Before implementing, it's worth clarifying the three functions that do the heavy lifting in this architecture:
createSlicebundles reducer logic, action creators, and initial state into one definition. No more separate action types or switch statements.configureStoreis an abstraction over Redux'screateStore(), automatically combining reducers and configuring the store for theProvider.createAsyncThunkhandles asynchronous request lifecycle events and error handling in a standardized pattern.
Defining the Issue Reducer
A new file in src/redux/IssueReducer.ts defines the reducer with an addIssue() action:
// 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
The implementation has three parts. Imports pull in the helper functions from @reduxjs/toolkit. Then the initial state type and value are defined, containing a projectIssues array to hold all submitted issues. Finally, createSlice receives a name (issueSlice), the initial state, and a reducers object with an addIssue action. The slice's actions and its reducer are exported at the end for use throughout the application.
Store Configuration
Creating src/redux/index.ts configures the store with configureStore(), which accepts all reducers in one object:
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
With the store in place, the final setup step is passing it through the Provider in App.tsx:
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;
The store is imported and passed directly to the provider; there is no manual store creation or explicit DevTools wiring, which is where the toolkit's convenience is most palpable.
Connecting the UI to the Store
With the plumbing complete, dispatching actions and selecting state require only a few lines. The application dispatches the addIssue action on the form's submit event:
const handleClick = () => {
dispatch(addIssue(textInput))
}
Reading the store's projectIssue list happens through the useSelector() hook:
const issueList = useSelector((state: RootState) => state.issue.projectIssues)
The final homepage renders each issue by mapping over the selected issueList with map() and passing items to the ProjectCard component:
{
issueList.map((issue) => {
return(
<ProjectCard issueTitle={issue} />
)
})
}
Here is the full final shape of HomePage.tsx with all of these pieces integrated:
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;
Submitting issues now adds them to the rendered view on the homepage. This section has covered synchronous state updates with the toolkit. Next we turn to asynchronous workflows, where the library's approach to thunks simplifies request-heavy logic considerably.
Handling API Calls With Async Thunks
State management doesn’t stop at synchronous actions. To pull repository issues from the GitHub API into the store, Redux Toolkit’s createAsyncThunk() is the cleanest path. It standardises how pending, fulfilled, and rejected states are processed, and eliminates the need for manual middleware configuration.
Begin by creating a GithubIssueReducer.ts file in the /redux folder:
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;
A few details matter in the fetchIssues portion of that code:
createAsyncThunk()generates the asynchronous action. The first argument is the action type string, heregithubIssue/fetchIssues.- The second argument is a function returning a
Promise. In this case it calls the GitHub endpoint and maps the response to issue titles. - The third argument is a configuration object. The
voidtype signals the thunk accepts no parameters on dispatch; arejectValuestring (“Failed to fetch issues.”) defines what lands in state when the request fails.
The slice definition shares a shape with the earlier issueSlice, but relies on extraReducers instead of the reducers object. Using a builder, addCase attaches a callback for each async status — pending, fulfilled, rejected — and those callbacks write the appropriate values into the store.
Wiring the Async Reducer Into the Store
Register the new reducer in the central store configuration:
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>()
Mapped to the githubIssue key, it becomes available across the app. In HomePage.tsx, two adjustments bring the data to the UI:
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;
- The
fetchIssues()action is dispatched inside auseEffecthook. - The component renders
loadingorerrortext while the request is in flight or has failed, then displays the issue titles once the promise resolves.
On initial load you will see “Loading”, followed by the populated list of GitHub issues.
The full project is available on GitHub, with a live demo showing the fetch in action.
Where the Tutorial Lands
This walkthrough covered the Redux Toolkit fundamentals: slice-based reducers, built-in immutability, the default middleware, and DevTools wiring. The resulting app tracks issues locally and syncs with GitHub, so new entries appear in the UI and API data populates the list. These patterns give you a repeatable structure for global state in larger React and TypeScript projects.



