How to Lift Component State to the URL in Next.js/React.js

When building SPAs (Single Page Applications), we tend to mostly manage state locally mainly using react hooks like useState or other external state management tools like redux, zustand etc.

You might be wondering why consider the lifting of component state to the URL and in what situation is it advisable to use this approach. Take for example, you are surfing through an e-commerce store, and you see a beautiful sneaker in different colours and sizes, so you select the colour gray and size 39 and then copy the URL and shared it with your best friend to get his/her opinion. If the e-commerce store didn't lift the state of the component to the URL, your best friend would most probably see the default size and color of that sneaker and not the exact colour and size you hoped to get his/her opinion on.

In this guide, I will walk you through the process of lifting your component state up to the URL in Next.Js/React. We will be building a simple user table with a modal and filter it by user-role. This step-by-step approach will ensure you understand each stage of the development process. Let’s dive in!

Please note that in this tutorial, you will need basic knowledge of React/NextJs as I will not be going through the installation process. You might also notice some strange syntax if you are not familiar with Typescript, do not be alarmed as this will not affect your learning in this tutorial. Although Next.Js will be used throughout, you can apply the same concepts to any React framework you are working with.

Setting Up Component Structure and Data.

I assume we must have set up our app by now, if not please follow the instructions here to install a new Next.Js app. We will also be using TaiwindCSS to style our app a bit. While Tailwind comes bundled into a new NextJs app if you accept the prompt, you will need to manually install it if you are using any other React framework by following the official documentation here.

After successful installation, let's set up our component and the data that will display the user table.

  • Open the page.tsx in the app folder in Next.Js in any your code editor of your choice.

  • Start the development server by running npm run dev or yarn dev if you are using yarn in your terminal.

  • Now, let us create a component folder in our root folder (same level as package.json) and then create a file in it called Users.tsx .

  • Inside the Users.tsx component, lets add the structure, create and display the dummy data in a table and style it a bit like this.

      // Create dummy data of users with roles
      const dummyData = [
        {
          id: 1,
          name: "John Doe",
          email: "john.doe@example.com",
          role: "admin",
        },
        {
          id: 2,
          name: "Jane Doe",
          email: "jane.doe@example.com",
          role: "user",
        },
        {
          id: 3,
          name: "Bob Smith",
          email: "bob.smith@example.com",
          role: "admin",
        },
        {
          id: 4,
          name: "Alice Johnson",
          email: "alice.johnson@example.com",
          role: "user",
        },
        {
          id: 5,
          name: "Mike Brown",
          email: "mike.brown@example.com",
          role: "user",
    
        },
        {
          id: 6,
          name: "Emma Davis",
          email: "emma.davis@example.com",
          role: "user",
        }
      ];
      export default function Users() {
       return (
          <main className="relative w-full h-screen flex flex-col px-5 gap-y-5 justify-center items-center">
            <h1>FILTER TABLE WITH URL SEARCH QUERIES</h1>
             <div className="flex w-full justify-between items-center">
              <div className="flex gap-x-2 items-center">
                <select value="" className="border p-2">
                  <option value="" selected disabled>
                    Filter by role
                  </option>
                  <option value="user">user</option>
                  <option value="admin">admin</option>
                </select>
                  <button
                    className="bg-white shadow-md border text-xs p-1 rounded-sm text-red-400"
                  >
                    Clear Filters
                  </button>
              </div>
              <button
                className="bg-blue-400 text-white px-2 py-1 rounded-md"
              >
                Open Modal
              </button>
            </div>
            <table className="w-full">
              <thead>
                <tr>
                  <th scope="col" className="border border-gray-200 p-4">
                    ID
                  </th>
                  <th scope="col" className="border border-gray-200 p-4">
                    Name
                  </th>
                  <th scope="col" className="border border-gray-200 p-4">
                    Email
                  </th>
                  <th scope="col" className="border border-gray-200 p-4">
                    Role
                  </th>
                </tr>
              </thead>
              <tbody>
                {dummyData.map((user) => (
                  <tr key={user.id}>
                    <td className="border border-gray-200 p-4">{user.id}</td>
                    <td className="border border-gray-200 p-4">{user.name}</td>
                    <td className="border border-gray-200 p-4">{user.email}</td>
                    <td className="border border-gray-200 p-4">{user.role}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </main>
        );
      }
    
  • Now in our page.tsx file, let us import the Users.tsx component into the file like this

import Users from "@/component/Users";
import { Suspense } from "react";

export default function Home() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Users />
    </Suspense>
  );
}

We are wrapping our Users component in a Suspense boundary here because we will be using some Next.Js features like useSearchParams . This allows a part of the route to be statically rendered while the dynamic part that uses useSearchParams is client-side rendered. You can read more about that here.

  • After doing this, our app should display the data in a table and look like this now.

Filtering Users by Role.

The traditionally way in which we would normally approach this is to keep track the state of the select tag when it changes and save it using useState. Then use the state to run a filter function on the original data (dummyData) so as to get a new filteredUsers data that can be used as a substitute to the dummyData we mapped earlier right. What if I told you we won't even have to use a useState hook at all!!

Creating a Function to Set States in the URL.

Now back to our Users.tsx file, the first thing we would have to do is to create a function that sets our states, in our case, the user-role and the modal state in the URL. We will import and wrap the function with the useCallback hook from React so as to return a memoized version of the callback that only changes if one of the inputs has changed.

import { useCallback } from "react";

  // Fuction that creates a new query string by adding a key-value pair to the existing search parameters.url
  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);

      return params.toString();
    },
    [searchParams]
  );

