DEV Community

Cover image for Advanced React state management using URL parameters
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Advanced React state management using URL parameters

Written by Rahul Chhodde✏️

One of the best capabilities of modern Web apps is providing smooth and responsive user interactions. Some common examples of such interactions are switching between tabs, loading paginated data, and filtering or sorting dynamic content.

React makes these interactions easier with its built-in and third-party state management solutions such as the useState Hook, the Context API, Redux, and more.

However, some cases demand that states persist in support of the app's overall UX. In such scenarios, using client-side storage or databases doesn't make much sense, as this persistence has more to do with the app's universal usability than personalization.

This tutorial will explore handling such state changes with URL and search parameters. By the end, you'll understand the importance of URL-based state management in React with its SEO, performance, and accessibility considerations.

N.B., As a prerequisite, you should have a general idea of working with React, React Hooks, and TypeScript. The code examples in this guide use Tailwind CSS for UI styling, but we won’t focus on it too much. You can find the source code in this GitHub repo.

What is URL-based state management?

When an app’s views depend on the URL for state changes, it is said to be managing states via URL. These changes in views range from small, interactive updates to huge ones that decide the core nature of a page.

Consider the following example where a URL determines the data for a Google search page with a search query and a filter:

https://www.google.com/search?q=hello&udm=7
Enter fullscreen mode Exit fullscreen mode

The structure of the URL shared above contains the following elements:

  • URL parameter: Also referred to as a path, a URL parameter identifies the specific location of the resource within the app or website (/search)
  • Query string: The entire segment after the question mark (?) in the URL (q=hello&udm=7)
  • Search parameters: Also known as query parameters, these are the individual key-value pairs in the query string (q=hello and udm=7), separated by an ampersand sign (&)

With their data, these URL units communicate with a backend behind the scenes to populate the frontend with relevant information. Google follows the same URL pattern for all search pages, where only the search parameters change to filter a search or perform another search operation.

Since the URL here contains all the triggers controlling the crucial information on the page, you can save or bookmark it and revisit it to access the expected data, which is a big plus from a UX perspective.

On the other hand, React apps do the exact opposite of the example above. By default, they can’t control the state from the URL unless we tell them to.

Advantages of URL-based state management

Managing states with the URL should establish a bidirectional data flow between the application states and the URL. This is important to keep the states synchronized and fresh and avoid using stale data.

Here’s what URL-based state management setup using React (with React Router DOM library) brings to the table:

  • Shareable views: Users can copy, share, and save URLs representing specific application states and catch up later from where they left off
  • Browser history integration: Seamless browser navigation using backward/forward arrow buttons
  • Server-side rendering compatibility: The initial state can be sent from the server through the URL and parsed on the client with router utilities; the rest of the app can operate on the client to manage the URL-based state handling

Managing states with URLs is largely a client-side concern, which makes client-side rendering the key focus of this article.

It’s worth noting that URL-based state management is not a replacement for traditional state management. These two work together to enable the utilization of URLs to persist views, making the overall app more accessible and user-friendly.

Step-by-step URL-based state management with React

Building a store-like utility is perfect for demonstrating the implementation of state management using URLs in React, as it poses some complex challenges, such as paging and filtering data.

Let’s create an app that uses a mock JSON API to fetch dummy product data in a paged fashion, as we might see on ecommerce apps. We’ll add more features later, such as filtering products on a categorical, price, and rating basis.

Setting up a React app with TypeScript is fairly simple with pnpm and Vite:

pnpm create vite@latest url-based-react-state -- --template react
Enter fullscreen mode Exit fullscreen mode

Pick TypeScript as the core language, and you are good to go.

After cd-ing into the project directory and installing the required dependencies, you may add Tailwind CSS to the app or skip it if you are considering a different UI solution.

Project folder structure

Creating dedicated directories to organize things is a best practice at the start of any project. I’m considering separating the API logic from the components and will provide them with the data they need with some custom React Hooks.

Following the same approach with the types, utilities, and configuration data, the final project folder looks something like the following: Project Folder Structure

Setting up the router

To work with routes and the browser URL, we should install the React Router DOM package, a library built on top of the core React router for handling routing smoothly.

At this point, we should also install the TanStack Query to avoid the repeating usage of state boilerplate in our hooks and handle errors and data caching more efficiently:

pnpm add react-router-dom @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

For the TanStack Query and routing to work properly, our App component should be wrapped within QueryClientProvider, which should be placed inside the BrowserRouter component as shown below:

