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
- The problem: https://nextjs-state-persistence-solution.vercel.app/
- The solution: https://nextjs-state-persistence-solution.vercel.app/solution
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;
};
}, []);
}
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];
}
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 useRouter
hook. 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;
}
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,
};
};
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>-></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>-></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,
},
};
};
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)
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