Sergio Xalambrí

Automatic Revalidation in Remix

If you have used SWR or React Query, you may be used to a feature both libraries have called automatic revalidation. If you don't know what that is when you use one of these libraries, it will automatically revalidate your data in certain conditions, in this case, revalidation means it will fetch the data again so it's up-to-date, Remix by default has a great approach to revalidation where it will automatically do it for you after every action (aka submitting a form) so if you mutate your data it will revalidate to ensure it's up-to-date, but it doesn't support some patterns those libraries have.

Let's see an example, imagine we create a custom hook called useCurrentUser that uses either SWR or React Query internally and defines this function to fetch the current user.

function fetchCurrentUser() {
  return fetch('/api/current-user').then(res => res.json());
}

Something super simple.

If we call our hook in a React component we will get the data returned by the API.

let { data } = useCurrentUser();

The first time we render a component calling our hook it the data will be undefined, then when the data is fetched it will be set to the data returned by the API. After that, both libraries will automatically run our fetchCurrentUser function again if:

In some types of applications, this can be a really useful behavior you will get for free. If you are building some dashboard-like web app, for example, you can use this feature to automatically refresh the data in your app without the user having to do anything.

In Remix, we do our data fetching on the routes loaders, so we could do something like this:

async function fetchCurrentUser(request: Request) {
  let token = await getUserToken(request);
  let response = fetch('/api/current-user', {
    headers: { Authorization: `Bearer ${token}` },
  })
  return response.json();
}

export let loader: LoaderFunction = async ({ request }) => {
  return json({ currentUser: await fetchCurrentUser(request) });
}

With this, Remix will automatically fetch the data when the route loads, but there's no way to tell Remix to revalidate it like in SWR or React Query. Let's see how we could do implement this in Remix.

Refresh the loader

Let's create a hook we can use to trigger a refresh of the data. The way these works is by triggering a navigation event to the same URL the user is right now, this will trigger Remix to call the loader of the route.

function useRevalidate() {
  // We get the navigate function from React Rotuer
  let navigate = useNavigate();
  // And return a function which will navigate to `.` (same URL) and replace it
  return useCallback(function revalidate() {
    navigate('.', { replace: true });
  }, [navigate]);
}

The replace: true part is important to avoid adding the same route to the history stack, if we didn't have that option and the user pressed back in their browser it would go back to the same URL but with the previous data, replacing it the URL with old data is removed from the history stack so the user will correctly go back to the previous page it was before.

You could also import the useRevalidate hook from Remix Utils which does the same thing.

Revalidate on Focus

When the user is currently interacting on the browser tab with your application open then your application has the current system focus. If the user switches to another tab of the browser or another application then it will lose focus.

Let's say the user opened your dashboard and then switched to another application, a few hours later the user came back to the tab where it has your dashboard open.

Most likely the data on the dashboard is inaccurate at that point because the user has been away for a while. The idea here is that we can detect the focus change and revalidate the data to ensure it's up-to-date.

interface Options {
  enabled?: boolean;
}

function useRevalidateOnFocus({ enabled = false }: Options) {
  let revalidate = useRevalidate();

  useEffect(function revalidateOnFocus() {
    if (!enabled) return;
    function onFocus() { revalidate() }
    window.addEventListener('focus', onFocus);
    return () => window.removeEventListener('focus', onFocus);
  }, [revalidate]);

useEffect(function revalidateOnVisibilityChange() {
    if (!enabled) return;
    function onVisibilityChange() { revalidate() }
    window.addEventListener('visibilitychange', onVisibilityChange);
    return () => window.removeEventListener('visibilitychange', onVisibilityChange);
  }, [revalidate]);
}

Now we can call our hook in any component of the route.

function SomeComponent(props) {
  useRevalidateOnFocus({ enabled: true })
  return <SomeUI {...props} />
}

And with this, if a route renders SomeComponent it will start revalidating the data when the user switches to the tab of the browser. If we call this hook in the root.tsx file it will enable it for all routes.

Revalidate on Interval

If you are building a real-time application most likely you will want to set up a WebSocket server to send events from the server to the client so it can update the data on the UI.

However, building a WebSocket server is not the easiest thing in the world to get it right, instead we can build an almost real-time application by revalidating the data every X seconds. This will give the user the sense the data is updated immediately even if it's not the case.

interface Options {
  enabled?: boolean;
  interval?: number;
}

function useRevalidateOnInterval({ enabled = false, interval = 1000 }: Options) {
  let revalidate = useRevalidate();
  useEffect(function revalidateOnInterval() {
    if (!enabled) return;
    let intervalId = setInterval(revalidate, interval);
    return () => clearInterval(intervalId);
  }, [revalidate]);
}

Now, this will revalidate the data every X seconds. We only need to call this hook in the root.tsx file and it will start doing it for every route, or we can call it in a specific route if we don't want it all the time.

Revalidate on Reconnect

Let's say the user is on your dashboard and their internet connection is lost, with luck, this only takes a few seconds and the user may not even notice it, but maybe it takes them a few minutes to fix the user, by the moment they come back to the tab the data on the screen may be stale.

We can detect the reconnection event and trigger a revalidation so the user is up-to-date.

interface Options {
  enabled?: boolean;
}

function useRevalidateOnReconnect({ enabled = false }: Options) {
  let revalidate = useRevalidate();
  useEffect(function revalidateOnReconnect() {
    if (!enabled) return;
    function onReconnect() { revalidate() }
    window.addEventListener('online', onReconnect);
    return () => window.removeEventListener('online', onReconnect);
  }, [revalidate]);
}

And now we can call our hook in root.tsx or some specific route and enable this feature automatically.

Some Pitfalls

Like everything in programming, there are tradeoffs, so let's take a look at some of the pitfalls we can run into. Note these could not only happen with the implementation above for Remix but also when using SWR or React Query.

Multiple Concurrent Revalidations

Thanks to Remix this shouldn't happen that much since Remix aborts the previous request and only use the data of the latest one, but if the request already started and we trigger a new one because of the revalidation, the first request most likely already run the loader so our database queries or API fetches will be called twice. If you combine more than one revalidation strategy this is most likely to happen.

This may not be a big deal in some applications but it can become an issue in some cases, especially if you are running some expensive queries or API calls.

To fix this, we can create some context to know the last time the data was revalidated and only revalidate if the data is older than X seconds. This way we can avoid calling the loaders multiple times.

Scroll Position

Let's say you are building a feed-like application like Twitter, Instagram, a chat, etc., and the user scrolled down (or up) to see more content, if you have the revalidation enabled it's triggered for some reason the data will automatically update and if new content is added on the screen the content the user is currently reading may be pushed down or up to make room for the new content.

This is a common problem and we can fix it by disabling the revalidation when the user is scrolling. We would need to detect the scroll position and only allow a revalidation revalidate when the user is not scrolling. If you are building a more complex application with multiple scrolls you will need to take into account every possible scroll that may be affected by a revalidation.

Auto inflinged DDoS

If you revalidate in an interval, and you have multiple users with your application open, and the interval is configured for a few seconds, all your users may trigger a revalidation at the same time, this can cause a DDoS attack to your application if you have enough users since all of them will call your server, if your loader does some heavy sync operation it can block the server for the next user.

The easiest way to prevent this is to don't use the interval revalidation with short times or try to use a WebSocket server so you don't need to call your loaders to update the data on the screen.