DEV Community

Ahmed Alsinan
Ahmed Alsinan

Posted on

How to TRULY persist component state using a custom hook - useQueryParam

The problem

Typical app showing list and details pages

It's very common to have react apps consisting of list page and upon clicking on a row we navigate to the details page, the problem is navigating back and forth loses our list's state.

  • Go to details page then back to the list using either browser or in-page back button.
  • Refresh the page.
  • Open details page in a new tab and go back to the list.

In all cases our precious search text, filters, sorts and pagination are lost

The Solution

Persist the state using query/search params, either by:

Global state management libraries

I would go with this approach only if:

  • ✅ Already have one installed with url sync support, since installing one just for this purpose is overkill and will increase bundle size by much.
  • ✅ State actually needs to be global

Example: Url Persistence | Recoil

Libraries specific to sync query params

use-query-params is very well maintained and tested, would recommend for production apps 👌.

The Implementation

HOWEVER, In this blog we're going to build our own custom hook useQueryParam for couple of reasons:

  • Lighter alternative (also less features) to use-query-params but with straightforward initial value as 2nd param.
  • 🏋️‍♀️ Exercise!

Search Params context

We're gonna have as many useQueryParam hooks and they need to share the same SearchParams object during a render lifecycle, so all updates are accounted for when re-rendering.

We can use useContext, so let's make our own context provider:

export const SearchParamsContext = createContext<URLSearchParams | null>(null);

export const SearchParamsProvider = ({
  children,
}: {
  children: React.ReactNode | React.ReactNode[];
}) => {
  const [searchParams] = useSearchParams();

  return (
    <SearchParamsContext.Provider value={searchParams}>
      {children}
    </SearchParamsContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Utilities

We need these utilities before implementing useQueryParam:

Falsy values

{}, [], 0 , false

There is no point of storing falsy values in the URL, we need this function later:

const isFalsy = (value: any) =>
  !value ||
  JSON.stringify(value) === "{}" ||
  (Array.isArray(value) && value.length === 0);
Enter fullscreen mode Exit fullscreen mode
Date parsing

Stringfying and parsing data types is straightforward except for dates, will need to perform regex test to convert them from strings to date object:

// Copied from StackOverFlow
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

const parseDate = (k: string, v: any) => {
  if (ISO_8601.test(v)) {
    return new Date(v);
  }
  return v;
};
Enter fullscreen mode Exit fullscreen mode

useQueryParam

Let's start the custom hook by using the context, and importing setSearchParams from react-router v6.4:

Using SearchParamsContext
export const useQueryParam = <T,>(key: string, initialValue: T) => {
  const searchParams = useContext(SearchParamsContext);
  const [, setSearchParams] = useSearchParams();

  if (!searchParams) {
    throw new Error("SearchParams provider is required");
  }
Enter fullscreen mode Exit fullscreen mode
Read State

We are going to have 3 values:

  • queryValue: The query value in URL as is (string | null).
  • queryValueRef: Copy of queryValue, we will use it as a mechanism to detect external change in the URL. We will keep queryValue and queryValueRef always in-sync so whenever queryValue !== queryValueRef it means there is an external change to the url and useEffect should run to re-sync value.
  • value: parsed queryValue, otherwise initialValue.

Only value is going to be exposed to the outside which should be always latest + parsed ready for usage!

  const queryValue = searchParams.get(key);
  // Memoize prev (queryValue) to detect if it has changed externally and not from this hook to sync it back.
  const queryValueRef = useRef<string | null>(queryValue);

  const [value, setValue] = useState(() =>
    queryValue !== null
      ? (JSON.parse(queryValue, parseDate) as T)
      : initialValue
  );

  // Sync query back to state if change is detected
  useEffect(() => {
    if (queryValue !== null && queryValue !== queryValueRef.current) {
      setValue(JSON.parse(queryValue, parseDate) as T);
      queryValueRef.current = queryValue;
    }
  }, [queryValue]);
Enter fullscreen mode Exit fullscreen mode
Set State

Last but not least our set function is going to check if the new value is falsy it will be deleted from the URL, otherwise we update searchParams object and update the URL:

  const set = (newValue: T | ((v: T) => T)) => {
    const invokedValue =
      newValue instanceof Function ? newValue(value) : newValue;

    if (isFalsy(invokedValue)) {
      setValue(initialValue);

      searchParams.delete(key);
      queryValueRef.current = null;
    } else {
      setValue(invokedValue);

      const invokedStringified = JSON.stringify(invokedValue);
      searchParams.set(key, invokedStringified);
      queryValueRef.current = invokedStringified;
    }

    setSearchParams(searchParams, { replace: true });
  };
Enter fullscreen mode Exit fullscreen mode

useLastSearch

So far we only handled these:

  • ✅ Going back to the list page using browser back button.
  • ✅ Refresh the page.
  • ❌ Going back to the list page using in-page back button.
  • ❌ Open details page in a new tab and go back to the list.

This is why the title mentions truly persist, we are going to handle the other two cases using useLastSearch which is responsible to:

  • Store last search query in localStorage.
  • Load it back whenever special navigation flag asking to retrieve it.
Local Storage

We will store last search query string in local storage, thus I will summon useLocalStorage from usehooks-ts:

export const useLastSearch = (key: string) => {
  const [lastSearch, setLastSearch] = useLocalStorage(`${key}/last-search`, "");
Enter fullscreen mode Exit fullscreen mode
useEffect

Here if loadLastSearch navigation state is received from details page we will push back last query string and clear the flag, otherwise keep storing last value whenever it updates:

useEffect(() => {
  if (location.state?.loadLastSearch) {
    navigate(location.pathname + lastSearch, { replace: true });
  } else {
    setLastSearch(location.search);
  }
}, [lastSearch, location, navigate, setLastSearch]);
Enter fullscreen mode Exit fullscreen mode
Example usage

By simply adding 1 line in our list component:

  useLastSearch("user-list");
Enter fullscreen mode Exit fullscreen mode
⚠️ Caveat

This hook only stores the last search which means if the user attempted to open 3 tabs coming from multiple searches then going back from any of them will invoke the last search.

Complete Example

export const TaskList = () => {
  const [pageIndex, setPageIndex] = useQueryParam("pageIndex", 0);
  useLastSearch("task-list");

  return (
     <MagicList/>
  );
};

export const Details = () => {
  const navigate = useNavigate();

  return (
    <>
      <button
        onClick={() =>
          navigate("/task-list", {
            state: { loadLastSearch: true },
          })
        }
      >
        Back
      </button>
      <Details />
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

Conclusion

Combining these two hooks solve the four cases mentioned, you're welcome to share your ideas or improvements on the shared code.

Check full code in Github gists:

Thanks for reading!

Top comments (0)