Skip to content

State management

Updated: at 09:28 AM

There are many ways to deal with state in Next.js but I feel there is also a lot of confusion when you have the crossover of server components and client components. Although each scenario is different - I do feel if developers used state in the URL more it would enable a much better user experience.

Table of contents

Open Table of contents

Comparing state

useState & useReducer

Firstly we have the useState and useReducer hooks, these will be the most useful tools when it comes to managing state especially useState. For any type of complex state useReducer does come handy as an example of this would be a traffic light system - if they want the cars to stop then it would be red only and yellow and green would need to be false. Then if they wanted the drivers to prepare to go then they would need red and yellow to be true and green to still be false. Then lastly once they want the cars to proceed then red and yellow will be false and green will be true.

Redux

Next up we have a popular but outdated state management tool which is redux - this uses useReducer under the hood that uses stores, reducers and actions to enable the developer to effectively manage state. One problem with using this is the fact it takes much longer to setup compared to other options as it uses a lot of boilerplate code.

Zustand

Then my personal favourite zustand which is modern and lightweight and is very user friendly as it doesn’t take too long to setup and also uses the stores pattern which the developer can create as many as they want for specific parts of their application - this means that it won’t render components that don’t use the changed state. This hook mainly relies on using useState and useEffect.

useContext

This hook should be used to state that rarely changes and the reason for that is because it needs to be wrapped around your whole application which means once you changed a value inside useContext your application will need to be re-rendered - therefore this is best used for changing themes or languages. As it’s not a complete state management solution you may also need to use useState or useReducer inside the context as it’s only used for passing values.

Recoil & Jotai

Both of these are modern and lightweight with not too much boilerplate to setup. These are also very similar as they both use atoms which hold individual pieces of state compared to stores which is a container of multiple states. Therefore this makes these both small and fine-grained and would be useful for smaller applications.

React-Query

This is a powerful library for caching and syncing server state. It helps cache data which optimises and improves the web application as it prevents multiple fetches and also has built-in features such as isLoading state and also refetches data which keeps data fresh.

URL params

A great way to store data as it’s easily saved and shared to other users, this is very useful for e-commerce sites as for example if the user has a specific search and wants to save that search then they could save the URL as www.ecommerce.co.uk/products?category=jackets&colour=black&size=m and once accessed this will directly send the user to the specific search. The major difference this type of state management has over the others is the fact it can be used on the server-side in Next.js using the nuqs library.

State options

OptionsDescription
useState & useReducerMost popular.
ReduxComplex to setup, uses the reducer pattern.
ZustandUser friendly, uses stores.
useContextUsed for rarely changed values.
Recoil & JotaiUses atoms - aimed at small applications.
React-QueryCaching library that syncs server state.
URL paramsEasily saved and sharable - can be used on the server-side.

Examples

useState

Firstly for useState we have a simple counter and a person which contains multiple values.

import { useState } from "react";

const [count, setCounter] = useState(0);

const changeNumber = () => {
  setCounter(prev => prev + 1);
};
import { useState } from 'react'

type PersonType = {
  name: string
  age: number
  height: number
  gender: string
}

const [person, setPerson] = useState<PersonType>({name: "", age: 0, height: 0, gender: ""})

const changePerson = (name: string, age: number, height: number, gender: string) => {
  setPerson({name, age, height, gender})
}

useReducer

For the useReducer We will create a traffic light system. We create the function in a seperate component to make the code cleaner and easier to read. We identify the initial state as ‘red’ then we include a switch statement to accurately process each step.

export type LightState = "red" | "red-yellow" | "yellow" | "green";

export const initialState: LightState = "red";

export const trafficLights = (
  state: LightState,
  action: { type: "NEXT" }
): LightState => {
  switch (state) {
    case "red":
      return "red-yellow";
    case "red-yellow":
      return "green";
    case "green":
      return "yellow";
    case "yellow":
      return "red";
    default:
      return state;
  }
};

We will then import it and use the useEffect hook to demonstrate the process of the lights. We create a setTimeout function to change the lights but if the light includes yellow then it will change in 3 seconds compared to the usual 5 seconds. We then map over the three possible colours and create a simple traffic light system.

"use client";
import { useEffect, useReducer } from "react";
import { initialState, trafficLights } from "./imports";

export default function page() {
  const [light, dispatch] = useReducer(trafficLights, initialState);

  const colours = ["red", "yellow", "green"];

  useEffect(() => {
    const time = light.includes("yellow") ? 3000 : 5000;
    setTimeout(() => {
      dispatch({ type: "NEXT" });
    }, time);
  }, [light]);

  return (
    <div className="h-screen flex-center flex-col gap-1">
      {colours.map((col: string) => (
        <div
          key={col}
          className={`h-4 w-4 rounded-full border border-gray-300 p-4 ${
            light.includes(col) ? `bg-${col}-500` : ""
          }`}
        ></div>
      ))}
    </div>
  );
}

The outcome: Traffic Lights

Redux

Firstly we will need to install the dependencies, once that is completed we then have to create the slice and store then we need to wrap the provider around the App (react) or Layout (next.js) and then we can change the values in the component.

npm install @reduxjs/toolkit react-redux
// store/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
};

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

export const { increment, decrement, setCounter } = counterSlice.actions;

export default counterSlice.reducer;
// store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";

const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export default store;

React:

// pages/_app.tsx
import { Provider } from 'react-redux';
import store from '../store/store';

function MyApp({ Component, pageProps }: any) {
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  );
}

