How to manage state in Next.js 13 using Redux Toolkit

Written by drogon | Published 2023/08/03
Tech Story Tags: javascript | reactjs | next.js | nextjs | react-redux | redux-toolkit | jwt | rest-api

TLDRvia the TL;DR App

Next.js 13 app router feature plus server and client side components to some extent brought changes on how we approach integration and state management using redux toolkit. This tutorial is meant to take you through how to add and use redux toolkit in your next.js 13 projects.

We will go through a real world example whereby, we need to store authentication data in redux state and in this case it will be persisted. We will also access this data in the some of the components that need it.

Assumptions

  • You are comfortable writing react functional components using Typescript.

  • You are familiar with redux toolkit - creating slices and reducer functions

  • You are familiar with Next.js 13 app router

  • You are familiar with the node.js package managers, I am using yarn in this case.

  • If you are a complete beginner in next.js or redux toolkit, this tutorial may not be suitable for you, but i have provided all the code to follow along.

Requirements

  • A backend API with login and logout endpoint. To simplify things, i have a simple nest.js backend with a simulation of login and logout functionality in this repo. Clone it, run yarn to install packages , and then run yarn run start:dev. Your are good to go.

  • You also need a next.js13 app, created using the npx create-next-app@latest. Again all the code for this tutorial can be found in this repo to follow along.

Let’s get started

Here is the file structure of the project. Using the app router feature of next.js 13.

Create a Redux Store

We need some packages. These are @reduxjs/toolkit and react-redux. However, i added redux-persist and redux-logger. Only redux-logger is optional, the other three are required for the rest of the tutorial.

yarn add @reduxjs/toolkit react-redux redux-persist redux-logger

After installing the packages let us dive into creating the store.

app/store/index.ts

import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { useDispatch, TypedUseSelectorHook, useSelector } from "react-redux";
import { persistReducer } from "redux-persist";
// import storage from "redux-persist/lib/storage";
import { authReducer } from "./slices/authSlice";
import storage from "./customStorage";
import logger from "redux-logger";

const authPersistConfig = {
  key: "auth",
  storage: storage,
  whitelist: ["isAuth", "jid"],
};

const rootReducer = combineReducers({
  auth: persistReducer(authPersistConfig, authReducer),
});

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({ serializableCheck: false }).concat(logger),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

The code above is responsible for creating the redux store for us. Notice the authPersistConfig definition. This code directs the redux store to persist auth data but only the isAuth and jid keys specified in the whitelist array. Also note the storage am using, its not directly from redux-persist. I have a custom implementation that will take care of execution of the code in client and server side. Using the storage directly from redux-persist results to an error in the server side.

Below is the customStorage implementation. Inspiration from this github thread.

app/store/customStorage.ts

"use client";

import createWebStorage from "redux-persist/lib/storage/createWebStorage";

const createNoopStorage = () => {
  return {
    getItem(_key: any) {
      return Promise.resolve(null);
    },
    setItem(_key: any, value: any) {
      return Promise.resolve(value);
    },
    removeItem(_key: any) {
      return Promise.resolve();
    },
  };
};

const storage =
  typeof window !== "undefined"
    ? createWebStorage("local")
    : createNoopStorage();

export default storage;

Notice that in the storage definition am creating a dummy store since its impossible to create local storage in the server side. By checking existence of the window object i can choose when to create the storage.

Next, we create an authSlice.

app/store/slices/authSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";

export interface IAuthState {
  isAuth: boolean;
  jid: string;
}

const initialState: IAuthState = {
  isAuth: false,
  jid: "",
};

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setAuth: (state, action: PayloadAction<boolean>) => {
      state.isAuth = action.payload;
    },
    setJid: (state, action: PayloadAction<string>) => {
      state.jid = action.payload;
    },
  },
});

// Action creators are generated for each case reducer function
export const { setAuth, setJid } = authSlice.actions;

export const authReducer = authSlice.reducer;

Here we get the actions and reducer to play around with the auth state. Notice the two keys isAuth and jid. These are the ones we persisted in the store.

Last, we need a way to consume the state in our components. Typically, in previous versions of nextjs we could add the provider in the _app.(jsx/tsx) file. In next.js 13 however, with server components being the default for all pages, it can be challenging to have the provider in the root of the app as it is meant to work on the client.

