Redux Toolkit (which onwards, I will refer to as RTK) is a massive improvement to the Redux ecosystem. RTK changes the way we approach writing Redux logic and is well known for cutting off all the boilerplate code Redux requires.
I’ve enjoyed playing around with this library for the last couple of days, but recently, I found myself in an unpleasant situation. All my Redux logic, including asynchronous calls to APIs, was packed down into one slice
file (more about slices in a bit).
Albeit this being the way RTK suggests we structure our slices, the file starts to become hard to navigate as the application grows and eventually becomes an eyesore to look at.
DISCLAIMER
This post isn’t an introductory guide on how to use RTK or Redux in general, however, I’ve done my bit to explain the little nuances that make RTK what it is.
A little understanding of state management in React is enough to help you wring some value from this post. You can always visit the docs to expand your knowledge.
SLICES
The term slice will be an unfamiliar word for the uninitiated so I’ll briefly explain what it is. In RTK, a slice is a function that holds the state eventually passed to your Redux store. In a slice, reducer functions used to manipulate state are defined and exported to be made accessible by any component in your app.
A slice contains the following data:
- the name of the slice — so it can be referenced in the Redux store
- the
initialState
of the reducer - reducer functions used to make changes to the state
- an
extraReducers
argument responsible for responding to external requests (likefetchPosts
below)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
const initialState = []
// async function
export const fetchPosts = createAsyncThunk(
'counter/fetchPosts',
async (amount) => {
const response = await fetch('https://api.backend.com').then((res) => res.json())
return response.data;
}
);
// slice
export const postSlice = createSlice({
name: 'posts',
initialState,
reducers: {
addPost: (state, action) => {
// some logic
},
},
})
export const { addPost } = postSlice.actions
export default postSlice.reducer
Basic overview of a slice
In a nutshell, the slice file is the powerhouse of an RTK application. Let’s move on to create a new React application with RTK included by running the following command
npx create-react-app my-app --template redux
On opening your app in a code editor, you’ll notice that this template has a slightly different folder structure compared to that of create-react-app.
The difference is the new app
folder which contains the Redux store and the features
folder which holds all the features of the app.
Each subfolder in the features
folder represents a specific functionality in the RTK application which houses the slice file, the component which makes use of the slice and any other files you may include here e.g. styling files.
This generated template also includes a sample counter
component which is meant to show you the basics of setting up a functional Redux store with RTK and how to dispatch actions to this store from components.
Run npm start
to preview this component.
With the way RTK has structured the app, each feature is completely isolated making it easy to locate newly added features in one directory.
THE PROBLEM
Let’s examine counterSlice.js
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { fetchCount } from './counterAPI';
const initialState = {
value: 0,
status: 'idle',
};
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
async (amount) => {
const response = await fetchCount(amount);
return response.data;
}
);
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: (builder) => {
builder
.addCase(incrementAsync.pending, (state) => {
state.status = 'loading';
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = 'idle';
state.value += action.payload;
});
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state) => state.counter.value;
export default counterSlice.reducer;
As I previously mentioned, you will notice that all the logic needed to handle the state for the counter component is consolidated into this single file. The asynchronous calls made using createAsyncThunk
, the createSlice
function and the extraReducers
property are all present.
As your application grows, you will continue to make more asynchronous requests to your backend API and in turn, have to handle all the possible states of that request to ensure that nothing unexpected breaks your application.
In RTK, the three possible states of a request are:
- pending
- fulfilled and
- rejected
Keep in mind that handling one of these cases takes, at least, 3 lines of code. So that’s a minimum of 9 lines for one asynchronous request.
Imagine how difficult it would be to navigate the file when you have about 10+ asynchronous requests. It’s a nightmare I don’t even want to have.
THE SOLUTION
The best way to improve the readability of your slice files would be to delegate all your asynchronous requests to a separate file and import them into the slice file to handle each state of the request.
I like to name this file using ‘thunk’ as a suffix in the same way slice files use 'slice’ as their suffix.
To demonstrate this, I’ve added a new feature to the app which interacts with the GitHub API. Below is the current structure
features
|_counter
|_github
|_githubSlice.js
|_githubThunk.js
githubThunk.js
import { createAsyncThunk } from '@reduxjs/toolkit'
// API keys
let githubClientId = process.env.GITHUB_CLIENT_ID
let githubClientSecret = process.env.GITHUB_CLIENT_SECRET
export const searchUsers = createAsyncThunk(
'github/searchUsers',
const res = await fetch(`https://api.github.com/search/users?q=${text}&
client_id=${githubClientId}&
client_secret=${githubClientSecret}`).then((res) => res.json())
return res.items
}
)
export const getUser = createAsyncThunk('github/getUser', async (username) => {
const res = await fetch(`https://api.github.com/users/${username}?
client_id=${githubClientId}&
client-secret=${githubClientSecret}`).then((res) => res.json())
return res
})
export const getUserRepos = createAsyncThunk(
'github/getUserRepos',
async (username) => {
const res = await fetch(`https://api.github.com/users/${username}/repos?per_page=5&sort=created:asc&
client_id=${githubClientId}&
client-secret=${githubClientSecret}`).then((res) => res.json())
return res
}
)
For more info on how to use createAsyncThunk
, reference the docs.
These asynchronous requests are then imported into the slice file and handled in extraReducers
githubSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { searchUsers, getUser, getUserRepos } from './githubThunk'
const initialState = {
users: [],
user: {},
repos: [],
loading: false,
}
export const githubSlice = createSlice({
name: 'github',
initialState,
reducers: {
clearUsers: (state) => {
state.users = []
state.loading = false
},
},
extraReducers: {
// searchUsers
[searchUsers.pending]: (state) => {
state.loading = true
},
[searchUsers.fulfilled]: (state, { payload }) => {
state.users = payload
state.loading = false
},
[searchUsers.rejected]: (state) => {
state.loading = false
},
// getUser
[getUser.pending]: (state) => {
state.loading = true
},
[getUser.fulfilled]: (state, { payload }) => {
state.user = payload
state.loading = false
},
[getUser.rejected]: (state) => {
state.loading = false
},
// getUserRepos
[getUserRepos.pending]: (state) => {
state.loading = true
},
[getUserRepos.fulfilled]: (state, { payload }) => {
state.repos = payload
state.loading = false
},
[getUserRepos.rejected]: (state) => {
state.loading = false
},
},
})
export const { clearUsers } = githubSlice.actions
export default githubSlice.reducer
I admit the extraReducers property still looks a bit clunky but we’re better off doing it this way. Fortunately, this is similar to the way logic is separated in a normal Redux application with the action and reducer folders.
ADDING SLICE TO THE STORE
Every slice you create must be added to your Redux store so you can gain access to its contents. You can achieve this by adding the github slice to App/store.js
.
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
import githubReducer from './features/github/githubSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
github: githubReducer,
},
})
Another thing to take into consideration is how requests are handled in extraReducers. In the sample slice file, counterSlice
, you’ll notice a different syntax is used to handle the requests.
In githubSlice
, I’ve used the map-object notation in extraReducers
to handle my requests mainly because this approach looks tidier and is easier to write.
The recommended way to handle requests is the builder callback as shown in the sample counterSlice.js
file. This approach is recommended as it has better TypeScript support (and thus, IDE autocomplete even for JavaScript users). This builder notation is also the only way to add matcher reducers and default case reducers to your slice.
MUTABILITY AND IMMUTABILITY
At this point, you may have noticed the contrast in the way state is being modified in RTK compared to how it's done in a normal Redux app or React’s Context API.
RTK lets you write simpler immutable update logic using "mutating" syntax.
// RTK
state.users = payload
// Redux
return {
...state,
users: [...state.users, action.payload]
}
RTK doesn’t mutate the state because it uses the Immer library internally to ensure your state isn’t mutated. Immer detects changes to a “draft state” and produces a brand new immutable state based on your changes.
With this, we can avoid the traditional method of making a copy of the state first before modifying that copy to add new data. Learn more about writing immutable code with Immer here.
DISPATCHING ACTIONS IN COMPONENTS
With the aid of two important hooks; useSelector
and useDispatch
from another library called react-redux
, you will be able to dispatch the actions you’ve created in your slice file from any component.
Install react-redux with this command
npm i react-redux
Now you can make use of the useDispatch
hook to dispatch actions to the store
Search.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { searchUsers } from '../../redux/features/github/githubThunk'
const Search = () => {
const dispatch = useDispatch()
const [text, setText] = useState('')
const onSubmit = (e) => {
e.preventDefault()
if(text !== '') {
dispatch(searchUsers(text))
setText('')
}
}
const onChange = (e) => setText(e.target.value)
return (
<div>
<form className='form' onSubmit={onSubmit}>
<input
type='text'
name='text'
placeholder='Search Users...'
value={text}
onChange={onChange}
/>
<input
type='submit'
value='Search'
/>
</form>
</div>
)
}
export default Search
When the request is fulfilled, your Redux store gets populated with data
CONCLUSION
Redux Toolkit is undeniably an awesome library. With all the measures they took and how simple it is to use, it shows how focused it is on developer experience and I honestly believe RTK should be the only way Redux is written.
RTK also hasn’t stopped here. Their team has gone further to make RTK Query, a library built to facilitate caching and fetching data in Redux applications. It's only a matter of time before RTK becomes the status quo for writing Redux.
What do you think about this approach and RTK in general? I’d be happy to receive some feedback! 😄
Top comments (2)
Atomic state managers like Recoil or Jotai are the solution to this and the future for sure if you ask me.
Have heard about Recoil but not the other. I'd look more into that. Thanks!