The problem
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>
);
};
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);
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;
};
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");
}
Read State
We are going to have 3 values:
-
queryValue
: The query value in URL as is (string | null). -
queryValueRef
: Copy ofqueryValue
, we will use it as a mechanism to detect external change in the URL. We will keepqueryValue
andqueryValueRef
always in-sync so wheneverqueryValue !== queryValueRef
it means there is an external change to the url anduseEffect
should run to re-syncvalue
. -
value
: parsedqueryValue
, otherwiseinitialValue
.
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]);
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 });
};
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`, "");
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]);
Example usage
By simply adding 1 line in our list component:
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
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:
Thanks for reading!
Top comments (0)