Table of contents
Introduction
Whenever you visit certain e-commerce websites, you may have observed that any time you click on a product or apply filters on a specific product such as colour or size, the URL gets instantly updated.
Take a look at the example below from the Adidas Shop and pay attention to the URL bar when I click on different shoe sizes.
This is an example of leveraging the URL to handle an application's state. Why do some web applications opt for this approach over alternatives such as the useState
hook?
Using the URL for state management in a React application can be beneficial in a number of ways:
Bookmarkable/Shareable State: If you want the users of your application to be able to bookmark or share specific states of your application, using the URL is essential. For example, the video illustration above allows a shopper to bookmark a particular shoe type, down to its size and colour. They can save this URL for later use or even share it with friends on chats and they will all see the exact same product on the UI.
Navigation History: By using the URL to manage the state, users can navigate back and forth using the browser's navigation buttons. This is useful when your application has a complex UI with various states, and users need to traverse through different views or steps.
Cross-device Usage: Managing your state in the URL allows users to view the same UI across different devices. Say, a user started shopping online on their laptop only to remember that their credit/debit card details are saved on their phone. The user can simply transfer to their phone the URL containing the full details of the product they had selected and proceed to checkout without having to restart the entire shopping process.
SEO and Indexing: Search engines generally index content based on URLs. If your application's state is encoded in the URL, it becomes more accessible to search engine crawlers, potentially improving SEO and discoverability.
Preserving state on Refresh: When the user refreshes the page or navigates away and then returns, using the URL to store the state allows you to preserve that state, providing a good user experience.
In today's article, we'll explore managing state using the URL in React applications.
Project Setup.
The starter code for this walkthrough is available HERE on GitHub. Feel free to fork the repository or follow the step-by-step procedure below to set up the project manually.
We'll use this Simple Stacked List UI component from TailwindUI's website along with its associated data and styling.
To use the component, click on the dropdown displaying "HTML" and select "React", then the clipboard icon to its right to copy the code.
In your new React project, paste this code into a component we'll call ListsContainer.jsx
.
Remove the people
array from the component and export it from its own people.js
file.
export const people = [
{
name: "Leslie Alexander",
email: "leslie.alexander@example.com",
role: "Co-Founder / CEO",
imageUrl:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
lastSeen: "3h ago",
lastSeenDateTime: "2023-01-23T13:23Z",
},
/*Rest of the array*/
]
Pass a people
prop in the ListsContainer
component, indicating that it will receive a prop from its parent.
export default function ListsContainer({ people }) {
return (
<ul role="list" className="divide-y divide-gray-100">
{/* The rest of the component as before */}
</ul>
);
}
Install and configure Tailwind CSS correctly in your React application for this to work.
Additionally, we'll use this code for the InputField.jsx
component to filter the array of people above by name.
export default function InputField({ ...props }) {
return (
<div>
<label
htmlFor="user"
className="block text-sm font-medium leading-6 text-gray-900"
>
Search User
</label>
<input
type="text"
name="user"
id="user"
className="my-4 block w-full rounded-md border-0 p-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="User"
{...props}
/>
</div>
);
}
We'll also use this as the Checkbox.jsx
component to toggle the list's results between people who are Co-founders:
export default function Checkbox({ ...props }) {
return (
<div className="flex items-center gap-3 self-end">
<label
htmlFor="isCoFounder"
className="block text-sm font-medium leading-6 text-gray-900"
>
Is Co-founder
</label>
<input
type="checkbox"
name="isCoFounder"
id="isCoFounder"
className="blockrounded-md my-4 border-0 p-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
{...props}
/>
</div>
);
}
We'll set up the rest of the project as follows:
Create two components:
Team.jsx
andAbout.jsx
. These will be two pages in our application.The
Team
component will be a page where we'll illustrate our state management using the URL. Inside this component, import theInputField
,Checkbox
andListsContainer
components. Also, import thepeople
array frompeople.js
and for now, drill it down to theListsContainer
component.import InputField from "./components/InputField"; import ListsContainer from "./components/ListsContainer"; import Checkbox from "./components/Checkbox"; import { people } from "./constants/people"; const Team = () => { return ( <div> <div className="flex flex-col"> <InputField /> <Checkbox /> </div> <ListsContainer people={people} /> </div> ); }; export default Team;
The
About
component will look like this:const About = () => { return <div>About Page</div>; }; export default About;
We'll use this component later to navigate away from the
Team
page to demonstrate how state management via the URL preserves data even when a user navigates away.In your
main.jsx
file, replace all existing code with this one which defines the routes of our application:import React from "react"; import ReactDOM from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import App from "./App.jsx"; import About from "./About.jsx"; import Team from "./Team.jsx"; import "./index.css"; const router = createBrowserRouter([ { path: "/", element: <App />, children: [ { path: "about", element: <About />, }, { path: "team", element: <Team />, }, ], }, ]); ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <RouterProvider router={router} /> </React.StrictMode>, );
Create a
Navbar.jsx
component and paste this code inside it:import { NavLink } from "react-router-dom"; const navLinks = [ { label: "Home", path: "/", }, { label: "About", path: "about", }, { label: "Team", path: "team", }, ]; const Navbar = () => { return ( <nav className="flex gap-4 bg-blue-200 p-4"> {navLinks.map((link) => ( <NavLink className={({ isActive }) => (isActive ? "underline" : "")} key={link.label} to={link.path} > {link.label} </NavLink> ))} </nav> ); }; export default Navbar;
Finally, in the
App.jsx
component, replace all code with the following:import { Outlet } from "react-router-dom"; import Navbar from "./components/Navbar"; const App = () => { return ( <div className="flex flex-col gap-4 p-8"> <Navbar /> <Outlet /> </div> ); }; export default App;
This is how our application should now look:
With the setup complete, let us now dive into state management.
The useSearchParams
hook.
useSearchParams
is a hook provided by the React Router library. It enables us to access and manipulate query parameters in the URL of our applications. Query parameters are the key-value pairs that appear after the "?"
in a URL.
Everything after the question mark following 'search' is a query parameter, uniquely identifying my search. For instance, "q=hashnode" reveals the search term I used on Google.
The
useSearchParams
hook is used to read and modify the query string in the URL for the current location. Like React's ownuseState
hook,useSearchPar
ams
returns an array of two values: the current location'ssearch paramsand a function that may be used to update them.
We initiate the useSearchParams
hook with a searchParams
object containing all the parameters we want to allow users to query in our application.
If we initialize it as shown in the code snippet below, we are only allowing our users to query by name. If, for example, they attempt querying by age or height, our application will ignore all those unauthorized query parameters.
const Team = () => {
const [searchParams, setSearchParams] = useSearchParams({
name: "",
});
return (
<div>
{ /*Rest of the component*/ }
</div>
)
Retrieving the value of a query parameter.
The useSearchParams
hook object comes with a method called searchParams.get()
. This method is used to retrieve the value of a specific query parameter from the current URL's query string. For instance, if you type "Michael" into an input field, this getter method will fetch the value and pass it on from the input field to the URL query.
The searchParams.get()
method requires a string as an argument, which should be any of the keys initialized with the searchParams
object, such as name
in this case.
To capture this name
parameter, we use the syntax below anywhere before our return statement:
const name = searchParams.get("name");
With this, track changes to the name
parameter later on.
Changing the name
parameter from an empty string.
Remember we are using an input field to manipulate values in the application's URL. Updates to input field values are handled using the onChange
handler.
Inside our onChange
handler, we invoke setSearchParams
which is a setter method provided by the useSearchParams
hook.
import { useSearchParams } from "react-router-dom";
import InputField from "./components/InputField";
import ListsContainer from "./components/ListsContainer";
import Checkbox from "./components/Checkbox";
const Team = () => {
const [searchParams, setSearchParams] = useSearchParams({
name: "",
});
const name = searchParams.get("name");
console.log(name);
return (
<div>
<div className="flex flex-col">
<InputField
onChange={(e) =>
setSearchParams((previousValue) => {
previousValue.set("name", e.target.value);
return previousValue;
})
}
/>
<Checkbox />
</div>
<ListsContainer people={people} />
</div>
);
};
export default Team;
This function accepts another function as an argument, which in turn takes the entire searchParams
object as its argument (what we are referring to as previousValue
). It then targets a key called name
in the object and resets its value to the value currently keyed in the input field.
It then returns the updated search parameters object. This updated object will be used to update the search parameters in the URL.
If we log the name
variable in the console now, we should see its value changing with every keystroke. At the same time, we should observe the name parameter appearing in the URL bar and its value dynamically adjusting to match the input field's value.
An illustration of this is shown below:
This confirms that we are successfully retrieving and updating the value of the name query parameter.
As a final step to validate this code's functionality, let us import the people
array we exported from people.js
into Team.jsx
. From it, we only want to pass to the ListsContaoneer.jsx
component a list of names matching our query.
To ensure case-insensitive filtering of names from the people array, we will add the code snippet below:
const filteredPeopleList = people.filter((person) =>
person.name.toLowerCase().includes(name.toLowerCase()),
);
Instead of the people
array, we now then pass this filtered filteredPeopleList
variable down to the ListsContainer
.
import { useSearchParams } from "react-router-dom";
import InputField from "./components/InputField";
import ListsContainer from "./components/ListsContainer";
import { people } from "./constants/people";
import Checkbox from "./components/Checkbox";
const Team = () => {
const [searchParams, setSearchParams] = useSearchParams({
name: "",
});
const name = searchParams.get("name");
const filteredPeopleList = people.filter((person) =>
person.name.toLowerCase().includes(name.toLowerCase()),
);
return (
<div>
<div className="flex flex-col">
{/* Same as before */}
</div>
<ListsContainer people={filteredPeopleList} />
</div>
);
};
export default Team;
Let us put this implementation to the test:
As demonstrated above, typing "Cook" in the input field filters the team members and only displays his profile.
You also notice from the illustration that a hard reload of the page does not reset the array, instead it preserves our previous search.
Even navigating back to other pages doesn't disrupt our state. We mentioned this earlier as an advantage of URL-based state management.
This preservation makes it easy to share this user's profile with others or to view it on different devices and browsers without the need to search for him again.
The checkbox toggle.
We can now add a final query parameter in our object. This time, when the checkbox is checked, the list will filter to display only Co-founders. This query operates independently of the text input field and the result can still be filtered by name.
We begin by adding the new query parameter in the searchParams
object:
import { useSearchParams } from "react-router-dom";
const Team = () => {
const [searchParams, setSearchParams] = useSearchParams({
name: "",
isCofounder: "false"
});
return (
<div>
{ /*Rest of the component*/ }
</div>
)
Next, we capture the isCofounder
parameter as we did with the name
parameter before using a getter method.
const isCoFounder = searchParams.get("isCoFounder") === "true";
Since query parameters are stored as strings, we compare the retrieved value to the string "true" to determine if the checkbox for filtering Co-founders is checked. If the value of "isCoFounder" is "true" in the URL, isCoFounder
will be set to true
, otherwise, it will be false
.
We apply a Co-founder match filter next.
const isCoFounder = searchParams.get("isCoFounder") === "true";
const filteredPeopleList = people.filter((person) => {
/*name match filter from before*/
const cofounderMatch = isCoFounder
? person.role.includes("Co-Founder")
: true;
return nameMatch && cofounderMatch;
});
This filter checks if the person is a Co-Founder. If isCoFounder
is true
, meaning the checkbox for filtering Co-founders is checked, it verifies if the person's role includes "Co-Founder". If isCoFounder
is false
(meaning the checkbox is unchecked), it automatically passes this condition (true
), allowing all persons to be included in the filtered list regardless of their role.
We return both filters so that filteredPeopleList
contains only people who satisfy both conditions: their names match the input value, and either they are Co-Founders or the Co-founder filter is not applied.
Putting our implementation to the test, we are achieving the desired output.
A Potential Pitfall: Navigating backward and forward
If you're following along with the code, you'll notice that when you filter the list by name and then press the back button, the query parameter is cleared one character at a time.
This occurs because the back button restores the URL object to its previous state, including the name query parameter. As you may recall, this name
parameter was updated one character at a time by the onChange
handler in our input, as we discussed earlier.
The previous state of the name query "Michael" becomes "Michae", then "Micha", and so forth as you click the Back button.
However, this default behaviour of useSearchParams
isn't what users typically expect when using the Back button. It makes navigation cumbersome and unnecessarily bloats your browser history.
The history stack below (read it from bottom to top) was generated solely by clicking the Back button. It gradually removes the query parameter one character at a time until it's an empty string. Only then does it navigate to the home page, which was the intended destination when I initially hit the Back button.
Feel free to add this temporary code snippet that dynamically sets the query as a page title in App.jsx
to test the above behaviour. Replace everything in your App.jsx
file by pasting the code below.
import { Outlet, useLocation } from "react-router-dom";
import Navbar from "./components/Navbar";
import { useEffect } from "react";
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
const App = () => {
const location = useLocation();
const search = location?.search;
function extractSearchQuery(urlString) {
// Find the index of the "?" character
const questionMarkIndex = urlString.indexOf("?");
// If "?" is found, extract the search query
if (questionMarkIndex !== -1) {
const searchQuery = urlString.substring(questionMarkIndex + 1);
return searchQuery.split("&")[0].split("=")[1];
} else {
return "useSearchParams Walkthrough"; // Default Page title if no search query is present
}
}
let pageTitle = extractSearchQuery(search);
useDocumentTitle(pageTitle);
return (
<div className="flex flex-col gap-4 p-8">
<Navbar />
<Outlet />
</div>
);
};
export default App;
Fixing the Navigation pitfall.
The setSearchParams
method accepts a second argument. This argument is a configuration object for the application's behaviour when a user navigates backward or forward.
A common configuration option is called replace
. When you set replace
to true
, it means that instead of adding a new entry to your browser's history every time you update the URL, it replaces the current entry. This keeps your browser history tidy and makes navigation smoother.
Let's add this second argument to our code and attempt navigating backward again:
// Same imports as before
const Team = () => {
// Same logic as before
return (
<div>
<div className="flex flex-col">
<InputField
onChange={(e) =>
setSearchParams(
(previousValue) => {
previousValue.set("name", e.target.value);
return previousValue;
},
{ replace: true },
)
}
/>
<Checkbox
onChange={(e) =>
setSearchParams(
(previousValue) => {
previousValue.set("isCoFounder", e.target.checked);
return previousValue;
},
{ replace: true },
)
}
/>
</div>
<ListsContainer people={filteredPeopleList} />
</div>
);
};
export default Team;
Compare the screenshot below to the previous previous one.
In this instance, the search query parameter "Lindsay" doesn't gradually revert one character at a time when I press the Back button, as it did previously. Instead, I'm immediately directed back to the previous page.
BONUS: Cleaning up our code with the Context API
We covered Context API in a previous article. Currently, our components are laden with excess logic, some of which they don't even require in the first place.
For instance, you might have noticed that Team.jsx
doesn't actually require access to the people
or filteredPeopleList
arrays, yet it contains the entire logic for manipulating these arrays.
We can refactor this by abstracting away all this logic into a Context. This way, we can easily share data between the two components while eliminating the need for prop drilling from Team
to ListsContainer
.
Create a Context file.
// TeamMembersContext.js
import { createContext } from "react";
const TeamMembersContext = createContext(null);
export default TeamMembersContext;
Create a Provider component for the above context.
I explained HERE why I create a Context and its Provider in separate files instead of one.
// TeamMembersContextProvider.jsx
import TeamMembersContext from "./TeamMembersContext";
const TeamMembersContextProvider = ({ children }) => {
return (
<TeamMembersContext.Provider>
{children}
</TeamMembersContext.Provider>
);
};
export default TeamMembersContextProvider;
Transfer all the logic defined insideTeam.jsx
(before the return
statement) into the Context Provider.
Remember to import the people
array from people.js
file.
import { people } from "../constants/people";
import { useSearchParams } from "react-router-dom";
import TeamMembersContext from "./TeamMembersContext";
const TeamMembersContextProvider = ({ children }) => {
const [searchParams, setSearchParams] = useSearchParams({
name: "",
isCoFounder: "false",
});
const name = searchParams.get("name");
const isCoFounder = searchParams.get("isCoFounder") === "true";
const filteredPeopleList = people.filter((person) => {
const nameMatch = person.name.toLowerCase().includes(name.toLowerCase());
const cofounderMatch = isCoFounder
? person.role.includes("Co-Founder")
: true;
return nameMatch && cofounderMatch;
});
return (
<TeamMembersContext.Provider>
{children}
</TeamMembersContext.Provider>
);
};
export default TeamMembersContextProvider;
Select the values you wish to share via this context across components.
In our scenario, Team.jsx
requires access to the setSearchParams
method from the context, while ListsContainer.jsx
requires access to the filteredPeopleList
array.
Assign these as the values of the Provider, as in the following code snippet:
// All the previous imports
const TeamMembersContextProvider = ({ children }) => {
// All the previous logic
const filteredPeopleList = people.filter((person) => {
// All the previous filtering logic
});
const contextValues = { filteredPeopleList, setSearchParams };
return (
<TeamMembersContext.Provider value={contextValues}>
{children}
</TeamMembersContext.Provider>
);
};
export default TeamMembersContextProvider;
Finally, let us centralize our error handling using a custom hook. Below the export default TeamMembersContextProvider
statement, define the below hook that will throw us an error if we attempt to use the Context outside of components wrapped by its Provider:
export const useTeamMembersContext = () => {
const context = useContext(TeamMembersContext);
if (!context) {
throw new Error(
"useTeamMembersContext must be used within a TeamMembersContextProvider",
);
}
return context;
};
We are ready to use our TeamMembersContext
.
Wrap the components insideApp.jsx
with the Context Provider.
The Navbar does not need access to any of this data, so it is not wrapped in the context. I've also removed the temporary previous code for setting the page's title to a query. However, it's in the final code available on GitHub.
import { Outlet } from "react-router-dom";
import Navbar from "./components/Navbar";
import TeamMembersContextProvider from "./context/TeamMembersContextProvider";
const App = () => {
return (
<div className="flex flex-col gap-4 p-8">
<Navbar />
<TeamMembersContextProvider>
<Outlet />
</TeamMembersContextProvider>
</div>
);
};
export default App;
Call our custom hook inside theListsContainer.jsx
component.
We remove the people
prop in the component and instead invoke our hook, destructuring the filteredPeopleList
array from the context object and renaming it to people
:
import { useTeamMembersContext } from "../context/TeamMembersContextProvider";
export default function ListsContainer() {
const { filteredPeopleList: people } = useTeamMembersContext();
return (
<ul role="list" className="divide-y divide-gray-100">
{people.map((person) => (
<li key={person.email} className="flex justify-between gap-x-6 py-5">
{/* rest of the component as before */}
</li>
))}
</ul>
);
}
Invoke our custom hook inTeam.jsx
component.
This component only needs access to the setSearchParams
method. It doesn't need to interact with the filteredPeopleList
array. We again destructure the method from our context object and pass it to this component:
import InputField from "./components/InputField";
import ListsContainer from "./components/ListsContainer";
import Checkbox from "./components/Checkbox";
import { useTeamMembersContext } from "./context/TeamMembersContextProvider";
const Team = () => {
const { setSearchParams } = useTeamMembersContext();
return (
<div>
{/* Same as before */}
</div>
);
};
export default Team;
While at it, remember to remove the prop passed to ListsContainer
as it's no longer necessary.
With this refactor, our code is cleaner, with data declared, managed and consumed only where needed. When we test this refactor, it confirms that our app works just as before.
Conclusion
The useSearchParams
hook is a valuable tool facilitating dynamic updates to our application's state based on URL changes.
Throughout this article, we've explored how to utilize it to retrieve, update, and synchronize state with the URL, enabling features such as filtering data and maintaining application state across navigation actions.
Additionally, we've addressed common challenges and pitfalls associated with using useSearchParams
, such as unexpected behaviour when navigating backward or forward in the browser history. Understanding these nuances enables us to mitigate such issues and ensure a smoother user experience.
The final code for this walkthrough is available on GitHub.
Happy Coding! ๐