This means we have to initialize the provider separately and wrap it only on client side components that need access to the store.

app/store/ReduxProvider.tsx


import { Provider } from "react-redux";
import { store } from ".";
import { persistStore } from "redux-persist";

persistStore(store); // persist the store

export default function ReduxProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return <Provider store={store}>{children}</Provider>;
}

The code above creates the provider for us. Notice the persistStore line of code. Since we need to persist a section of the state, we need this line before supplying the store to the Provider.

With the provider in place we can move it to client components that need access to the the store.

Create the Next.js 13 Pages

We will look into three pages which are essential to test the functionality we want. ie Home,Login and Account pages. On running the client app,and visiting http://localhost:3000 you will see this simple home page. Nothing fancy, just a navbar and text

Here is the home page code. app/(navbar)/page.tsx

export default function Home() {
  return (
    <div className="p-5">
      <h3>Home</h3>
    </div>
  );
}

Here is the login page code. app/(navbar)/login/page.tsx

import ClientLoginFormWrapper from "./ClientFormWrapper";

export default function Login() {
  return (
    <div className="h-screen w-full flex">
      <div className="m-auto">
        <h1 className="font-bold text-3xl">Login</h1>
        <br />
        <ClientLoginFormWrapper />
      </div>
    </div>
  );
}

app/(navbar)/login/ClientFormWrapper.tsx code. This is a client side component marked with the directive “use client“ at the top of the file. It has functionality that only works in the client side. Notice the <ReduxProvider/> wrapper. It will work in this case since it is in a client component. This allows us to have the redux store in the components it wraps.

"use client";

import ReduxProvider from "@/app/store/ReduxProvider";
import LoginForm from "./Form";

export default function ClientLoginFormWrapper() {
  return (
    <ReduxProvider>
      <LoginForm />
    </ReduxProvider>
  );
}

app/(navbar)/login/LoginForm.tsx this file has the login form code. Two html input fields and a submit button with both the onchange and onsubmit handlers.

"use client";

import { useAppDispatch } from "@/app/store";
import { setAuth, setJid } from "@/app/store/slices/authSlice";
import { useRouter } from "next/navigation";
import { useState } from "react";

// client side to add interactivity using local state
export default function LoginForm() {
  const [loading, setLoading] = useState(false);
  const [values, setValues] = useState({ username: "", password: "" });
  const router = useRouter();

  const dispatch = useAppDispatch();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues({ ...values, [name]: value });
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    try {
      const res = await fetch("http://localhost:3002/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
        credentials: "include",
        mode: "cors",
      });
      const data = await res.json();
      if (!res.ok) throw new Error(data.message);
      dispatch(setJid(data.access_token));
      dispatch(setAuth(true));
      router.push("/account");
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <div>
          <label htmlFor="username">Username</label>
        </div>
        <input
          name="username"
          id="username"
          type="text"
          className="w-full border-2 border-gray-900"
          onChange={handleChange}
          value={values.username}
        />
      </div>
      <br />
      <div>
        <div>
          <label htmlFor="password">Password</label>
        </div>
        <input
          name="password"
          type="password"
          className="w-full border-2 border-gray-900"
          onChange={handleChange}
          value={values.password}
        />
      </div>
      <br />
      <div>
        <button type="submit" className="bg-blue-700 p-2 text-white font-bold">
          Login
        </button>
      </div>
    </form>
  );
}

Lets take a look at the onSubmit handler. The login endpoint returns a response object with an access_token key. In this component note that i can access the redux store dispatch method since it is wrapped by the ReduxProvider. I can therefore dispatch the token to the store where it is then persisted. Am also dispatching true value to update the isAuth state. After successful login, using the router i redirect user to the account page.

With the nest.js backend above running, you can type any dummy username and password and this will take you to the account page. Here is the code. app/(navbar)/account/page.tsx

import ClientStuff from "./ClientStuff";

export default function Account() {
  return (
    <div className="p-5">
      <ClientStuff />
    </div>
  );
}

app/(navbar)/account/ClientStuff.tsx this is a client component that fetches user data from the backed and shows a welcome message. Notice we can use hooks in this component as it is client component.

