DEV Community

Cover image for Solving the Challenge of State Persistence in Next.js: Effortless State Management with Query Parameters
Jeffrey Nwankwo
Jeffrey Nwankwo

Posted on

Solving the Challenge of State Persistence in Next.js: Effortless State Management with Query Parameters

The Challenge

Hi devs ๐Ÿ‘จโ€๐Ÿ’ป! When it comes to web applications, they often rely on us users to provide input so that they can fetch and show us the data we need. However, there are a couple of common situations where the state of things can get a bit tricky: search and pagination/filtering.

  • Let's take search as an example. You type something into the search box, and the website displays the results based on your query. All good, right? But things can get annoying if you refresh the page or hit the back button, because your search query disappears and you end up with zero results. Not exactly what we were hoping for!

  • Pagination and filtering can also be problematic. They're great because they allow us to control how many results are shown per page, and which page we're looking at. But if we navigate away from the current page or refresh the page, we can lose all the results and filter settings we had before.

For demonstration purposes, check out this example website I made: https://nextjs-state-persistence-solution.vercel.app/. When you go to the page, you'll see some articles already displayed. You can search for articles by their title using the search bar and even load more by clicking the "Load More" button. Sounds cool, right?

But there's a catch. As soon as you refresh the page or navigate away and then come back using the browser arrow keys, everything resets. This means that you'll have to start your search from scratch and load the articles again. That's not very convenient. Ideally, you should have the ability to reset things yourself when you want to, instead of having everything reset automatically.

By implementing a solution as developers, we can provide a more user-friendly experience that saves users time and effort.

Project Links


If you'd like to follow along with me, you can download the starter template I created on GitHub here. This template includes all the code you need to fetch, search, and load more articles. The only thing left to do is to implement a solution to solve the state persistence challenge.

The Wrong Solution

One common approach developers would use is the browser storage, such as local storage or session storage, to store the user's search query and filter settings. Then when the user returns to the page, the website can check for any stored data and use it to restore the previous search results and settings. It's not entirely a wrong solution but it's not best solution for a problem like this.

The issue with using the browser storage is that it can fill up quickly with pieces of information, especially if there are a lot of filters or other user settings to store. This can cause the browser to slow down or even crash, especially on older or less powerful devices. In addition, if the user leaves the website completely or closes the page without resetting, the results will be read based on the data that is stored in the browser storage, which may not reflect their current preferences or needs. For example, if a user has applied a filter to their search results but then closes the page and returns later, they may forget that the filter was applied and assume that the results are simply incomplete or inaccurate.

Using query parameters instead of browser storage can help mitigate this issue by ensuring that the user's preferences are always reflected in the URL, even if they leave and return to the site at a later time. This makes it easier for the user to understand and navigate the site, and can lead to a more positive user experience overall.

The Solution

The best solution here is to use query parameters over local or session storage for a few reasons.

Firstly, query parameters are visible and editable by the user, which can be useful for troubleshooting or sharing. In a case where a user encounters an issue with their search results or wants to share their search with someone else, they can simply copy and share the URL, including the query parameters. With browser storage, the user would need to manually export their data or use a third-party tool to access and share it. The heck?

Additionally, query parameters can help improve the SEO and accessibility of a website. Search engines and screen readers can read and interpret URL parameters, potentially leading to better search engine rankings and accessibility for users with disabilities.

Once you've downloaded the starter template, open the project folder in your favorite IDE and run the npm install command in your terminal to install the required dependencies.