export default MyApp;

Next.js:

// app/layout.tsx
import { Provider } from 'react-redux';
import store from '../store/store';

// Layout component
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <Provider store={store}>
      <html lang="en">
        <body>{children}</body>
      </html>
    </Provider>
  );
}
// pages/index.tsx
import { useDispatch, useSelector } from 'react-redux';
import { increment, decrement, setCounter } from '../store/slice';

const Home = () => {
  const dispatch = useDispatch();
  const counter = useSelector((state: any) => state.counter.value);

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
      <button onClick={() => dispatch(setCounter(100))}>Set to 100</button>
    </div>
  );
};

export default Home;

Zustand

This is fairly easy to setup as you just need to create a store and import it into the component.

import { create } from "zustand";

export type ToastType = {
  toastMsg: string;
  setToastMsg: (value: string) => void;
};

export const useStoreToastMsg = create<ToastType>((set) => ({
  toastMsg: "",
  setToastMsg: (tst) => set({ toastMsg: tst }),
}));
const { toastMsg, setToastMsg } = useStoreToastMsg();

useContext

Also fairly easy to setup but should only be used for state that doesn’t change often. We need to create a ThemeProvider then wrap this around your app (react) or layout (next.js)

// ThemeContext.tsx
import { createContext, useState, useContext } from "react";

type ThemeContextType = {
  theme: string;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
};

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState("light");

  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// app/layout.tsx (Next.js)
import { ThemeProvider } from "../context/ThemeContext";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ThemeProvider>
  );
}

// components/ThemeToggle.tsx
import { useTheme } from "../context/ThemeContext";

export default function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className="rounded border p-2"
      style={{
        background: theme === "light" ? "#fff" : "#333",
        color: theme === "light" ? "#000" : "#fff",
      }}
    >
      Toggle Theme (Current: {theme})
    </button>
  );
}

Recoil & Jotai

Both of these use atoms for state and the boilerplate to setup is similar to each other.

npm install recoil
// app/layout.tsx
"use client";

import { RecoilRoot } from "recoil";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <RecoilRoot>
      <html lang="en">
        <body>{children}</body>
      </html>
    </RecoilRoot>
  );
}

// state/counterAtom.ts
import { atom } from "recoil";

export const counterAtom = atom({
  key: "counter",
  default: 0, // Initial state
});
// components/RecoilCounter.tsx
"use client"; // Required for Next.js App Router

import { useRecoilState } from "recoil";
import { counterAtom } from "../state/counterAtom";

export default function RecoilCounter() {
  const [count, setCount] = useRecoilState(counterAtom);

  return (
    <div className="text-center">
      <h2>Recoil Counter: {count}</h2>
      <button
        onClick={() => setCount(count + 1)}
        className="rounded bg-blue-500 p-2 text-white"
      >
        +
      </button>
      <button
        onClick={() => setCount(count - 1)}
        className="rounded bg-red-500 p-2 text-white"
      >
        -
      </button>
    </div>
  );
}

React-Query

We will need to create a simple fetch function and we will pass this into a query using a custom hook. If a user was to navigate someone else on the web application with this query then it will be cached and wouldn’t need to be reloaded.

export const getPostsByUser = async (userId: string | undefined) => {
  const res = await fetch(`${endpoint}/posts/${userId}`)
  return res.json()
}
export const useGetPostsByUser = (user_id: string | undefined) => {
  const { data: posts, isLoading } = useQuery({
    queryKey: ["posts", user_id],
    queryFn: () => getPostsByUser(user_id),
  });
  return { posts, isLoading };
};

const { posts, isLoading } = useGetPostsByUser(userId);
{posts?.map((post: PostType) => (
  <div>
  <p>{post.name}</p>
  <p>{post.description}</p>
  <p>{post.category}</p>
  </div>
))}

URL params

We will be using useRouter for this example, however in the next post we will look deeper into better and cleaner approaches which also allow us to use it server-side.

// app/shop/page.tsx
"use client";

import { useSearchParams, useRouter } from "next/navigation";

export default function ShopPage() {
  const searchParams = useSearchParams();
  const router = useRouter();

  const category = searchParams.get("category") || "all";
  const color = searchParams.get("color") || "any";
  const size = searchParams.get("size") || "any";

  const updateFilter = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams.toString());

    if (value === "any") {
      params.delete(key);
    } else {
      params.set(key, value);
    }

    router.push(`/shop?${params.toString()}`);
  };

  return (
    <div className="p-4">
      <h1 className="text-xl font-bold">Shop Jackets</h1>

      <div className="flex gap-4 mt-4">
        <label>Color:</label>
        <select value={color} onChange={(e) => updateFilter("color", e.target.value)}>
          <option value="any">Any</option>
          <option value="black">Black</option>
          <option value="blue">Blue</option>
          <option value="red">Red</option>
        </select>

        <label>Size:</label>
        <select value={size} onChange={(e) => updateFilter("size", e.target.value)}>
          <option value="any">Any</option>
          <option value="s">S</option>
          <option value="m">M</option>
          <option value="l">L</option>
        </select>
      </div>

      <p className="mt-4">Selected: {category} | {color} | {size}</p>
    </div>
  );
}

Conclusion

This is a brief introduction into each state management patterns which hopefully help developers decide which is better for each use-case. My next post will look into URL params deeper and use one of my favourite packages nuqs which allow us to use state management server-side.


Previous Post
URL params - nuqs
Next Post
Custom hooks