How to TRULY persist component state using a custom hook - useQueryParam
Tags: react, javascript, frontend, typescript
Published: 2023-05-26 12:22:09

## The problem


![Typical app showing list and details pages](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nxabe1cinn2p5kkfwyi7.png)

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](https://recoiljs.org/docs/recoil-sync/url-persistence/)

### Libraries specific to sync query params

[use-query-params](https://www.npmjs.com/package/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](https://www.npmjs.com/package/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:

```typescript
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>
  );
};
```

#### 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:

```typescript
const isFalsy = (value: any) =>
  !value ||
  JSON.stringify(value) === "{}" ||
  (Array.isArray(value) && value.length === 0);
```

###### 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:

```typescript
// 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;
};
```

### useQueryParam

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

###### Using SearchParamsContext

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

  if (!searchParams) {
    throw new Error("SearchParams provider is required");
  }
```

###### 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!

```typescript
  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]);
```

###### 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:

```typescript
  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 });
  };
```
 
### 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](https://usehooks-ts.com/):

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

###### 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:

```typescript
useEffect(() => {
  if (location.state?.loadLastSearch) {
    navigate(location.pathname + lastSearch, { replace: true });
  } else {
    setLastSearch(location.search);
  }
}, [lastSearch, location, navigate, setLastSearch]);
```

###### Example usage

By simply adding 1 line in our list component:

```typescript
  useLastSearch("user-list");
```

###### ⚠️ 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

```typescript
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 />
    </>
  );
};

```

## 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: 
- [useQueryParam.tsx](https://gist.github.com/tekno0ryder/469ce409012a20a32f3ed45d152a4328) 
- [useLastSearch.ts](https://gist.github.com/tekno0ryder/f25158983647978013979ce3aa6bba49)

Thanks for reading!