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
oryarn dev
if you are using yarn in your terminal.Now, let us create a
component
folder in our root folder (same level aspackage.json
) and then create a file in it calledUsers.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 theUsers.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 usesuseSearchParams
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 ofrouter.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 ourUsers.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!!!
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.