6 min read
Sometimes, you want to refetch data when the tab or browser window is focused. This is especially useful if we have data that changes frequently and we want to show the latest information. Normally, I would use TanStack Query for this purpose, but for just one fetch, it is a bit excessive. Therefore, I have decided to write a custom hook for this task.
The API
Let's start by focusing on the API. The API should be a hook that is flexible enough to be used in different scenarios. So, I decided to go with a hook that takes a function which returns a promise. The hook should return the data, an error, and a loading state. Let's start with the types.
type FetchDataFunction<T> = () => Promise<T>;type State<T> = { data?: T; error?: Error; isLoading: boolean;};type useRefreshOnFocus = <T>(fn: FetchDataFunction<T>) => State<T>;
Looks good so far, but I don't like the State
type because it is not clear enough.
With this State
type, it is not clear that data
and error
are mutually exclusive,
and isLoading
should only be true if both are undefined.
So let's change it to a discriminated union.
type State<T> = | { data: T; error?: undefined; isLoading: false; } | { data?: undefined; error: Error; isLoading: false; } | { data?: undefined; error?: undefined; isLoading: true; };
This looks much better. Let's implement the hook.
State management
The data fetching is done in a useEffect
hook.
For the state, we use the useState
hook.
We could do something like this:
const [data, setData] = useState<T>();const [error, setError] = useState<Error>();const [isLoading, setIsLoading] = useState(true);
But this makes it hard to update the state based on the previous state because we have to define data as part of the dependency array of the effect,
which causes the effect to run every time the data changes.
Having multiple state variables also makes it very easy to return an inconsistent state.
However, with a single state variable, we can easily update the state based on the previous state.
To achieve this, we pass a function to the setState
function that receives the previous state as an argument, for example:
const [state, setState] = useState<State<T>>({ isLoading: true,});setState((prevState) => { if (!prevState.data) { return { isLoading: true, }; } return s;});
Now, we can add the useEffect
hook.
export const useRefreshOnFocus = <T>(fn: FetchDataFunction<T>) => { const [state, setState] = useState<State<T>>({ isLoading: true, }); useEffect(() => {}, [fn]); return state;};
The only dependency of the effect is the function that fetches the data. Okay, let's implement the effect.
useEffect(() => { const fetchData = async () => { // implementation comes next }; // fetch initial data fetchData(); // refetch data when the tab or browser window is focused const handleVisibilityChange = () => { if (!document.hidden) { fetchData(); } }; // register event listeners window.addEventListener("focus", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { // unregister event listeners when the component is unmounted window.removeEventListener("focus", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange); };}, [fn]);
The code snippet above calls fetches the initial data and set up event listeners for the focus
and visibilitychange
events.
The window focus
event is triggered when the tab or browser window gains focus, while the visibilitychange
event is triggered when the tab is switched.
If either of these events is triggered and the tab is visible, the data is re-fetched.
Let's proceed with the implementation of the fetchData
function.
Fetching the data
Our data-fetching function begins by setting the loading state. However, we should only set the loading state if we don't have any data. his is crucial because we don't want to display a loading spinner when the user switches back to the tab. Nevertheless, we need to set the loading state again because the function may change between renders.
setState((prevState) => { if (!prevState.data) { return { isLoading: true, }; } return prevState;});
Then, we can call our function to fetch the data and update the state.
const data = await fn();setState({ data, isLoading: false,});
But we should keep in mind that the function could fail and throw an error, so we have to catch the error. However, we only want to set the error state if we don't have data, because in most cases, it is better to show stale data than an error message.
try { const data = await fn(); setState({ data, isLoading: false, });} catch (e) { let error: Error; if (e instanceof Error) { error = e; } else { error = new Error(`Failed to fetch data: ${e}`); } setState((prevState) => { if (!prevState.data) { return { isLoading: false, error, }; } return prevState; });}
All together
Finally, we can put everything together. Here is the complete code for our hook.
Usage
Now, we can use our hook to fetch some data.
In the example above, we can iterate over the users without having to check if the data is defined.
This is possible because we return immediately if isLoading
is true
or error
is defined.
Additionally, our types are well defined in TypeScript,
so it knows that data
is defined if isLoading
is false
and error
is undefined.
Posted in: react, focus, web