// src/Main.tsx
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/* Other imports... */

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </BrowserRouter>
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

We can now add routes to the App component according to our requirements and hand them the components they are supposed to show:

// src/App.tsx

export default function App() {
  return (
    <>
      <Navbar />
      <Routes>
        <Route path="/" element={ /* HomePage component */ } />
        <Route path="*" element={ /* NotFoundPage component */ } />
      </Routes>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Defining types

As discussed, we are using the DummyJSON API to populate views in our app. If you have a backend API of your own, you may use it instead with the required changes in types and URL endpoints.

Here’s the URL the API provides us to fetch a list of products:

https://dummyjson.com/products/?limit=10&skip=0
Enter fullscreen mode Exit fullscreen mode

The limit and skip keys in the API URL determine the number of products to load and skip respectively. Both these search parameters work together to achieve different sets of data. The structure of the response we receive upon requesting this URL looks something like this: Defining Types Based on this JSON schema, we can construct types to handle different kinds of data in our app. You can also use tools like JSON2TS to convert the JSON schema instantly into TypeScript types:

// src/types/product.ts

// A single product
export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  ...
}

// A collection of products with additional response info
export interface ProductsResponse {
  products: Product[];
  total: number;
  skip: number;
  limit: number;
}

// Types for Query params
export ProductQueryParams {
  limit?: string;
  skip?: string;
}
Enter fullscreen mode Exit fullscreen mode

Some types will be used repeatedly throughout the project. We should group such types based on relevance and maintain them separately for better organization.

Defining constants

Let’s declare some configuration options before moving to the API logic part. These values should go right into the api.ts and pagination.ts files of the config folder:

// src/config/api.ts

export const API_CONFIG = {
  BASE_URL: "https://dummyjson.com",
  ENDPOINTS: {
    PRODUCTS: "/products",
    PRODUCTS_BY_CATEGORY: "/products/cateogry",
    PRODUCT_CATEGORIES: "/products/categories",
  },
  buildUrl: (endpoint: string) => `${API_CONFIG.BASE_URL}${endpoint}`,
};

// src/config/pagination.ts
export const PAGINATION_CONFIG = {
  ITEMS_PER_PAGE: 9,
  INITIAL_ITEMS_TO_SKIP: 0,
};
Enter fullscreen mode Exit fullscreen mode

The above definitions are self-explanatory and will provide crucial data like API URLs, the number of products per page, and more. The value 9 for ITEMS_PER_PAGE will facilitate the construction of a 3x3 product card grid later.

Using these configuration options, we can set up utility functions to neatly construct our API URLs:

// src/utils/getApiUrls.ts

export const getProductsUrl = () =>
  API_CONFIG.buildUrl(API_CONFIG.ENDPOINTS.PRODUCTS);
Enter fullscreen mode Exit fullscreen mode

Defining an API wrapper

After declaring types, we should create a new file in the apis directory, name it productApi.ts, and define an object called productApi in it. This object will act as an abstraction layer over the underlying DummyJSON API.

Since this object will contain endpoint functions that communicate with the API to bring us the data we need to show on the frontend, we can call it the API wrapper or API client. We may now define separate methods inside it to load a list of products, an individual product, categories, etc.

Let’s define getProducts, which takes an optional object (params) of type ProductQueryParams as an argument. With the limit and skip properties of params, we can construct a query string and attach it to the API URLs for loading the data in a paged fashion:

// src/apis/productApi.ts

export const productApi = {
  async getProducts(params?: ProductsQueryParams) {
    const queryParams = new URLSearchParams();

    // Add pagination params
    queryParams.append(
      "limit",
      (params?.limit ?? PAGINATION_CONFIG.ITEMS_PER_PAGE).toString()
    );
    queryParams.append(
      "skip",
      (params?.skip ?? PAGINATION_CONFIG.INITIAL_ITEMS_TO_SKIP).toString()
    );

    const response = await fetch(`${getProductsUrl()}?${queryParams}`);
    if (!response.ok) {
      throw new Error(
        `API Error: ${response.status} - failed to load products.`
      );
    }

    return response.json() as Promise<ProductResponse>;
  }
};
Enter fullscreen mode Exit fullscreen mode

The above definition of the getProduct method illustrates the use of native JavaScript’s URLSearchParams object for constructing a query string (queryParams) with the limit and skip values.

It then constructs the required API URL with queryParams, uses the fetch API to get a response from the DummyJSON server, and returns the JSON data as a promise after the basic error checking.

We have a choice to use this API method directly in our components. However, using a custom hook to construct the data and paging logic is a better approach.

Consuming the API with a custom hook

This custom hook communicates with our API client using certain search parameters of our app URL and generates loading, error, and data states accordingly.

With the useSearchParams Hook from the React Router DOM library, we can grab the value of search parameters from the query string of the app URL. In this case, we need the value of the page parameter, which, if not found, defaults to 1. This means the first page is always shown when the page search parameter is not found:

// src/hooks/useProducts.ts

export function useProducts(limit: number) {
  const [searchParams, setSearchParams] = useSearchParams();
  const currentPage = Number(searchParams.get("page")) || 1;
}
Enter fullscreen mode Exit fullscreen mode

When managing data, loading, and error states, the first thing that comes to mind is a big pile of useState Hooks. With TanStack Query, we don’t need all that boilerplate code to manage states. On top of that, we can add caching, conditional loading, error handling, and prefetching support right out of the box:

// src/hooks/useProducts.ts

export function useProducts(limit: number) {
  // Previously declared states...

  const { data, isLoading, error } = useQuery({
    queryKey: ["products", { limit, skip }],
    queryFn: () => productsApi.getProducts({ limit, skip })
  });
}
Enter fullscreen mode Exit fullscreen mode

When setting up a TanStack query, we provided an identity to the query with queryKey. We then gave queryFn a reference to our getProducts API function to load the data. If limit or skip values change, the query will automatically re-call the API function. You may specify other properties to the query to optimize it your way.

We can then calculate the total number of pages (totalPages) by dividing the total value available through the API response by limit we are using as a parameter for the useProducts Hook.

Defining a function to facilitate pagination is apt here, as we have all the info we need to control the paging and navigation between pages. The totalPages and currentPage values will help us formulate a pagination logic:

// src/hooks/useProducts.ts

export function useProducts(limit: number) {
  // Previously declared states...

  const totalPages = Math.ceil((data?.total || 0) / limit);

  const goToPage = useCallback(
    (page: number) => {
      if (page >= 1 && page <= totalPages) {
        setSearchParams((prev) => {
          const params = new URLSearchParams(prev);
          params.set("page", page.toString());
          return params;
        });
      }
    }, [totalPages, setSearchParams]
  );
}
Enter fullscreen mode Exit fullscreen mode

The useProducts Hook returns a long list of states, values, and methods that we can later access in a component:

// src/hooks/useProducts.ts

export function useProducts(limit: number) {
  // ...

  return {
    products: data?.products || [],
    total: data?.total || 0,
    isLoading,
    error,
    currentPage,
    totalPages,
    goToPage,
    hasNext: currentPage < totalPages,
    hasPrevious: currentPage > 1,
    goToNext: () => currentPage < totalPages && goToPage(currentPage + 1),
    goToPrevious: () => currentPage > 1 && goToPage(currentPage - 1),
  };
}
Enter fullscreen mode Exit fullscreen mode

Defining components

With the API client and useProducts Hook to do all the heavy lifting, the structure and functioning of components will be pretty straightforward.

Let’s create three components in the components/product directory that handle the rendering of individual cards, a grid of such cards, and the pagination of the card grid.

The ProductCard component takes the product data as a prop and uses its properties, such as title, name, price, thumbnail, etc., to give the card some identity. Note that this component will be part of the ProductGrid component, which we will define in the next segment:

// src/components/product/ProductCard.tsx

export function ProductCard({ product }: { product: Product }) {
  return (
    <article className="...">
      <div className="...">
        <h2 className="...">
          {product.title}
        </h2>

        <div className="...">
          <p className="...">
            ${product.price}
          </p>
        </div>
      </div>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, define a ProductGrid component that uses a collection of product data, allowing us to loop over it and assign the required data from each item to the ProductCard component:

// src/components/product/ProductGrid.tsx

export default function ProductGrid({ products }: { products: Product[] }) {
  return (
    <div className="...">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The ProductPagination component efficiently controls the paged navigation using several props. It will receive all values for these props later in the ProductPage component through the useProducts Hook:

// src/components/product/ProductPagination.tsx

interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onNext: () => void;
  onPrevious: () => void;
  hasNext: boolean;
  hasPrevious: boolean;
  isLoading: boolean;
}

export default function Pagination({
  currentPage,
  ...
  isLoading,
}: PaginationProps) {
  return (
    <nav
      className="..."
      aria-label="Pagination"
    >
      <button
        onClick={onPrevious}
        disabled={!hasPrevious || isLoading}
        aria-label="Previous page"
      >
        Previous
      </button>

      <span className="text-sm text-gray-700">
        Page {currentPage} of {totalPages}
      </span>

      <button
        onClick={onNext}
        disabled={!hasNext || isLoading}
        aria-label="Next page"
      >
        Next
      </button>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let’s put all these pieces together in the ProductsPage component. We grab all the necessities using the useProducts Hook first, and then provide these values appropriately to the ProductGrid and ProductPagination components as shown below:

// src/components/pages/ProductPage.tsx

export default function ProductsPage() {
  const {
    products,
    currentPage,
    ...
    isLoading
  } = useProducts(PRODUCTS_PER_PAGE);

  if (error) {
    return <div>{error.message}</div>;
  }

  return (
    <main>
      <div className="...">
        {loading ? (
          <div>Loading...</div>
        ) : (
          <>
            <ProductGrid products={products} />
            <Pagination
              currentPage={currentPage}
              ...
              isLoading={loading}
            />
          </>
        )}
      </section>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

One last thing remaining is to add a route in the App component and point it to the ProductsPage component. You may also set it to the main path, but I’m using the /products path because I’m using the main page to explain what this app does. You should also use a fallback component when the requested path doesn’t match any routes we have set up here:

// src/App.tsx

export default function App() {
  return (
    <>
      <Navbar />
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products" element={<ProductsPage />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

After running the app, if you navigate to the /products route, you should see several products and a nice pagination that allows you to move between different product pages. You can now bookmark any of these pages and visit them later to continue navigating from exactly where you left off.

Complex URL-based state scenarios

The product pagination part was pretty straightforward. We checked for the page query and worked around it. However, things get more complex when you also want to utilize multiple queries in the query string, which should also yield the expected results.

One such case is filtering data based on multiple parameters. The API we are using offers many options to request filtered data. Let’s implement two features to filter our products based on:

  • Categories: We fetch a list of categories from the API, display it in a sidebar, and load products in the ProductsGrid component based on these categories
  • Sorting: We also use a couple more queries to sort the product data based on price, reviews, and title

Implementing these two filters and making them work together while respecting pagination can be a challenging task.

Adding API support for sorting and category-wise loading

The sorting can be achieved using the API URL with sortBy and order query parameters as shown in the URL below:

https://dummyjson.com/products?sortBy=price&order=asc
Enter fullscreen mode Exit fullscreen mode

The DummyJSON provides sorting in three forms, title, pricing, and rating. The order query is supported only by pricing and title-based sorting.

This also requires adding sortBy, order, and category keys in the ProdcutParams type. You may make it stricter by specifying values for sortBy. For now, I’m keeping it a string only:

// src/types/product.ts
/* Previously added types... */

export type ProductsQueryParams = {
  limit?: number;
  skip?: number;
  sortBy?: string | null;
  order?: "asc" | "desc" | null;
  category?: string | null;
};
Enter fullscreen mode Exit fullscreen mode

If the sortBy and order search parameters are provided to the getProducts method, we will add them to the existing queryParams object.

Also, the loading of products according to the category is possible from the following URL structure:

https://dummyjson.com/products/category/smartphones
Enter fullscreen mode Exit fullscreen mode

We can easily form a separate URL for the category-based loading of products by grabbing the category slug from the query string. The modified getProducts method would look something like this:

// src/apis/productApi.ts

export const productsApi = {
  async getProducts(params?: ProductsQueryParams) {
    const queryParams = new URLSearchParams();

    // Add pagination params
    queryParams.append(
      "limit",
      (params?.limit ?? PAGINATION_CONFIG.ITEMS_PER_PAGE).toString()
    );
    queryParams.append(
      "skip",
      (params?.skip ?? PAGINATION_CONFIG.INITIAL_ITEMS_TO_SKIP).toString()
    );

    // Add optional sort params
    if (params?.sortBy && params?.order) {
      queryParams.append("sortBy", params.sortBy);
      queryParams.append("order", params.order);
    }

    // Pick the right URL
    const url = params?.category
      ? getCategoryProductsUrl(params.category)
      : getProductsUrl();

    const response = await fetch(`${url}?${queryParams}`);

    if (!response.ok) {
      throw new Error(
        `API Error: ${response.status}; failed to load products.`
      );
    }

    return response.json() as Promise<ProductsResponse>;
  },
};
Enter fullscreen mode Exit fullscreen mode

Loading a list of all categories

Getting a list of all the categories with labels and slugs is possible with the following API URL:

https://dummyjson.com/products/cateogories/
Enter fullscreen mode Exit fullscreen mode

Adding a method (getCategories) to load these categories is simple compared to the getProducts method:

// src/apis/productApi.ts

export const productsApi = {
  async getProducts(params?: ProductsParams) { ... },
  async getCategories() {
    const response = await fetch(`${PRODUCTS_URL}/categories`);

    if (!response.ok) {
      throw new Error(
        `API Error: ${response.status}; failed to load categories.`
      );
    }

    return response.json() as Promise<ProductCategory[]>;
  }
};
Enter fullscreen mode Exit fullscreen mode

Optimizing the hooks

We have to optimize the useProducts Hook to include sorting and category-based loading of products:

// src/hooks/useProducts.ts

export function useProducts(limit: number) {
  const [searchParams, setSearchParams] = useSearchParams();

  const currentPage = Number(searchParams.get("page")) || 1;
  const skip = (currentPage - 1) * limit;
  const sortBy = searchParams.get("sortBy");
  const order = searchParams.get("order") as "asc" | "desc" | null;
  const category = searchParams.get("category");

  const { data, isLoading, error } = useQuery({
    queryKey: ["products", { limit, skip, sortBy, order, category }],
    queryFn: () =>
      productsApi.getProducts({
        limit,
        skip,
        sortBy: sortBy || null,
        order,
        category,
      }),
  });

  // Pagination logic...

  return { ... };
}
Enter fullscreen mode Exit fullscreen mode

Also, we should add a new useCategories Hook that delivers a list of categories in a label-slug key-value pair, which is pretty simple:

// src/hooks/useCategories.ts

export function useCategories() {
  return useQuery({
    queryKey: ["categories"],
    queryFn: () => productsApi.getAllCategories(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Creating filter triggers

Let's set up a Sidebar component that uses a select combo box to trigger the sorting logic we just established. Adding some configuration for our sort options is a good starting point here. We can iterate through these options to form a select combo box later:

// src/config/sorting.js

export const SORT_OPTIONS = [
  { label: "Title (A-Z)", value: "title-asc" },
  { label: "Title (Z-A)", value: "title-desc" },
  { label: "Price (Low to High)", value: "price-asc" },
  { label: "Price (High to Low)", value: "price-desc" },
  { label: "Rating (High to Low)", value: "rating-desc" },
];
Enter fullscreen mode Exit fullscreen mode

In the Sidebar.tsx file, we can get the sortBy and order search parameters from our app URL with the help of the useSearchParams Hook provided by React Router DOM and use them to prepare the selected value for the select box dedicated to sorting the products:

// src/components/layout/Sidebar.ts

export default function Sidebar() {
  const [searchParams, setSearchParams] = useSearchParams();

  const sortBy = searchParams.get("sortBy");
  const order = searchParams.get("order");

  const currentSortValue = sortBy && order ? `${sortBy}-${order}` : "";
}
Enter fullscreen mode Exit fullscreen mode

We can then set up a handler function for the sorting select box to get the input value and set sortBy and order query parameters in the app URL using it:

// src/components/layout/Sidebar.ts

export default function Sidebar() {
  // ...

  const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    setSearchParams((prev) => {
      const params = new URLSearchParams(prev);
      const [sortBy, order] = event.target.value.split("-");
      if (sortBy && order) {
        params.set("sortBy", sortBy);
        params.set("order", order);
      } else {
        params.delete("sortBy");
        params.delete("order");
      }
      params.set("page", "1");
      return params;
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

We can then use the SORT_OPTIONS to construct our select combo and provide it with the right values we just established above:

// src/components/layout/Sidebar.ts

export default function Sidebar() {
  // ...

  return (
    <aside className={`hidden md:block w-60 flex-shrink-0 ${className}`}>
      <h2>Sort products</h2>
      <label htmlFor="sort">
        Sort By
      </label>
      <select
        id="sort"
        value={currentSortValue}
        onChange={handleSortChange}
        className={selectClassName}
      >
        <option value="">Default Sorting</option>
        {SORT_OPTIONS.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </aside>
  );
}
Enter fullscreen mode Exit fullscreen mode

Adding the Sidebar component

Finally, let's add the Sidebar component to the ProductPage component:

// components/pages/ProductPage.tsx

export default function ProductsPage() {
  const { ... } = useProducts(PRODUCTS_PER_PAGE);

  if (error) {
    return <div>{error.message}</div>;
  }

  return (
    <main>
      <Sidebar />
      <div className="...">
        {loading ? (
          <div>Loading...</div>
        ) : (
          <>
            <ProductGrid products={products} />
            <Pagination
              currentPage={currentPage}
              totalPages={totalPages}
              onNext={goToNext}
              onPrevious={goToPrevious}
              hasNext={hasNext}
              hasPrevious={hasPrevious}
              isLoading={loading}
            />
          </>
        )}
      </section>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Running the app and visiting the /products path should show you something like the following, where loading and filtering are managed through the URL:

GIF of filtering through URL

I’ve also implemented the loading of individual products when clicking corresponding card titles in the grid. You should also consider implementing it as an assignment.

Performance optimization in React apps using URL parameters

You typically don’t need useCallback or useMemo Hooks with TanStack Query, which is highly optimized and handles memoization pretty well by default.

If you choose not to use it, always consider using useCallback in your hooks to cache a function between re-renders and avoid unnecessary API calls. Also, implement useMemo if you want to cache a response for some time.

Memory optimization

The examples we saw in this tutorial maintain browser history, which can be expensive memory-wise with frequent URL updates. Consider using the useNavigate Hook and replacing history entries instead of pushing them to the browser history:

import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
// ...

navigate(`?${newParams.toString()}`, { replace: true });
Enter fullscreen mode Exit fullscreen mode

State synchronization between URL states and API calls

If you expand the app further and implement a product search feature, an API call is made whenever something is typed in the search box. To avoid such frequent updates and rapid URL updates, you should use a pattern like this with Lodash’s debounce method:

import debounce from 'lodash.debounce';
import { useSearchParams } from 'react-router-dom';

export default function Sidebar() {
  const [searchParams, setSearchParams] = useSearchParams();

  const updateSearchFilter = useCallback(
    debounce((searchTerm: string) => {
      setSearchParams(prev => {
        const params = new URLSearchParams(prev);
        params.set('search', searchTerm);
        return params;
      });
    }, 300),
    [setSearchParams]
  );

  return (
    { /* ... */ }
    <input 
      type="text"
      onChange={(e) => updateSearchFilter(e.target.value)}
      placeholder="Search products..."
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

This will keep URL states and API calls in sync, avoiding additional load on your app’s frontend as well as its backend.

Accessibility considerations in React apps with URL state management

The React Router DOM library solves browser accessibility by allowing users to use backward/forward browser navigation, which is absent in React apps by default.

General accessibility is pretty much the same as what we usually do with our React apps; we use ARIA to make the app accessible for screen readers and follow general accessibility practices.

For URL-based state management, always focus on providing the most commonly used information through the URL. Avoid exposing information you don’t want to provide publicly through the API, which mostly depends on how your backend and its API are built. Keep an eye on API security and its correct implementation.

SEO considerations for URL-based state management in React

URLs with different parameters are treated as separate pages by search engines, which is great from an SEO point of view. Search crawlers grab important pages on your app through internal links on your site, therefore consider implementing hyperlinks over input buttons for internal linking.

You should also consider creating a dynamic XML sitemap for such unique pages and submit it to major search engines for better visibility.

If a URL parameter doesn’t make any significant or unique change to the content of the rendered page, consider implementing URL canonicalization to avoid duplicate content problems. Here’s an example to add the right canonical URL to the paginated or filtered products using the React Helmet Async package for React:

import { Helmet } from 'react-helmet-async';
import { getCategoryProductsUrl, getProductsUrl } from '@/utils/getApiUrls';

export default function ProductPage() {
  const [searchParams] = useSearchParams();
  const category = searchParams.get('category');

  return (
    <>
      <Helmet>
        <link 
          rel="canonical" 
          href={`${category ? getCategoryProductsUrl(category) : getProductsUrls()}`}
        />
      </Helmet>

      {/* ... */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this guide, we explored managing state with URL and search parameters in React. We covered both simple and complex patterns through a store-like application, the source code of which you can find in this GitHub repo.

We also briefly examined some SEO, accessibility, and performance considerations for URL-based states.

I hope this tutorial helped you learn something new! If you got stuck anywhere, feel free to share your suggestions and questions in the comment section.


Get set up with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now.

Top comments (0)