"use client";

import { store, useAppSelector } from "@/app/store";
import { useEffect, useState } from "react";

export default function ClientStuff() {
  const [loading, setLoading] = useState(true);
  const [username, setUsername] = useState("");
  const jid = store.getState().auth.jid;

  useEffect(() => {
    const getData = async () => {
      try {
        const res = await fetch("http://localhost:3002/user/profile", {
          headers: { Authorization: `Bearer ${jid}` },
          mode: "cors",
        });
        const data = await res.json();
        setUsername(data.sub);
        setLoading(false);
      } catch (error) {
        console.log(error);
      }
    };

    getData();
  }, [jid]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h3 className="font-bold text-4xl">Welcome {username}!</h3>
    </div>
  );
}

If the app router is still new to you, you might be a little confused by the file structure am using. Let’s discuss a few things about the app router feature. By default, all next.js 13 projects will have a pages folder with a layout.tsx file. This is how it looks like. It is a function component with children prop ,html and body tags. This is the root layout to be used with all the pages in your applications.

import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

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

Based on your design and requirements, you will make decisions on how you will layout your application. In this application, i needed a navbar that will be used in all pages. I therefore had to use a concept in next.js 13 called route groups. For more information check out the docs. This enabled me to group all the pages that needed the navbar inside the (navbar) folder without affecting the url.

Here is the code in the layout in (navbar) folder. I called it NavLayout

app/(navbar)/layout.tsx

import MainNavbarClientWrapper from "./NavbarClientWrapper";

export default function NavLayout({ children }: { children: React.ReactNode }) {
  return (
    <main>
      <div className="w-full h-16 bg-black">
        <MainNavbarClientWrapper />
      </div>
      {children}
    </main>
  );
}

With this layout all the pages in the (navbar) folder will have the navbar.

app/(navbar)/NavbarClientWrapper.tsx

"use client";

import ReduxProvider from "../store/ReduxProvider";
import MainNavbar from "./Navbar";

export default function MainNavbarClientWrapper() {
  return (
    <ReduxProvider>
      <MainNavbar />
    </ReduxProvider>
  );
}

This is a client component, and hence am able to add the ReduxProvider to wrap the MainNavbar component.

app/(navbar)/Navbar.tsx

"use client";

import Link from "next/link";
import { MouseEvent, useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../store";
import { setAuth, setJid } from "../store/slices/authSlice";
import { usePathname, useRouter } from "next/navigation";


export default function MainNavbar() {
  const isAuth = useAppSelector((state) => state.auth.isAuth);
  const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
  const dispatch = useAppDispatch();
  const pathname = usePathname();
  const router = useRouter();

  useEffect(() => {
    if (isAuth) {
      setIsAuthenticated(true);
    } else {
      setIsAuthenticated(false);
    }
  }, [isAuth]);

  const handleLogout = async (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    await fetch("http://localhost:3002/auth/logout", {
      credentials: "include",
      mode: "cors",
    });
    const inAuthPage = pathname.startsWith("/account");
    if (inAuthPage) {
      router.push("/login");
    }
    dispatch(setJid(""));
    dispatch(setAuth(false));
  };

  return (
    <nav className="flex content-between justify-between w-full h-full px-5 text-white">
      <div className="w-2/3 h-full flex">
        <Link href={"/"} className="my-auto font-bold text-4xl">
          Test App
        </Link>
      </div>

      <div className="flex justify-end w-1/3">
        {isAuthenticated && (
          <>
            <Link className="m-auto" href={"/account"}>
              Account
            </Link>
            <button onClick={handleLogout}>Logout</button>
          </>
        )}
        {isAuthenticated === false && (
          <div className="flex">
            <Link href={"/login"} className="m-auto">
              Login
            </Link>
          </div>
        )}
      </div>
    </nav>
  );
}

In here, we have the logout handler which makes a request to server and blacklists the token. Then we can update the state by dispatching the jid and isAuth setters. With this functionality the user is logged out from the application.

Final thoughts

Redux remains to be a go to state manager in react apps development. Only by consuming it in client components that we can safely use it without interfering with the next.js server side functionality.


Written by drogon | Javascript programmer
Published by HackerNoon on 2023/08/03