skip to Main Content

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?

2

Answers


  1. In first useEffect you only need to update the URL when the state changes, so it’s correct that you’re including state as a dependency here. but removing searchParams from the dependency array can give you lots of issues. So, you could optimize the situation here by checking whether the current search params are different from the state and updating only if necessary.

    Only update the URL when there is an actual change (when the new value is different from the current one). You avoid unnecessary re-renders this way,

    1. In the first useEffect you should add both state and searchParam in the dependency array.
    2. in the second useEffect you are already syncing the url with state based on the searchParams. Since you are directly updating the state there, you don’t need to include state in dependency array.

    This approach should work:

    import { useState, useEffect } from "react";
    
    export default function useURLSync(
      searchParams,
      setSearchParams,
      paramName,
      type = "string",
      initialValue = ""
    ) {
      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"`);
      }
    
      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;
      });
    
      useEffect(() => {
        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, searchParams, paramName, type]);
    
    
      useEffect(() => {
        const paramValue = searchParams.get(paramName);
        let newStateValue = initialValue;
    
        if (paramValue) {
          if (type === "array") {
            newStateValue = paramValue.split(",");
          } else if (type === "string") {
            newStateValue = paramValue;
          }
    
          // Only change state if value is different
          if (JSON.stringify(newStateValue) !== JSON.stringify(state)) {
            setState(newStateValue);
          }
        } else if (state !== initialValue) {
          setState(initialValue);
        }
      }, [searchParams, paramName, initialValue, type, state]);
    
      return [state, setState];
    }
    
    
    Login or Signup to reply.
  2. Is intentionally omitting dependencies in useEffect the right way to
    prevent circular updates?

    No, you generally want to include all external references in the effect’s dependency array to ensure the effect runs with the latest/current dependency values. This avoids stale closures, etc. If you haven’t already, it is recommended you add eslint-plugin-react-hooks to your project to help highlight missing React hook dependencies.

    How can I properly synchronize the state and URL search parameters
    without causing circular updates or omitting necessary dependencies?

    One thing you could do would be to use the functional updates. This generally eliminates the "state" as a dependency for state updaters, e.g. setState(currentState => { /* logic to compute next state */ }); and similarly with the setSearchParams, using the functional update eliminates searchParams as an external dependency, e.g. setSearchParam(searchParams => { /* logic to compute next searchParams */ });.

    Overall though I think you’ve overcomplicated your logic.

    You basically need:

    1. Initialize the state to the param if it is defined, otherwise initialize to the initialValue.
    2. One useEffect hook to "react" to URLSearchParams updates and update the internal state
    3. One useEffect hook to "react" to state updates and update the URLSearchParams.

    Your code can be simplified to:

    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"`);
      }
    
      // Read the current search parameter value
      const param = searchParams.get(paramName);
    
      // Lazy initialize to param value or fallback to provided initial value
      const [state, setState] = useState(() => {
        if (param) {
          return type === "array" ? param.split(",") : param;
        }
        return initialValue;
      });
    
      // Effect to "synchronize" URLSearchParams to state updates
      useEffect(() => {
        setSearchParams(
          (searchParams) => {
            searchParams.set(paramName, state);
            return searchParams;
          },
          { replace: true }
        );
      }, [state]);
    
      // Effect to "synchronize" state to URLSearchParams updates
      useEffect(() => {
        if (param) {
          setState(type === "array" ? param.split(",") : param);
        } else {
          setState(initialValue);
        }
      }, [param, type]);
    
      // return stable array reference with return values
      return useMemo(() => [state, setState], [state, setState]);
    }
    

    Edit infallible-mountain-q4r4dw

    Alternative

    Note that is is generally considered a bit of an anti-pattern to duplicate state or store derived state into local React state. In this case I’d suggest simply returning the current URLSearchParam parameter value and a stable callback handler to update the URLSearchParams.

    Example:

    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"`);
      }
    
      // Read the current search parameter value
      const param = searchParams.get(paramName);
    
      // Memoize the current URLSearchParams parameter value
      const parameter = useMemo(() => {
        if (param !== null) {
          return type === "array" ? param.split(",") : param;
        }
        return initialValue;
      }, [initialValue, param, type]);
    
      // Memoized callback handler to update the URLSearchParams parameter
      const setParameter = useCallback((newParamValue) => {
        setSearchParams(
          (searchParams) => {
            searchParams.set(paramName, newParamValue);
            return searchParams;
          },
          { replace: true }
        );
      }, []);
    
      // Effect to "seed" URLSearchParams if not defined yet
      useEffect(() => {
        if (param === null && initialValue) {
          setSearchParams(
            (searchParams) => {
              searchParams.set(paramName, initialValue);
              return searchParams;
            },
            { replace: true }
          );
        }
      }, [initialValue, param]);
    
      // return stable array reference with return values
      return useMemo(() => [parameter, setParameter], [parameter, setParameter]);
    }
    

    Edit blue-wildflower-6hjlnl

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search