Skip to main content

Next.js Redux Integration

Introduction

Redux is a powerful state management library that helps you manage the global state of your application in a predictable way. When combined with Next.js, it provides a robust solution for handling complex state across your application. This guide will walk you through integrating Redux with your Next.js project, from basic setup to advanced patterns.

Redux follows a unidirectional data flow and is based on three main principles:

  • A single source of truth (the store)
  • State is read-only
  • Changes are made with pure functions (reducers)

By the end of this guide, you'll understand how to implement Redux in your Next.js applications and efficiently manage global state.

Prerequisites

Before we begin, make sure you have:

  • Basic knowledge of Next.js and React
  • A Next.js project set up
  • Node.js and npm installed

Setting Up Redux in Next.js

Step 1: Install Required Packages

First, we need to install Redux and related packages:

bash
npm install redux react-redux @reduxjs/toolkit next-redux-wrapper
  • redux: The core Redux library
  • react-redux: React bindings for Redux
  • @reduxjs/toolkit: Official toolset for Redux development
  • next-redux-wrapper: Helper for using Redux with Next.js

Step 2: Create a Redux Store

Create a store folder in your project structure:

/src
/store
/slices
counterSlice.js
index.js

First, let's create a simple counter slice using Redux Toolkit:

javascript
// src/store/slices/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

Now, let's create our Redux store with the next-redux-wrapper:

javascript
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { createWrapper } from 'next-redux-wrapper';
import counterReducer from './slices/counterSlice';

const makeStore = () =>
configureStore({
reducer: {
counter: counterReducer,
},
devTools: process.env.NODE_ENV !== 'production',
});

export const wrapper = createWrapper(makeStore);

Step 3: Integrate with Next.js

Wrap your application with the Redux provider by updating your _app.js file:

javascript
// pages/_app.js
import { wrapper } from '../src/store';

function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}

export default wrapper.withRedux(MyApp);

Using Redux in Next.js Components

Now that we have Redux integrated with our Next.js application, let's see how to use it in components.

Basic Usage Example

Create a simple counter component:

javascript
// components/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../src/store/slices/counterSlice';

const Counter = () => {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();

return (
<div>
<h2>Counter: {count}</h2>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
</div>
);
};

export default Counter;

Use this component in a page:

javascript
// pages/counter-example.js
import Counter from '../components/Counter';

const CounterPage = () => {
return (
<div>
<h1>Redux Counter Example</h1>
<Counter />
</div>
);
};

export default CounterPage;

Handling Server-side Rendering with Redux

One of the challenges of using Redux with Next.js is handling server-side rendering (SSR). The next-redux-wrapper package helps solve this by allowing you to initialize and populate your store on the server.

Here's how you can use Redux with getServerSideProps:

javascript
// pages/server-counter.js
import { useSelector } from 'react-redux';
import { wrapper } from '../src/store';
import { incrementByAmount } from '../src/store/slices/counterSlice';

const ServerCounter = () => {
const count = useSelector((state) => state.counter.value);

return (
<div>
<h1>Server Rendered Counter</h1>
<p>Counter value: {count}</p>
</div>
);
};

export const getServerSideProps = wrapper.getServerSideProps(
(store) => async () => {
// Dispatch an action to update the store on the server
store.dispatch(incrementByAmount(5));

return {
props: {},
};
}
);

export default ServerCounter;

When you visit the /server-counter page, the counter will be pre-populated with a value of 5 from the server.

Advanced Redux Patterns in Next.js

Implementing Async Actions with Redux Toolkit

Redux Toolkit provides createAsyncThunk for handling asynchronous actions. Let's create a new slice for fetching user data:

javascript
// src/store/slices/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const data = await response.json();
return data;
} catch (error) {
return rejectWithValue(error.message);
}
}
);

const userSlice = createSlice({
name: 'users',
initialState: {
users: [],
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});

export default userSlice.reducer;

Update the store to include the new reducer:

javascript
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { createWrapper } from 'next-redux-wrapper';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';

const makeStore = () =>
configureStore({
reducer: {
counter: counterReducer,
users: userReducer,
},
devTools: process.env.NODE_ENV !== 'production',
});

export const wrapper = createWrapper(makeStore);

Create a component to display users:

javascript
// components/UserList.js
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from '../src/store/slices/userSlice';

const UserList = () => {
const { users, loading, error } = useSelector((state) => state.users);
const dispatch = useDispatch();

useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;

return (
<div>
<h2>User List</h2>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
};

export default UserList;

Hydration with Redux and Next.js

Next.js hydration can sometimes cause issues with Redux, particularly with mismatched state between server and client. Here's how to handle hydration properly:

javascript
// src/store/index.js
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { createWrapper, HYDRATE } from 'next-redux-wrapper';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';

const combinedReducer = combineReducers({
counter: counterReducer,
users: userReducer,
});

const rootReducer = (state, action) => {
if (action.type === HYDRATE) {
const nextState = {
...state,
...action.payload,
};
return nextState;
} else {
return combinedReducer(state, action);
}
};

const makeStore = () =>
configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production',
});

export const wrapper = createWrapper(makeStore);

This setup ensures that server-side state is properly merged with client-side state during hydration.

Real-World Example: Shopping Cart

Let's create a more practical example of a shopping cart using Redux in Next.js:

javascript
// src/store/slices/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
totalQuantity: 0,
totalAmount: 0,
},
reducers: {
addItemToCart: (state, action) => {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);

if (!existingItem) {
state.items.push({
id: newItem.id,
name: newItem.name,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}

state.totalQuantity++;
state.totalAmount += newItem.price;
},
removeItemFromCart: (state, action) => {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);

if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price;
}

state.totalQuantity--;
state.totalAmount -= existingItem.price;
},
clearCart: (state) => {
state.items = [];
state.totalQuantity = 0;
state.totalAmount = 0;
}
},
});

export const { addItemToCart, removeItemFromCart, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

Add the cart reducer to the store:

javascript
// src/store/index.js
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { createWrapper, HYDRATE } from 'next-redux-wrapper';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import cartReducer from './slices/cartSlice';

const combinedReducer = combineReducers({
counter: counterReducer,
users: userReducer,
cart: cartReducer,
});

const rootReducer = (state, action) => {
if (action.type === HYDRATE) {
return {
...state,
...action.payload,
};
} else {
return combinedReducer(state, action);
}
};

const makeStore = () =>
configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production',
});

export const wrapper = createWrapper(makeStore);

Create components for the shopping cart:

javascript
// components/ProductItem.js
import { useDispatch } from 'react-redux';
import { addItemToCart } from '../src/store/slices/cartSlice';

const ProductItem = ({ product }) => {
const dispatch = useDispatch();

const addToCartHandler = () => {
dispatch(addItemToCart(product));
};

return (
<div className="product-item">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={addToCartHandler}>Add to Cart</button>
</div>
);
};

export default ProductItem;
javascript
// components/Cart.js
import { useSelector, useDispatch } from 'react-redux';
import { removeItemFromCart, clearCart } from '../src/store/slices/cartSlice';

const Cart = () => {
const { items, totalQuantity, totalAmount } = useSelector((state) => state.cart);
const dispatch = useDispatch();

if (items.length === 0) {
return <div className="cart">Your cart is empty.</div>;
}

return (
<div className="cart">
<h2>Your Cart</h2>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} x {item.quantity} (${item.totalPrice.toFixed(2)})
<button onClick={() => dispatch(removeItemFromCart(item.id))}>Remove</button>
</li>
))}
</ul>
<div className="cart-summary">
<p>Total Items: {totalQuantity}</p>
<p>Total Amount: ${totalAmount.toFixed(2)}</p>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
</div>
</div>
);
};