Here, we are just creating a query string by adding a key-value pair to the existing search parameters. You can read more on URLSearchParams here.

Handling User Role Changes.

Now, when the user selects a role to filter with in the select tag, we need to get the value selected and then save it in the URL. The way we can accomplish this is to create a function and then attach it to the onChange property on the select tag

import { ChangeEvent } from "react";  
import { usePathname, useRouter } from "next/navigation";

const pathname = usePathname();
const router = useRouter();

// Filter function that saves user role to url as search params
  const handleFilter = (e: ChangeEvent<HTMLSelectElement>) => {
    const selectedRole = e.target.value;
    router.replace(pathname + "?" + createQueryString("role", selectedRole));
  };

What is happening here is that we firstly import the usePathname hook which is a Client Component hook that lets us read the current URL's pathname, the useRouter which is also a hook that allows us to programmatically change routes inside a Client Component in NextJs.

Next, we get the value the user selected in the filter (user or admin) and then save it to the variable selectedRole and then we use the router we previously defined to replace the current URL. So, when we filter the user role to see only admins, then our URL will look like this localhost:3000/?role=admin which is the behavior we want.

You can also use router.push() in place of router.replace() but be aware that .push() will add a new entry into the browser’s history stack.

Syncing URL State with Our App.

You would notice that our user table is still not filtering based on role whenever we select a role, but our URL keeps updating right? That is because, our table is still displaying the rigid dummyData we mapped through previously.

To fix this, we would have to create a new user data that will derive its data from the original dummyData . We will filter the dummyData and checks if each object role matches the role that is now saved to our URL.

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

 const searchParams = useSearchParams();  
// Get query values from url which serves as the single source of truth(i.e not relying on local state)
  const roleQueryValue = searchParams.get("role") || "";
  // Logic to filter based on role in url vs role in original data
  const filteredUsers = dummyData.filter(
    (user) => user.role === roleQueryValue
  );
  const users = filteredUsers.length > 0 ? filteredUsers : dummyData;

What is happening here is that we import the useSearchParams hook which is a Client Component hook that lets us read the current URL's search parameters in Next.Js, in our case, the search parameter we are trying to read is the 'role' in which we then save to the variable roleQueryValue .

          <select
            value={roleQueryValue}
            className="border p-2"
            onChange={handleFilter}
          >
            <option value="" selected disabled>
              Filter by role
            </option>
            <option value="user">user</option>
            <option value="admin">admin</option>
          </select>

Since we now have access to the role in the URL, we can then proceed to use it filter the dummyData to get a new array of user objects called filteredUsers . We create a new users variable that performs further checks to confirm if our filteredUsers array length is greater than zero (there are filtered users), if not, we just display the original data (dummyData). The newly created users can now be used to replace the dummyData we mapped in our component like this.

        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td className="border border-gray-200 p-4">{user.id}</td>
              <td className="border border-gray-200 p-4">{user.name}</td>
              <td className="border border-gray-200 p-4">{user.email}</td>
              <td className="border border-gray-200 p-4">{user.role}</td>
            </tr>
          ))}
        </tbody>

