
How to manage state in a Next.js app?
State management is a critical aspect of building modern web applications. In Next.js, you have multiple options for managing state, each suited for different use cases. Whether you're handling client-side state, server-side state, or global application state, choosing the right approach ensures scalability, performance, and maintainability.
This guide explores the best practices for state management in Next.js, covering:
-
Client-Side State Management
- React’s
useState
anduseReducer
- Context API
- Zustand
- Jotai
- Redux
- React’s
-
Server-Side State Management
- Data Fetching in Next.js (
getStaticProps
,getServerSideProps
) - React Query
- SWR
- Data Fetching in Next.js (
-
URL-Based State Management
- Query Parameters
- Next.js Router
-
Choosing the Right State Management Solution
The end of this guide, you’ll understand how to efficiently manage state in Next.js applications while optimizing performance and developer experience.
Client-Side State Management
Client-side state refers to data that persists only in the browser and doesn’t require server interaction. Next.js supports all React state management solutions, so you can choose based on complexity and scalability needs.
React’s Built-in State Hooks (useState
and useReducer
)
For simple local state, React’s useState
is the easiest solution.
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
For more complex state logic, useReducer
provides better control:
import { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>Increment</button>
<button onClick={() => dispatch({ type: "decrement" })}>Decrement</button>
</div>
);
}
When to use:
- Simple component-level state (
useState
) - Complex state transitions (
useReducer
)
Context API for Global State
The Context API is useful for sharing state across multiple components without prop drilling.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
Wrap your app with ThemeProvider
in _app.js
:
import { ThemeProvider } from "../context/ThemeContext";
export default function App({ Component, pageProps }) {
return (
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
);
}
When to use:
- Medium-sized apps with limited global state
- Avoiding prop drilling
Zustand for Lightweight State Management
Zustand is a minimal state management library that avoids boilerplate.
import create from "zustand";
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
export default function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}
When to use:
- Lightweight global state without Context API overhead
Jotai for Atomic State
Jotai provides atomic state management, ideal for fine-grained reactivity.
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
export default function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
When to use:
- Optimized re-renders with atomic updates
Redux for Large-Scale Applications
Redux remains a robust solution for complex state management.
import { Provider, useSelector, useDispatch } from "react-redux";
import { configureStore, createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
});
const store = configureStore({ reducer: counterSlice.reducer });
export default function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(counterSlice.actions.increment())}>
Increment
</button>
</div>
);
}
When to use:
- Large applications with complex state logic
- Middleware requirements (e.g., Redux Thunk, Redux Saga)
Server-Side State Management
Next.js enables server-side rendering (SSR), static site generation (SSG), and incremental static regeneration (ISR). Managing server-side state efficiently ensures fast page loads and SEO benefits.
Data Fetching in Next.js (getStaticProps
, getServerSideProps
)
For static and server-rendered pages, use:
export async function getStaticProps() {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
return { props: { data } };
}
export default function Page({ data }) {
return <div>{data.title}</div>;
}
When to use:
- Pre-rendering pages at build time (
getStaticProps
) - Server-side rendering per request (
getServerSideProps
)
React Query for Server-State Synchronization
React Query simplifies fetching, caching, and updating server state.
import { useQuery } from "@tanstack/react-query";
async function fetchPosts() {
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default function Posts() {
const { data, isLoading, error } = useQuery(["posts"], fetchPosts);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
When to use:
- Managing asynchronous server state
- Background data refetching and caching
SWR for Data Fetching
Vercel’s SWR is another lightweight data-fetching library.
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((res) => res.json());
export default function Profile() {
const { data, error } = useSWR("/api/user", fetcher);
if (error) return <div>Failed to load</div>;
if (!data) return <div>Loading...</div>;
return <div>Hello, {data.name}!</div>;
}
When to use:
- Fast, lightweight data fetching
- Built-in caching and revalidation
URL-Based State Management
For state that should persist across page reloads or be shareable via URL, use query parameters.
Using Next.js Router for Query Parameters
import { useRouter } from "next/router";
export default function Search() {
const router = useRouter();
const { query } = router;
const handleSearch = (term) => {
router.push({ query: { ...query, search: term } });
};
return (
<input
type="text"
defaultValue={query.search || ""}
onChange={(e) => handleSearch(e.target.value)}
/>
);
}
When to use:
- Preserving state in the URL (e.g., search filters)
Choosing the Right State Management Solution
Use Case | Recommended Solution |
---|---|
Local component state | useState , useReducer |
Global client state | Context API, Zustand, Jotai |
Large-scale applications | Redux |
Server-side data fetching | getStaticProps , getServerSideProps |
Async server state | React Query, SWR |
URL-persisted state | Next.js Router |
Conclusion
Next.js offers multiple state management strategies, each suited for different scenarios. For simple state, React hooks suffice. For global state, Zustand or Context API works well. Redux remains ideal for complex applications. Server-side state benefits from React Query or SWR, while URL-based state is best managed with Next.js Router.
By selecting the right approach, you ensure a performant, maintainable, and scalable Next.js application. Evaluate your project’s needs and choose accordingly.
Now that you understand state management in Next.js, implement these techniques to build robust, efficient applications. Happy coding! 🚀