To start with, according to the Redux team, Redux Toolkit was created to address three major concerns:
- "Configuring a Redux store is too complicated"
- "I have to add a lot of packages to get Redux to do anything useful"
- "Redux requires too much boilerplate code"
Simply put, Redux Toolkit provides us with tools that help abstract over the setup process that used to be troublesome. It also comes loaded with some useful utilities that were popular with the standalone Redux, in order to handle the most common use cases.
Now that you are familiar with what Redux Toolkit is and the reason for its being, let me get to the main aim of this post: get introduced to using Redux Toolkit in an app using Next.js and TypeScript.
Redux toolkit in Next.js and TypeScript
At Merix, we use Next.js as our preferred tool for when a project needs React, because it comes preloaded with many things we look for when deploying production-ready apps. We also use TypeScript because it helps us write less error-prone code, is documentation in itself and helps with project maintenance far into the future.
Before we begin, I would like to mention that the app that we will be creating today will only use client-side rendering. This will help us focus on getting introduced to Redux Toolkit and not be distracted with the bells and whistles involved in bringing server-side rendering into the picture.
First things first, let's get our project started.
The basic setup
- Clone the Next + TS starter project here.
- Once cloned, install the packages and delete the components, interfaces, pages and utils folders as they contain some files we won't be using for this demo.
- In the root of the project, create a src directory and create these 3 sub-directories: pages, features and app.
- Inside the pages directory, create an index.tsx file. Create a functional component in there and some content like “hello world”, so that you will have something to see when you action the next step.
- Run yarn dev or npm run dev (depending on your package manager) to get the project running on dev.
Once you do the above, you should be able to see http://localhost:3000/ open with your “hello world” text in the index.tsx file.
Now is the time to really get down to working with Redux Toolkit. But before we do, I want to mention that Redux Toolkit is a very versatile tool that developers use based on their individual needs. If you are a solo developer working on a small project, it makes sense to use Redux Toolkit in a way that many developers in a large project would not. So, I will first demonstrate the way you can use Redux Toolkit in an app that is rather small. After that, you can proceed to using Redux Toolkit when working with larger apps.
You might be tempted to check out the cool way in which Merix uses Redux Toolkit first, but I recommend you start by following the “simple” way as it will help you understand the concepts better. I also reuse a hefty portion of the code from the “simple” way later, so bear that in mind.
So, the first view of the app we will be building, looks like this:
You can find the second view here:
Groundbreaking. I know… Seriously though, the way we work on these 2 views will teach you a lot about working with Redux Toolkit.
Using Redux Toolkit in small apps
To utilize Redux Toolkit in smaller applications, follow these instructions:
- Add the required packages with yarn add @reduxjs/toolkit react-redux or npm install @reduxjs/toolkit react-redux
- Add axios with yarn add axios or npm install axios
- Inside the app folder we created in the basic setup, create 2 files: hooks.ts and store.ts.
- Inside store.ts, you have the below code:
import {
Action,
configureStore,
ThunkAction,
} from '@reduxjs/toolkit';
export const store = configureStore({
reducer: {
// This is where we add reducers.
// Since we don't have any yet, leave this empty
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
If the above code looks too confusing, pay no heed. Most of the code here is boilerplate associated with Redux Toolkit and TypeScript. The only thing you have to pay attention to and understand is that this is where all your reducers will be gathered. Right now, there are no reducers, but this will soon change.
Next, you must add the below code inside hooks.ts.
import {
TypedUseSelectorHook,
useDispatch,
useSelector,
} from 'react-redux';
import type {
AppDispatch,
RootState,
} from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
The purpose of the above code is to simply allow us to use useAppDispatch and useAppSelector instead of the plain useDispatch and useSelector. It’s just some more boilerplate you should not worry about.
- Make sure that the data in the store is accessible to us throughout the app. To do this in Next.js we have to create an _app.tsx file inside the pages folder and add the below code. All that is happening here is we put a <Provider> around our entire app, and pass the store as a prop.
import { Provider } from 'react-redux';
import type { AppProps } from 'next/app';
import { store } from '../app/store';
function MyApp({
Component, pageProps,
}: AppProps) {
return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
);
}
export default MyApp;
That’s all the set up we needed. Let’s create the first view.
The functionalities here should be as follows:
- When you add a number in the input field and click on ‘Increment by amount’ that number will be added to the current number.
- When you click on the ‘Decrement by 1’, the number is reduced by 1.
- When you click on the ‘Increment by 1’, the number increases by 1.
Now that we know what is expected of us, let's get coding.
Inside the features folder, create a subfolder with the name counter. In it, create a file with the name counterSlice.ts. We need to add the below code inside it. Take a look, I have highlighted what is happening there together with the code.
import {
createSlice,
PayloadAction,
} from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
// declaring the types for our state
export type CounterState = {
value: number;
};
const initialState: CounterState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions.
// In this example, 'increment', 'decrement' and 'incrementByAmount' are actions. They can be triggered from outside this slice, anywhere in the app.
// So for example, if we make a dispatch to the 'increment' action here from the index page, it will get triggered and change the value of the state from 0 to 1.
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++;
},
decrement: state => {
state.value--;
},
// 'The increment by amount' action here, has one job and that is to take whatever value is passed to it and add that to state.value.
// The PayloadAction type here is used to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
// Here we are just exporting the actions from this slice, so that we can call them anywhere in our app.
export const {
increment,
decrement,
incrementByAmount,
} = counterSlice.actions;
// calling the above actions would be useless if we could not access the data in the state. So, we use something called a selector which allows us to select a value from the state.
export const selectCount = (state: RootState) => state.counter.value;
// exporting the reducer here, as we need to add this to the store
export default counterSlice.reducer;
As you can see in the above code, createSlice is pretty nifty as it allows us to have the actions, reducers, and selectors in one place. So much so that the Redux Toolkit team appears to consider this as the standard approach for writing Redux logic. You can read more about it in their documentation.
Now that we have our counterSlice all set up, it's time to add it to our store.
import {
Action,
configureStore,
ThunkAction,
} from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
Alrighty! We have our Slice all set up with the store and all that is left to do is to head over to our index.ts and create view 1, so that we can use the actions and selectors we created in our counterSlice.
import { useState } from 'react';
import {
useAppDispatch,
useAppSelector,
} from '../app/hooks';
import {
decrement,
increment,
incrementByAmount,
selectCount,
} from '../features/counter/counterSlice';
const IndexPage:React.FC = () => {
const dispatch = useAppDispatch();
const count = useAppSelector(selectCount);
const [incrementAmount, setIncrementAmount] = useState<number>(0);
return (
<>
<h1>Welcome to the greatest app in the world!</h1>
<h2>
The current number is
{count}
</h2>
<div>
<input
value={incrementAmount}
onChange={(e) => setIncrementAmount(Number(e.target.value))}
type="number"
/>
<button
onClick={() => dispatch(incrementByAmount(Number(incrementAmount)))}
>
Increment by amount
</button>
</div>
<div>
<button onClick={() => dispatch(decrement())}>Decrement by 1</button>
<button onClick={() => dispatch(increment())}>Increment by 1</button>
</div>
</>
);
};
export default IndexPage;
Take a careful look at the code above. Let’s check if we achieved the goals we wanted from view 1.
Goals and how they were achieved in the codeGoalHow it was achieved When you add a number in the input field and click on ‘Increment by amount’ that number will be added to the current number.
The input updates the value of the incrementAmount when a value is entered. When the button ‘Increment by amount’ is clicked, it takes whatever value is in incrementAmount, converts it to a number and then dispatches it to the incrementByAmount action we created in our counterSlice.
We then fetch the value from the state using the selector and display it.
When you click on the ‘Decrement by 1’, the number is reduced by 1
The button click invokes the decrement action we created in our counterSlice.
The value in the state is then reflected in the UI thanks to the selector.
When you click on the ‘Increment by 1’, the number increases by 1
The button click invokes the increment action we created in our counterSlice.
The value in the state is then reflected in the UI thanks to the selector.
Now that we have completed view 1, let's get down to making view 2.
The only expected functionality here is that whenever a user clicks the "Generate Kanye Quote" button, they will be able to see a random Kanye quote. So, what is essentially happening is that when the user clicks the button, we dispatch an action that makes an API call. The data that is returned is then added to the state and displayed in the UI using a selector.
To make this functionality come alive, we will need to create a separate slice in the features folder. So, let's create a subfolder there called kanye and add a file with the name kanyeSlice.ts. Below is what the kanyeSlice will look like.
import {
createAsyncThunk,
createSlice,
} from '@reduxjs/toolkit';
import axios from 'axios';
import type { RootState } from '../../app/store';
// here we are typing the types for the state
export type KanyeState = {
data: { quote: string };
pending: boolean;
error: boolean;
};
const initialState: KanyeState = {
data: { quote: 'click that button' },
pending: false,
error: false,
};
// This action is what we will call using the dispatch in order to trigger the API call.
export const getKanyeQuote = createAsyncThunk('kanye/kanyeQuote', async () => {
const response = await axios.get('https://api.kanye.rest/');
return response.data;
});
export const kanyeSlice = createSlice({
name: 'kanye',
initialState,
reducers: {
// leave this empty here
},
// The `extraReducers` field lets the slice handle actions defined elsewhere, including actions generated by createAsyncThunk or in other slices.
// Since this is an API call we have 3 possible outcomes: pending, fulfilled and rejected. We have made allocations for all 3 outcomes.
// Doing this is good practice as we can tap into the status of the API call and give our users an idea of what's happening in the background.
extraReducers: builder => {
builder
.addCase(getKanyeQuote.pending, state => {
state.pending = true;
})
.addCase(getKanyeQuote.fulfilled, (state, { payload }) => {
// When the API call is successful and we get some data,the data becomes the `fulfilled` action payload
state.pending = false;
state.data = payload;
})
.addCase(getKanyeQuote.rejected, state => {
state.pending = false;
state.error = true;
});
},
});
export const selectKanye = (state: RootState) => state.kanyeQuote;
export default kanyeSlice.reducer;
Notice the code above. I have explained what the above code does using the comments, but if you remain curious about working with createAsyncThunk, I recommend taking a look at the Redux Toolkit documentation here.
So, now that we have our kanyeSlice, all we have to do is import it into our store as is shown below:
import {
Action,
configureStore,
ThunkAction,
} from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import kanyeReducer from '../features/kanye/kanyeSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
kanyeQuote: kanyeReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
Once this is done, let's create a new page called kanye.tsx in order to use the actions and selectors we just created in kanyeSlice.ts. Once done, we will add the below code.
import React from 'react';
import {
useAppDispatch,
useAppSelector,
} from '../app/hooks';
import {
getKanyeQuote,
selectKanye,
} from '../features/kanye/kanyeSlice';
const kanye:React.FC = () => {
const dispatch = useAppDispatch();
const {
data,
pending,
error,
} = useAppSelector(selectKanye);
return (
<div>
<h2>Generate random Kanye West quote</h2>
{pending && <p>Loading...</p>}
{data && <p>{data.quote}</p>}
{error && <p>Oops, something went wrong</p>}
<button onClick={() => dispatch(getKanyeQuote())} disabled={pending}>
Generate Kanye Quote
</button>
</div>
);
};
export default kanye;
Now we may verify if we achieved the goals we wanted from view 2.
Goals and how they were achieved in the codeGoalHow was it achieved Fetch a random quote on button click. On button click, we invoke the getKanyeQuote action using a dispatch. While the promise from the API call is pending, we display the loading text. If the promise is fulfilled, the data is shown. If the promise is rejected, the user gets notified that something went wrong.
That is how you can use Redux Toolkit in simple apps using a slice. Up next is how we use Redux Toolkit at Merix.
Using Redux Toolkit with large apps: the Merix way
The only difference between our way of using Redux and the above method is that we divide our actions, reducers and selectors into different files. Doing this helps us keep the codebase clean as things start to look cluttered when you have a large amount of actions.
So, in order to reproduce view 1, we first have to navigate to the counter folder inside features. Once there, delete the counterSlice.ts and create 4 new files: actions.ts, reducer.ts, selectors.ts and index.ts.
The first thing we do is add the below to the index.ts file so that we will be importing from it the actions, reducers and selectors we will be creating.
export * from "./actions";
export * from "./reducer";
export * from "./selectors";
In the actions.ts, we will have the below code:
import { createAction } from "@reduxjs/toolkit"
export const increment = createAction('counter/increment')
export const decrement = createAction('counter/decrement')
export const incrementByAmount = createAction<number>('counter/incrementByAmount')
The createAction helper you see above, takes an action type and returns an action creator for that type. The action creator can be called either without arguments or with a payload to be attached to the action. For more information on this, check out the documentation.
Now that we have our actions set up, let's add the below code to our reducer.
import { createReducer } from '@reduxjs/toolkit';
import {
decrement,
increment,
incrementByAmount,
} from './actions';
type CounterState = {
value: number;
};
const initialState: CounterState = {
value: 0,
};
export const counterReducer = createReducer(initialState, builder => {
builder
.addCase(increment, state => {
state.value++;
})
.addCase(decrement, state => {
state.value--;
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload;
});
});
Most of the code here is identical to what we saw in counterSlice. This is as all we are doing is basically breaking the counterSlice into separate actions, reducers and selectors in order to improve readability.
Now, let's add this reducer to our store.
import {
Action,
configureStore,
ThunkAction,
} from '@reduxjs/toolkit';
import { counterReducer } from '../features/counter';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
Since our reducer is now in the store, we can create a selector for it. So, let's head over to selectors.ts and add the below code:
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
export const selectCount = (state: RootState) => state.counter.value;
export const countSelector = createSelector(selectCount, state => state);
Now we are all set. All we need to do is head over to our index.ts and update the import from where we are getting our actions and selectors. The rest of the code says the same. Take a look below.
import React, { useState } from 'react';
import {
useAppDispatch,
useAppSelector,
} from '../app/hooks';
import {
decrement,
increment,
incrementByAmount,
selectCount,
} from '../features/counter';
const IndexPage:React.FC = () => {
const dispatch = useAppDispatch();
const count = useAppSelector(selectCount);
const [incrementAmount, setIncrementAmount] = useState<number>(0);
return (
<>
<h1>Welcome to the greatest app in the world!</h1>
<h2>
The current number is
{count}
</h2>
<div>
<input
value={incrementAmount}
onChange={(e) => setIncrementAmount(Number(e.target.value))}
type="number"
/>
<button
onClick={() => dispatch(incrementByAmount(Number(incrementAmount)))}
>
Increment by amount
</button>
</div>
<div>
<button onClick={() => dispatch(decrement())}>Decrement by 1</button>
<button onClick={() => dispatch(increment())}>Increment by 1</button>
</div>
</>
);
};
export default IndexPage;
Great! View 1 is complete. Let’s get started on view 2.
To start with, let’s delete the kanyeSlice.ts from the kanye folder in features and create 4 new files: actions.ts, reducer.ts, selectors.ts and index.ts.
The index.ts will have the below code as earlier.
export * from "./actions";
export * from "./reducer";
export * from "./selectors";
Our actions.ts will look as follows:
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
export const getKanyeQuote = createAsyncThunk('kanye/kanyeQuote', async () => {
const response = await axios.get('https://api.kanye.rest/');
return response.data;
});
We will then use this action in our reducer as shown below:
import { createReducer } from '@reduxjs/toolkit';
import { getKanyeQuote } from './actions';
export type KanyeState = {
data: { quote: string };
pending: boolean;
error: boolean;
};
const initialState: KanyeState = {
data: { quote: 'click that button' },
pending: false,
error: false,
};
export const kanyeReducer = createReducer(initialState, builder => {
builder
.addCase(getKanyeQuote.pending, state => {
state.pending = true;
})
.addCase(getKanyeQuote.fulfilled, (state, { payload }) => {
state.pending = false;
state.data = payload;
})
.addCase(getKanyeQuote.rejected, state => {
state.pending = false;
state.error = true;
});
});
export default kanyeReducer;
Once this is done, let’s add this reducer to our store, which will then look like this:
import {
Action,
configureStore,
ThunkAction,
} from '@reduxjs/toolkit';
import { counterReducer } from '../features/counter';
import { kanyeReducer } from '../features/kanye';
export const store = configureStore({
reducer: {
counter: counterReducer,
kanyeQuote: kanyeReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
This then gives us the ability to add the below code in selectors.ts.
import { RootState } from "../../app/store";
import { createSelector } from '@reduxjs/toolkit';
export const selectQuote = (state: RootState) => state.kanyeQuote
export const kanyeQuoteSelector = createSelector(
selectQuote,
state => state
)
Great! We are all set to move onto our kanye.ts page. Once there, all we have to do is update the imports of the actions and selector, since we no longer have a kanyeSlice.ts.
import React from 'react';
import {
useAppDispatch,
useAppSelector,
} from '../app/hooks';
import {
getKanyeQuote,
kanyeQuoteSelector,
} from '../features/kanye';
const kanye:React.FC = () => {
const dispatch = useAppDispatch();
const {
data,
pending,
error,
} = useAppSelector(kanyeQuoteSelector);
return (
<div>
<h2>Generate random Kanye West quote</h2>
{pending && <p>Loading...</p>}
{data && <p>{data.quote}</p>}
{error && <p>Oops, something went wrong</p>}
<button onClick={() => dispatch(getKanyeQuote())} disabled={pending}>
Generate Kanye Quote
</button>
</div>
);
};
export default kanye;
That is how you can structure the Redux Toolkit in a manner that makes writing the Redux logic easy, clear and more manageable.
Looking forward to taking a look at a case study where Redux was used? Check out our work here!
Ready for more Redux Toolkit insights?
I won’t keep you any longer since I believe that we achieved the goal we set out to achieve: understanding the basics of Redux Toolkit. Now that you have covered the basics, it's worth taking a look at the official documentation for more information. They also have some great starter templates for CRA and Next.js, if you’d like to take a look.
If you liked what you read, stay tuned! We've got a lot more of these coming up! In the meantime, feel free to check out our additional reading list:
- Redux vs Context vs Local Component State - state management solutions for React
- State Management with React Hooks
Feel like taking on a new professional challenge? Join us as a JavaScript Developer!
Navigate the changing IT landscape
Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .