Redux Toolkit Basics. Learn Modern Redux Fast
Redux & Redux Toolkit
Redux-toolkit is the modern way to write redux. If you haven't used Redux Toolkit before, I suggest you go read the Getting Started Guide, and at minimum you should understand createSlice
, createAction
, createAsyncThunk
from the API.
Mandatory documentation:
In order for you to fully grasp the power of Redux Toolkit, go checkout these docs:
1. Redux toolkit - Usage with Typescript. Read this twice
Usage With TypeScript | Redux Toolkit
2.createAsyncThunk
:
createAsyncThunk | Redux Toolkit
3.createSlice
:
Folder structure
We define an entity as a slice of state. For example, a user entity, a post entity, a comment entity, etc. Each entity will have its own slice of state, and its own reducer, actions, and selectors.
**Note:**The directory entity
does not exist in the actual project, it is a generalisation in order to showcase the structure of files.
store / //directory where all state management logic is stored
modules / //directory where all entities are stored
entity /
slice.ts.actions.ts // File where the slice and reducer are defined // File where all the actions are defined
selectors.ts // File where all the selectors are defined
index.ts // File for exports management
To avoid circular dependencies inside slices DON'T declare actions inside the slice file as unknown behaviour might occur. Even though this is not specified in the docs, once your project grows, you will encounter this problem if you declare everything in the same file.
Usage
Let's say that in the future, a new type of entity will be used: Monument
and users can access, search and save monuments in their profile. Let's first define the type of a monument
enum ApiStatus {
Idle = 'idle',
Loading = 'loading',
Success = 'success',
Error = 'error',
}
interface Monument {
id: number // primary ID property
name: string
apiStatus: ApiStatus // status of fetching entity from the server
}
1. Create the entity directory
First off we create a new directory named monuments
in the app/store/modules
directory.
2. Create a new slice for the entity
Than we create a slice.ts
:
import {createSlice} from '@reduxjs/toolkit';
import {isErrorPayload} from '@utils/typeGuards';
import {ApiStatus} from 'types';
/**
* Type for dictionary where we keep fetched monuments
* for fast lookup by Id.
* In this dictionary the key is the id and the value is
* the monument object with that id. Example: Get monument
* with id 5 => localDictionary[5] (the monument object or undefined)
*/
type LocalDictionary = Record<number, Monument | undefined>
interface MonumentState {
byId: number[] // an array to store monuments by Id
local: LocalDictionary
apiStatus: ApiStatus // status for fetching many monuments at once
error?: string //optional error to display in case of fetching errors
}
const initialState: MonumentState = {
byId: []
local: {}
apiStatus: ApiStatus.Idle,
};
const monumentSlice = createSlice({
name: 'monument',
initialState, // defined on top
reducers: {},
extraReducers: (builder) => {
// This is where we will define most reducers for extra type safety
},
});
export default monumentSlice.reducer;
3. Define an action
Now that we have the slice. Let's define anaction
that fetches a monument from the server and stores it in the slice. We will usecreateAsyncThunk
to do that. We now create an actions.ts
file inside the monuments
directory. If the code below seems confusing please read the link from above on createAsyncThunk
:
import * as monumentAPI from '@api/monument' // functions for fetching monument data
import { createAsyncThunk } from '@reduxjs/toolkit'
import { ThunkApi } from 'types'
/* Async action to fetch a monument from the server */
export const fetchMonument = createAsyncThunk<Monument, number, ThunkApi>(
'monuments/fetchOne',
async (monumentId, thunkAPI) => {
try {
// fetch from the server
const monument = await monumentAPI.getById(monumentId)
// return with status success
return {
...monument,
apiStatus: ApiStatus.Success,
}
} catch (error) {
return thunkAPI.rejectWithValue(error)
}
}
)
4. Handle action inside slice
We defined the action but we haven't specified how should redux handle this action. If you stop here and call dispatch(fetchMonument(14))
the theoretical Api Call will launch but the returned data would not be stored in the global state. Let's define that behaviour back in slice.ts
:
import * as actions from './actions'
import {addNewValuesToLocalState} from '@utils/redux';
...
const monumentSlice = createSlice({
name: 'monument',
initialState, // defined on top
reducers: {},
// Use extraReducers, not reducers. Why?
// With this style of adding reducers the (state, action) pair
// is already typed with the definitions from the action file.
// This is faster AND safer than declaring how to handle the action in the
// reducers prop as their you need to define the TS types yourself.
extraReducers: (builder) => {
// handle what the state should be when the monument starts fetching
builder.addCase(actions.fetchMonument.pending, (state, action) => {
//mark the individual monument as pending
// get the id that was passed as parameter to action
const {arg: monumentId} = state.meta
// retrieve existent monument object or create new one
const monument: Partial<Monument> = {
...(state.local[monummentId] ?? {},
apiStatus: ApiStatus.Pending
}
//save value with pending to state
addNewValuesToLocalState(state.local, [monument as Monument])
}
},
});
...
So now when we dispatch fetchMonument
, our local reducer will mark that particular monument as being in a loading state. Next let's define what happens when the monument has been fetched:
...
extraReducers: (builder) => {
// handle what the state should be when the monument starts fetching
builder.addCase(actions.fetchMonument.pending, (state, action) => {
...
}
// handle what the state should be when the monument has been fetched
builder.addCase(actions.fetchMonument.success, (state, action) => {
const fetchedMonument = action.payload; // already typed because of builder function
// we already set apiStatus to ApiStatus.Success when we
// returned from the action
addNewValuesToLocalState(state.local, [fetchedMonument])
}
}
Great! Now we have a functional action that fetches a resource and stores it in the global state to be used by components.
But what happens if an error occurs?!
Let's define that case too:
import {isPayloadError} from '@utils/typeGuards';
...
extraReducers: (builder) => {
// handle what the state should be when the monument starts fetching
builder.addCase(actions.fetchMonument.pending, (state, action) => {...}
// handle what the state should be when the monument has been fetched
builder.addCase(actions.fetchMonument.success, (state, action) => {...}
// handle what the state should be when the api call failed
builder.addCasse(actions.fetchMonument.rejected, (state, action) => {
const {payload} = action;
// type guard to determine if there is actually an error message
if (isPayloadError(payload) {
state.error = payload.message
}
// get the id that was passed as parameter to action
const {arg: monumentId} = state.meta
// retrieve existent monument object or create new one
const failedMonuent: Partial<Monument> = {
...(state.local[monummentId] ?? {}),
apiStatus: ApiStatus.Error // mark fetching failed
}
//save value with pending to state
addNewValuesToLocalState(state.local, [failedMonuent as Monument])
})
}
Awesome! We have a fully usable redux action to fetch individual monuments.
5. Getting data from redux
Now that we have our action let's use it in a MonumentScreen
. What we want to do:
- Get the monument from the redux store
- If the monument is not fetched, fetch it
- Display monument information
Let's start:
import * as React from 'react'
import {useSelector, useDispatch} from 'react-redux'
import * as monumentActions from '@redux/modules/monuments/actions'
interface ScreenProps {
monumentId: number
}
function MonumentScreen(props: ScreenProps) {
const {monumentId} = props;
// Get the current value from the store
const monument = useSelector<StoreState>(state =>
state.monuments.local[monumentId] ?? {apiStatus: ApiStatus.Idle})
// Helpful status indicators
const isIdle = monument.apiStatus === ApiStatus.Idle;
const isLoading = monument.apiStatus === ApiStatus.Loading;
const isError = monument.apiStatus === ApiStatus.Error;
const isSuccess = monument.apiStatus === ApiStatus.Success;
// redux dispatch prop
const dispatch = useDispatch();
// If the monument is not fetched dispatch the action to fetch it
React.useEffect(() => {
if (isIdle) {
dispatch(monumentActions.fetchMonument(monumentId))
}
}, [isIdle, dispatch]
// Placeholder for loading monument
if (isLoading) {
return <LoadingMonument />
}
// Feedback in case of error
if (isError) {
return <Text>Something went wrong...</Text>
}
// fetch was succesfull!
return <View>
<Text>{monument.name}</Text>
</View>
}
Let's evaluate what happens in the code above.
- In the first moment we try to extract from redux a monument that doesn't exist so we get back
{apiStatus: ApiStatus.Idle}
- In the
useEffect
hook we check if the status is idle we dispatch the fetch action - The
pending
part of action triggers so now our data extracted from redux is{apiStatus: ApiStatus.Loading}
- The api finishes loading from the server and the
success
part offetchMonument
is triggered in redux. ThereforeapiStatus
is equal toApiStatus.Success
making theisSuccess
value be true and display the final data to the screen. Yaaay!
Great! Everything works ok. But we have a problem with code reusability and the fact that we can just extract all that logic into a useMonument
hook. Let's do that:
export function useMonument(monumentId: number) {
// Get the current value from the store
const monument = useSelector<StoreState>(state =>
state.monuments.local[monumentId] ?? {apiStatus: ApiStatus.Idle})
// redux dispatch prop
const dispatch = useDispatch();
// Helpful status indicators
const isIdle = monument.apiStatus === ApiStatus.Idle;
const isLoading = monument.apiStatus === ApiStatus.Loading;
const isError = monument.apiStatus === ApiStatus.Error;
const isSuccess = monument.apiStatus === ApiStatus.Success;
// If the monument is not fetched dispatch the action to fetch it
React.useEffect(() => {
if (isIdle) {
dispatch(monumentActions.fetchMonument(monumentId))
}
}, [isIdle, dispatch]
return {
monument,
isIdle,
isError,
isSuccess
}
}
As you can see, no code has been rewritten, just copy and paste into a hook value.
And now our MonumentScreen
becomes:
import {useMonument} from '@hooks/monument'
...
function MonumentScreen(props: ScreenProps) {
const {monumentId} = props;
const {monument, isLoading, isError, isIdle, isSuccess} = useMonument(
monumentId,
);
// Placeholder for loading monument
if (isLoading || isIdle) {
return <LoadingMonument />;
}
// Feedback in case of error
if (isError) {
return <Text>Something went wrong...</Text>;
}
// fetch finished successfully!
if (isSuccess) {
return (
<View>
<Text>Hello from {monument.name}</Text>
</View>
);
}
// (Optional) Safety throw if none of the above cases match
throw new Error('This part of the function should not be reachable');
}
Looks way better now. And now we have a reusable hook to use throughout the application and by other developers to further speed up development. Great!
Keep this structure in mind for other async redux actions you might be building as it is easier to read and highly reusable.