Now, after making this small modification, our table now filters effortlessly while its states also get updated in the URL as search params. That feels great right? 😄.

Toggling Modal.

You will notice we have a button 'open modal' in our component that should open a modal when it is clicked. We will also use search params to save and control the state of the modal but before we get into that, let us first define the structure of our modal and add some basic styles to it also.

  • Just after the table tag, still in our Users.tsx component, add this code below.

    
              <div className="bg-black/50 w-full h-screen fixed z-10">
                <button
                  className="bg-red-600 text-xs text-white absolute right-4 top-4 px-2 py-1 rounded"
                >
                  close
                </button>
              </div>
    

    Now, you should see the modal covering the whole page now but that is not the behaviour we want. We want to be able to toggle the modal (open/close). To accomplish this:

  • Let's create a Boolean variable to manage the modal visibility and wrap our modal code in it, just like this:

        const isToggleModal = false;
    
          {isToggleModal && (
              <div className="bg-black/50 w-full h-screen fixed z-10">
                <button
                  className="bg-red-600 text-xs text-white absolute right-4 top-4 px-2 py-1 rounded"
                >
                  close
                </button>
              </div>
            )}
    

    The modal should be closed after we do this, but you should notice that when we click the 'open modal ' button, the modal doesn't open and that is because we are controlling the visibility state of the modal with a value that is rigid and will never change ( isToggleModal ). Since we also want to derive the state of the modal visibility from the URL, we would need to do the following to achieve this;

Firstly, we need to create a function for our 'open modal ' and 'close ' button that saves the modal state in the URL (modal=true / modal=false) and then attach these functions to the onClick property in respective buttons.

  // Function that saves modal state to url as search params(modal=true)
  const handleModalOpen = () => {
    router.replace(pathname + "?" + createQueryString("modal", "true"));
  };

  // Function that saves modal state to url as search params(modal=false)
  const handleModalClose = () => {
    router.replace(pathname +  "?" + createQueryString("modal", "false"));
  };

       //Open Modal Button
        <button
          onClick={handleModalOpen}
          className="bg-blue-400 text-white px-2 py-1 rounded-md"
        >
          Open Modal
        </button>

      //Close Modal Button
      {isToggleModal && (
        <div className="bg-black/50 w-full h-screen fixed z-10">
          <button
            onClick={handleModalClose}
            className="bg-red-600 text-xs text-white absolute right-4 top-4 px-2 py-1 rounded"
          >
            close
          </button>
        </div>
      )}

Syncing Modal URL State with Our App.

Now, when we click the 'open modal ' button, our URL is updated and will look like this localhost:3000/?modal=true but our modal is still not open. Since we now have access to the modal state in the URL whenever the open or close modal is clicked, we can now use those values to sync our local modal state ( isToggleModal ) instead of just it having a constant value of false always.

  const roleModalState = searchParams.get("modal");  

  //Converts the roleModalState string to a boolean value and assigns it to isToggleModal.
  const isToggleModal = roleModalState === "true" ? true : false;

What this is doing is that it we get the string value of the modal state in the URL and save it to the variable roleModalState and then perform a check that converts the roleModalState string to a Boolean value and assigns it to isToggleModal. Now, if roleModalState is a string true, the isToggleModal will be a Boolean true and vice versa.

After these changes, our modal should now open when we click the 'open modal' button and also close when we click the 'close' button. This is exciting right!!

Conclusion.

We have now come to the end of this little tutorial. This post addresses how, why and when to lift a component state to the URL in Next.Js/React.Js. Watch this space for the upcoming ones.

Please note that while lifting component state to the URL has many benefits, it must be done with caution since developers do not own the URL—the user does. For example, it will not be advisable to lift the state of a modal used for restrictions to the URL.

The full source file of this tutorial can be found here as well as the live link here also.

I'd love to connect with you on Twitter | LinkedIn | GitHub | Portfolio.

See you in my next blog article. Take care!!!

10
Subscribe to my newsletter

Read articles from Alabi Olalekan Emmanuel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alabi Olalekan Emmanuel
Alabi Olalekan Emmanuel

I am a Frontend Developer who is diving into writing to document my experience and also help others find their foot in the little way that I can.