NextJs meets Redux: A simple user PoC
What is Redux, and why we use it?
Redux is a powerful state management library primarily used in JavaScript applications, particularly those built with frameworks like React. At its core, Redux provides a predictable state container for managing the state of an application in a more organised and centralised manner. It operates on a unidirectional data flow model, which helps in maintaining the consistency of application state and facilitates easier debugging and testing.
Redux is used for several reasons:
- It promotes a single source of truth for application state, meaning that all the state of an application is stored in a single object called the Redux store. This makes it easier to manage and access the state from different components throughout the application, reducing the chances of inconsistencies or conflicts between different parts of the application.
- It facilitates predictable state changes through the use of pure functions called reducers. Reducers take the current state and an action as input and return a new state based on that action. This deterministic approach to state changes makes it easier to understand how the state of an application evolves over time, making debugging and reasoning about the application’s behaviour more straightforward.
- It enables efficient data flow and communication between different components of an application. By dispatching actions to update the state, components can stay decoupled from each other, leading to a more modular and maintainable codebase.
Redux Elements
1. Store
A Redux Store is the global state tree of the application in which all the global states of the app are stored, check out more about Redux Stores.
2. Actions
The Redux Actions are the operations (events) to be performed on the store. A set of update changes, Know more about Redux Actions.
3. Reducers
Redux Reducers are functions that take a state and an action as arguments and returns a new state which will be overriding the previous one.
Example
In a Counter App, the store will have a counter state (with number value), an action incrementing/decrementing the value, two reducers to handle the actions and return a new state for each action.
Note: You can have as much states, actions and reducers you need.
Project Setup
For this project, we’ll be using:
- NextJs version 14.1.0 with React version 18.
- react-redux version 9.1.0.
- @reduxjs/toolkit version 2.2.1.
Let’s install all the dependencies starting with the new Next project:
$ npx create-next-app@latest nextjs-redux-poc-user-profile
I’ll be using TypeScript and tailwind css, you can config that while executing the previous command.
Now, let’s install the redux dependencies:
$ npm i react-redux @reduxjs/toolkit
The redux toolkit will help us setup the redux elements with less boilerplate code. You can know more about the Redux Toolkit here.
Setting up Redux Store
In a folder called “redux”, create a file under the name “store.ts”:
import {configureStore} from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {
// since we don't have any slice reducers, will pass an empty object.
}
})
// we need to export the reture type of the getState() using the TS's ReturnType Utility.
export type RootState = ReturnType<typeof store.getState>;
// Also we need to export the type of the store dispatch as well.
export type AppDispatch = typeof store.dispatch;
The dispatch a function of the Redux store which we call to dispatch an action and eventually trigger a state change.
Note: As previously stated, the Redux doesn’t access and change the state directly, it always creates a new version with the new changes and overrides the old one.
Now, we create a slice which is a collection of reducer logic and actions for a single feature in your app, in our redux/user/userSlice.ts file will define the user feature we need to create, starting with a simple typescript interface shaping the structure of the user state:
interface UserState {
name: string;
email: string;
isVerified: boolean;
isLoading: boolean;
}
Then we need to define the initial value of our state:
const initialState: UserState = {
name: 'n/a',
email: 'n/a',
isVerified: false,
isLoading: false // The loading will be used for the async behaviour
}
Now it’s time to create our slice, in which will define our actions and reducers:
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
const userSlice = createSlice({
name: 'userSlice',
initialState, // The user initial state.
reducers: {
/*
The verify reducer is using the current state as an argument and perfoming
a logic to create a new state with new changes.
*/
verify: (state: UserState) => {
state.isVerified = true;
},
/*
Just like the verify reducer, the edit name and email reducers are creating a new state with
the new name, also the value of the name is retreived from the action payload.
*/
editName: (state: UserState, action: PayloadAction<string>) => {
state.name = action.payload;
},
editEmail: (state: UserState, action: PayloadAction<string>) => {
state.email = action.payload;
}
}
});
We need to export the actions and the slice reducer:
export const { editName, editEmail, verify } = userSlice.actions;
export default userSlice.reducer;
Next, we add our slice reducer to the store, the store.ts file should look like this:
import {configureStore} from "@reduxjs/toolkit";
import userReducer from "@/redux/user/userSlice";
export const store = configureStore({
reducer: {
user: userReducer
}
})
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Since, React cannot communicate directly with the Redux store, we need to add the Redux provider at the root our app:
'use client';
import { store } from "@/redux/store";
import { Provider } from "react-redux";
export default function Home() {
return (
<Provider store={store}>
<h1>Home</h1>
</Provider>
);
}
Setting up the UI
For the UI, will be having a simple user edit and profile sections.
Let’s create a user profile component, in the components folder, create a file called UserProfile.tsx with the following code:
'use client';
import { RootState } from "@/redux/store";
import { useSelector } from "react-redux";
export const UserProfile = () => {
// The useSelector hook is used to retrieve the state from the store.
const user = useSelector((state: RootState) => state.user);
// Show a status with a specific status based on the user.isVerified value
const showStatus = () => {
return <span className={`text-${user.isVerified?'green':'red'}-500`}>{user.isVerified?'Verified':'Unverified'}</span>;
}
return (
<div className=" w-full ">
<h1 className="text-xl">Name: {user.name}</h1>
<h1 className="text-xl">Email: {user.email}</h1>
<h1 className="text-xl">Status: {showStatus()}</h1>
</div>
);
}
Next let’s create the UserEdit.tsx component:
import { AppDispatch } from "@/redux/store";
import { editEmail, editName, verify} from "@/redux/user/userSlice";
import { useRef } from "react";
import { useDispatch } from "react-redux";
export const UserEdit = () => {
// The useDispatch hook is used to dispatch the redux actions.
// Note: the AppDispatch is not required for sync actions.
const dispatch = useDispatch<AppDispatch>();
// Just a simple two refs to retrieve the values of the inputs.
const nameInputRef = useRef<HTMLInputElement>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
return (
<div className="w-full flex flex-col gap-5">
<div className="flex flex-row gap-2 w-full">
<input ref={nameInputRef} type="text" className="w-2/3 bg-gray-50 border border-gray-300 block p-2.5 rounded" placeholder="New Name" required />
<button className="text-white w-1/3 bg-blue-700 hover:bg-blue-800 font-medium text-sm px-5 py-2.5 text-cente rounded"
onClick={() => {
/*
On the click we check if the current ref is not null (type check)
then we dispatch the editName action with the new name as its payload.
*/
if (nameInputRef.current?.value == null) return;
dispatch(editName(nameInputRef.current.value))
}}
>Edit Name</button>
</div>
<div className="flex flex-row gap-2 w-full">
<input ref={emailInputRef} type="text" className="w-2/3 bg-gray-50 border border-gray-300 block p-2.5 rounded" placeholder="New Email" required />
<button className="text-white w-1/3 bg-blue-700 hover:bg-blue-800 font-medium text-sm px-5 py-2.5 text-cente rounded"
onClick={() => {
if (emailInputRef.current?.value == null) return;
dispatch(editEmail(emailInputRef.current.value))
}}
>Edit Email</button>
</div>
<button className="text-white w-full bg-blue-700 hover:bg-blue-800 font-medium text-sm sm:w-auto px-5 py-2.5 text-cente rounded"
onClick={() => {
/*
Just like the editName and editEmail we dispatch the verify event
only this time without a payload.
*/
dispatch(verify());
}}
>Verify</button>
</div>
);
}
All we need now is to add the two components to the main page (app/page.tsx), let’s create a components/index.ts file:
export {UserProfile} from './UserProfile';
export {UserEdit} from './UserEdit';
Our page.tsx should look like this:
'use client'; // So the page will be rendered from the client side.
import { UserEdit, UserProfile } from "@/components";
import { store } from "@/redux/store";
import { Provider } from "react-redux";
export default function Home() {
return (
<Provider store={store}>
<div className="flex flex-row w-screen mx-20 justify-center mt-20 gap-10">
<UserEdit />
<UserProfile />
</div>
</Provider>
);
}
When we run our app using the following command:
$ npm run dev
We should be a having a similar page on the http://localhost:3000
Setting up Async operations
As you saw, the operations we performed are synchronous, in real-world use cases we must deal with async operations such as Api calls and IO processes.
Setting up an async action is the next step, it will focus on the “unverification”, we turn the user status from verified to unverified.
In the userSlice.ts file, we create and export the action:
export const unverifyAsync = createAsyncThunk(
"userSlice/unverifyAsync",
async () => {
// Simulating the time we wait for a real async operation.
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2s
}
);
Then in the userSlice const, we can add the async action using the extraReducers property.
extraReducers: builder => {
/* The async action is exposing 3 status which we will need to handle
its behaviour: the pending (when loading), rejected (when error) and fulfilled
(when success).
Also for this one, we can use the action as argument and retreive the payload
is we need to.
*/
builder
.addCase(unverifyAsync.pending, (state: UserState) => {
state.isLoading = true;
})
.addCase(unverifyAsync.fulfilled, (state: UserState) => {
state.isVerified = false
state.isLoading = false;
});
}
In the UserEdit.tsx, we need to add another button which will dispatch the async action we just created:
<button className="text-white w-full bg-blue-700 hover:bg-blue-800 font-medium text-sm sm:w-auto px-5 py-2.5 text-cente rounded"
onClick={() => {
// Just like we did before, we dispatch the async action the same way.
dispatch(unverifyAsync());
}}
>Unverify (Async)</button>
Also on the UserProfile.tsx, we need to make use of the user.isLoading property:
const showStatus = () => {
if (user.isLoading){
return <span>Loading...</span>
}
return <span className={`text-${user.isVerified?'green':'red'}-500`}>{user.isVerified?'Verified':'Unverified'}</span>;
}
After the new tweaks, we should be having a similar page:
Finally,
As a personal opinion, I believe Redux is huge help when it comes to state management, especially in large and complex projects, this article only serves as a simple getting-started PoC :))
Please, find the source code repository here.