Synchronize react state with react router url search params

I’m building an e-commerce page using React that includes various options stored in state, such as sortBy, itemsPerPage, and several interdependent categories. I want these state values to also be reflected in the URL as search parameters.

I’m using React Router’s useSearchParams hook to keep the state and URL in sync. However, I’m encountering issues with useEffect dependencies and potential logical flaws due to intentionally omitting certain values from the dependency array to avoid circular dependencies.

I created a custom hook called useURLSync so that different state can sync and share the same searchParams object returned by the useSearchParams hook and to hide all the synchronization logic.

import { useState, useEffect } from "react";

export default function useURLSync(
  searchParams,
  setSearchParams,
  paramName,
  type = "string",
  initialValue = ""
) {
  // Type validation
  if (type === "array" && !Array.isArray(initialValue)) {
    throw new Error(
      `useURLSync: initialValue must be an array when type is "array"`
    );
  }
  if (type === "string" && typeof initialValue !== "string") {
    throw new Error(`
        useURLSync: initialValue must be a string when type is "string"`);
  }

  // Intiliaze state from URL search params according to the paramName if it exists,
  // if not, return the initial value
  const [state, setState] = useState(() => {
    if (searchParams.has(paramName)) {
      const paramValue = searchParams.get(paramName);
      if (paramValue) {
        if (type === "string") {
          return paramValue;
        } else if (type === "array") {
          return paramValue.split(",");
        }
      }
    }
    return initialValue;
  });

  // Update the URL when state changes
  useEffect(() => {
    // Create a new URL search params object from a copy of the current one
    const newSearchParams = new URLSearchParams(searchParams);
    let shouldChange = false;

    if (state.length > 0) {
      const currentParamValue = searchParams.get(paramName);

      if (type === "array") {
        const newParamValue = state.join(",");
        if (newParamValue !== currentParamValue) {
          newSearchParams.set(paramName, newParamValue);
          shouldChange = true;
        }
      } else if (type === "string") {
        const newParamValue = state;
        if (newParamValue !== currentParamValue) {
          newSearchParams.set(paramName, newParamValue);
          shouldChange = true;
        }
      }
    } else if (newSearchParams.has(paramName)) {
      newSearchParams.delete(paramName);
      shouldChange = true;
    }

    if (shouldChange) {
      setSearchParams(newSearchParams, { replace: true });
    }
  }, [state]);

  // Update state when URL seach params changes
  useEffect(() => {
    const paramValue = searchParams.get(paramName);
    let newStateValue = initialValue;
    if (paramValue) {
      if (type === "array") {
        newStateValue = paramValue.split(",");
      } else if (type === "string") {
        newStateValue = paramValue;
      }
      if (JSON.stringify(newStateValue) !== JSON.stringify(state)) {
        setState(newStateValue);
      }
    } else if (state !== initialValue) {
      setState(initialValue);
    }
  }, [searchParams]);

  return [state, setState];
}

And here is how it is used:


  // Search Params
  const [searchParams, setSearchParams] = useSearchParams();

  // Page State
  const [page, setPage] = useURLSync(
    searchParams,
    setSearchParams,
    "page",
    "string",
    "1"
  );

  // PerPage state
  const [perPage, setPerPage] = useURLSync(
    searchParams,
    setSearchParams,
    "perPage",
    "string",
    "12"
  );

  // Sort state
  const [sort, setSort] = useURLSync(
    searchParams,
    setSearchParams,
    "sort",
    "string",
    "alpha-desc"
  );

  const [selectedPlatforms, setSelectedPlatforms] = useURLSync(
    searchParams,
    setSearchParams,
    "platforms",
    "array",
    []
  );

In the first useEffect, I update the URL whenever the React state changes. I include the state in the dependency array. Within this useEffect, I also use the searchParams object returned by useSearchParams to get the current parameter value and check if a change is necessary. I intentionally omit searchParams from the dependency array to avoid a circular dependency.

In the second useEffect, I update the state whenever the URL search parameters change. Here, I include searchParams in the dependency array but omit the state, even though I use it for comparison.

Is intentionally omitting dependencies in useEffect the right way to prevent circular updates? How can I properly synchronize the state and URL search parameters without causing circular updates or omitting necessary dependencies?

Thank you!