And to improve performance and maintain clean code (which I'm a big fan of), we'll create two custom hooks inside the hooks folder:

  • useRouterQueryState
  • useWatch

The useRouterQueryState hook replaces the useState hook and effectively manages and tracks our states in the URL bar. On the other hand, the useWatch hook simply watches for changes in the query parameters.

Now, you can proceed to create the useRouterQueryState.ts and useWatch.ts files inside the hooks folder. Simply copy and paste the respective code into the files as needed.

useWatch.ts

import { useEffect, useRef, EffectCallback, DependencyList } from "react";

export function useWatch(
  func: EffectCallback,
  deps: DependencyList | undefined
) {
  const mounted = useRef<boolean>(false);

  useEffect(() => {
    if (mounted.current === true) {
      func();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);
}

Enter fullscreen mode Exit fullscreen mode

Copy and paste into useRouterQueryState.ts file

import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useState } from "react";
import { useWatch } from "./useWatch";

type SerializerFunction = (value: any) => string | undefined;
type DeserializerFunction = (value: string) => any;

interface Options {
  serializer?: SerializerFunction;
  deserializer?: DeserializerFunction;
}

export function useRouterQueryState<T>(
  name: string,
  defaultValue?: T,
  opts: Options = {}
): [T, Dispatch<SetStateAction<T>>] {
  const router = useRouter();

  const serialize = (value: T): string | undefined => {
    if (opts.serializer) {
      return opts.serializer(value);
    }
    return value as string;
  };

  const deserialize = (value: string): T => {
    if (opts.deserializer) return opts.deserializer(value);

    // default deserializer for number type
    if (typeof defaultValue === "number") {
      const numValue = Number(value === "" ? "r" : value);
      return isNaN(numValue) ? (defaultValue as T) : (numValue as T);
    }
    return value as T;
  };

  const [state, setState] = useState<T>(() => {
    const value = router.query[name];
    if (value === undefined) {
      return defaultValue as T;
    }
    return deserialize(value as string);
  });

  useWatch(() => {
    //! Don't manipulate the query parameter directly
    const serializedState = serialize(state);
    const _q = router.query;

    if (serializedState === undefined) {
      if (router.query[name]) {
        delete _q[name];
        router.query = _q;
      }
    } else {
      _q[name] = serializedState;
      router.query = _q;
    }
    router.push(
      {
        pathname: window.location.pathname,
        query: {
          ..._q,
          [name]: router.query[name],
        },
        hash: window.location.hash,
      },
      undefined,
      { shallow: true }
    );
  }, [state, name]);

  return [state, setState];
}

Enter fullscreen mode Exit fullscreen mode

The useRouterQueryState hook simply allows us to manage a state as a query parameter in the URL using the useRouter hook. By managing a state variable as a query parameter in the URL, the hook ensures that the state is persisted between page refreshes and browser navigation and this can be useful for cases where you want to preserve user input or application state across page reloads or navigation actions.

Important: Before moving forward, we need to modify the _app.tsx file located in the pages folder. It is crucial to ensure that the router is ready to be used before utilizing the useRouterQueryState hook, which utilizes the Next.js useRouterhook. This check is particularly important in Next.js applications, where navigation takes place on the client-side. If we attempt to navigate before the router is ready, it may result in unexpected behavior or errors.

Updated _app.tsx file

import "@/styles/globals.css";
import { useState, useEffect } from "react";
import { useRouter } from "next/router";
import type { AppProps } from "next/app";

function useRouterReady() {
  const [isReady, setIsReady] = useState(false);
  const router = useRouter();

  useEffect(() => {
    setIsReady(router.isReady);
  }, [router.isReady]);

  return isReady;
}

export default function App({ Component, pageProps }: AppProps) {
  const isRouterReady = useRouterReady();

  return isRouterReady ? <Component {...pageProps} /> : null;
}
Enter fullscreen mode Exit fullscreen mode

In general, if you plan on using the Next.js useRouter hook anywhere in your code, it is crucial to ensure that you check for the readiness of the router somewhere in your code, such as in the _app.tsx file as demonstrated above.

To separate the challenge and solution into two distinct pages, we will implement the state persistence solution in the solution.tsx file inside the pages folder.

To accomplish this, we will first create a duplicate of the useLoadMore hook that is responsible for handling the search and filtering of articles and controlling the number of articles displayed on the screen per click.

Create a new hook called useLoadMorev2.ts in the hooks folder and copy the contents of the useLoadMore hook into it. The useLoadMore hook manages the search query (query) and the number of articles displayed per load (perLoad) using the useState hook.

However, in the useLoadMorev2.ts file, we will import the useRouterQueryState hook and replace the useState hook with it for the query and perLoad states.

To set up the query state, name it "search" and set the default value to an empty string.

For the perLoad state, name it "perLoad" and set the default value to 5.

Updated useLoadMorev2.ts file

import { useState, useEffect, ChangeEvent } from "react";

import { useRouterQueryState } from "./useRouterQueryState";

export const useLoadMorev2 = (articles: any[]) => {
  const [query, setQuery] = useRouterQueryState("search", "");
  const [filteredArticles, setFilteredArticles] = useState(articles);
  const [perLoad, setPerLoad] = useRouterQueryState("perLoad", 5);

  const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
    setQuery(e.target.value);

  const loadMore = () => setPerLoad(perLoad + 5);

  useEffect(() => {
    if (query.length < 3) {
      setFilteredArticles(articles);
      return;
    }
    const results = articles.filter((article) =>
      article.title.toLowerCase().includes(query.toLowerCase())
    );
    setFilteredArticles(results);
  }, [articles, query]);

  return {
    filteredArticles: filteredArticles.slice(0, perLoad),
    query,
    handleChange,
    loadMore,
    total: filteredArticles.length,
    showLoadMoreButton: filteredArticles.length > perLoad,
  };
};

Enter fullscreen mode Exit fullscreen mode

Next, we will copy the contents of the index.tsx file, which is the challenge page, into the solution.tsx file. We will also import the new useLoadMorev2 hook and replace the useLoadMore hook with it.

Updated solution.tsx file

import Head from "next/head";
import { GetStaticProps } from "next";
import Link from "next/link";
import { Inter } from "next/font/google";
import styles from "@/styles/Home.module.css";

import { SearchBar } from "@/components/search";
import { useLoadMorev2 } from "@/hooks/useLoadMorev2";

import bgImage from "@/assets/images/landscape.jpg";

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

export default function Home({ articles = [] }: { articles: any[] }) {
  const {
    filteredArticles,
    query,
    handleChange,
    total,
    loadMore,
    showLoadMoreButton,
  } = useLoadMorev2(articles);

  return (
    <>
      <Head>
        <title>The State Persistence Solution</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main className={styles.main}>
        <div className="container py-12 px-8 mx-auto">
          <div>
            <h1 className="text-2xl mb-3">The Solution: State Persistence</h1>
            <Link href="/" className="text-lg block underline mb-3">
              Go to challenge <span>-&gt;</span>
            </Link>
          </div>
          <div
            className="w-full min-h-[200px] px-12 flex flex-col items-center justify-center rounded-md"
            style={{
              backgroundImage: `url(${bgImage.src})`,
              backgroundSize: "cover",
              backgroundPosition: "top",
            }}
          >
            <SearchBar onChange={handleChange} value={query} />
          </div>

          <div
            className={`mt-20 mb-4 inline-block p-2 rounded-md border border-gray-600 text-xl ${styles.code}`}
          >
            codenameone Articles ({filteredArticles.length} of {total})
          </div>
          {filteredArticles.length < 1 && (
            <div className="text-center my-10 text-3xl">No articles found!</div>
          )}
          <div className="mt-4 grid grid-cols-1 grid-flow-row md:grid-cols-2 xl:grid-cols-3 gap-5">
            {filteredArticles.map((article) => (
              <Link
                key={article.id}
                href={article.url}
                className={styles.card}
                target="_blank"
                rel="noopener noreferrer"
              >
                <h2 className={inter.className}>
                  {article.title} <span>-&gt;</span>
                </h2>
                <p className={inter.className}>{article.description}</p>
              </Link>
            ))}
          </div>
          {showLoadMoreButton && (
            <div className="text-center my-6">
              <button
                className="py-3 px-4 text-xl rounded-sm bg-white text-black hover:opacity-70"
                onClick={loadMore}
              >
                Load More
              </button>
            </div>
          )}
        </div>
      </main>
    </>
  );
}

export const getStaticProps: GetStaticProps = async () => {
  const response = await fetch(
    "https://dev.to/api/articles?username=codenameone"
  );

  const articles = await response.json();

  return {
    props: {
      articles,
    },
  };
};

Enter fullscreen mode Exit fullscreen mode

Once you have made all the necessary changes, be sure to save your files. Then start your development server to see the results.

Here's the link to the solution: https://nextjs-state-persistence-solution.vercel.app/solution

Thats the solution! Now, if you refresh or navigate the page after searching by title or loading more articles, your state will remain unchanged. This means that if you share the link with someone else, they will see exactly what you see, thanks to the query parameters.

That's all folks ๐Ÿ˜Ž. Please leave your thoughts and questions in the comments and don't forget to give me a follow on Twitter.

Top comments (1)

Collapse
 
bearcooder profile image
BearCoder

This is great thank you very much! Would you be so kind and also show the solution for Next.JS 13 with App Router? Right now for example I show by default all images and have buttons that would display only the selected category. But I use useState right now