export default Cart;

Create a shop page:

javascript
// pages/shop.js
import { useState } from 'react';
import ProductItem from '../components/ProductItem';
import Cart from '../components/Cart';

const products = [
{ id: 'p1', name: 'Product 1', price: 9.99 },
{ id: 'p2', name: 'Product 2', price: 19.99 },
{ id: 'p3', name: 'Product 3', price: 14.99 },
{ id: 'p4', name: 'Product 4', price: 29.99 },
];

const ShopPage = () => {
return (
<div className="shop-page">
<h1>Shop</h1>
<div className="products-grid">
{products.map((product) => (
<ProductItem key={product.id} product={product} />
))}
</div>
<Cart />
</div>
);
};

export default ShopPage;

Performance Optimization

Using Selectors

For optimal performance, use selector functions with useSelector to prevent unnecessary re-renders:

javascript
// Improved selector usage
import { createSelector } from '@reduxjs/toolkit';

export const selectCartItems = state => state.cart.items;
export const selectCartTotalQuantity = state => state.cart.totalQuantity;
export const selectCartTotalAmount = state => state.cart.totalAmount;

// Create memoized selector for cart summary
export const selectCartSummary = createSelector(
[selectCartTotalQuantity, selectCartTotalAmount],
(totalQuantity, totalAmount) => ({
totalQuantity,
totalAmount
})
);

// In your component
const CartSummary = () => {
const { totalQuantity, totalAmount } = useSelector(selectCartSummary);

return (
<div className="cart-summary">
<p>Total Items: {totalQuantity}</p>
<p>Total Amount: ${totalAmount.toFixed(2)}</p>
</div>
);
};

Persist Redux State

You can persist your Redux state across page refreshes using redux-persist:

bash
npm install redux-persist

Update your store configuration:

javascript
// src/store/index.js
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { createWrapper, HYDRATE } from 'next-redux-wrapper';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import cartReducer from './slices/cartSlice';

const persistConfig = {
key: 'root',
storage,
whitelist: ['cart'], // Only persist cart
};

const combinedReducer = combineReducers({
counter: counterReducer,
users: userReducer,
cart: cartReducer,
});

const rootReducer = (state, action) => {
if (action.type === HYDRATE) {
return {
...state,
...action.payload,
};
} else {
return combinedReducer(state, action);
}
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const makeStore = () => {
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
devTools: process.env.NODE_ENV !== 'production',
});

store.__persistor = persistStore(store);
return store;
};

export const wrapper = createWrapper(makeStore);

Update your _app.js:

javascript
// pages/_app.js
import { PersistGate } from 'redux-persist/integration/react';
import { useStore } from 'react-redux';
import { wrapper } from '../src/store';

function MyApp({ Component, pageProps }) {
const store = useStore();

return (
<PersistGate persistor={store.__persistor} loading={<div>Loading...</div>}>
<Component {...pageProps} />
</PersistGate>
);
}

export default wrapper.withRedux(MyApp);

Summary

In this comprehensive guide, we've covered:

  1. Setting up Redux with Next.js using Redux Toolkit and next-redux-wrapper
  2. Creating and managing Redux state in a Next.js application
  3. Handling server-side rendering with Redux
  4. Implementing async actions with Redux Toolkit
  5. Building a practical shopping cart example
  6. Optimizing Redux performance with selectors and persistence

Redux provides a powerful and predictable way to manage state in your Next.js applications. While it adds some complexity compared to simpler state management solutions, its benefits become clear as your application grows in size and complexity.

Additional Resources

Exercises

  1. Basic: Add a feature to the counter application that allows users to reset the counter to zero.
  2. Intermediate: Extend the shopping cart to support product categories and filtering.
  3. Advanced: Implement user authentication with Redux, including login state persistence.

By completing these exercises, you'll gain practical experience with Redux in Next.js and deepen your understanding of state management patterns.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)