Vue-router Keepalive

When using vue-router (v4) for your Single-Page-Application, one of the options is to combine it with the <keep-alive> tag. However, this may break your page components in unexpected ways, if done naively.

A typical usage of keep-alive with vue-router looks like this:

<router-view v-slot="{Component}">
    <keep-alive>
        <component :is="Component" />
    </keep-alive>
</router-view>

And the recommended way to fetch data after navigation looks like this:

const route = useRoute()
watch(
    () => route.params.id,
    fetchTheDataHandler
)

But beware, what if you have another component that is also using the id param. Your components will actually react to changes in the id that is not really meant for them. Even if components that are deactivated (not shown, but kept alive) seem to have their watcher disabled, this does not work perfectly on component transitions. And {flush: 'post'} does not help here.

So supose you have two pages, user/foo and product/bar (where foo and bar are id parameters). Let’s start with the user page loaded, then navigate to product. Then press back. Two things will happen. product page will try to load its data, because the watcher sees id changing from bar to foo, before it gets deactivated. Meanwhile the user page sees an id change after getting activated. So you loose state on both your components and trigger a data fetch. Not good, not what you had in mind when trying to use <keep-alive>.

There are some solutions floating around, but I think the correct one is to:

  • Watch only changes if the route matches current component
  • Only react to changes if the value actually changes

For that, I wrote a helper for composition API, that can be used like this:

watchRoute((r) => r.params.id, fetchDatahandler);

The helper takes care of checking the current component and checking if the value actually did change (in a deep comparison sense).

type RouteWatcher<T> = (l: RouteLocationNormalizedLoaded) => T;
type RouteWatchEffect<T> = (l: T) => void;

export function watchRoute<T>(
    watcher: RouteWatcher<T>, handler: RouteWatchEffect<T>
    ): RouteLocationNormalizedLoaded 
{
    const route = useRoute();
    const componentType = getCurrentInstance()?.type;
    const routeComponentType = () => {
        const matched = route.matched[0];
        if (matched && matched.components) {
            return matched.components['default'];
        }
    };
    let lastParams: T | undefined = undefined;
    watch([() => watcher(route), routeComponentType], ([params, type]) => {
        if (type == componentType) {
            if (!lodash.isEqual(lastParams, params)) {
                lastParams = params;
                handler(params);
            }
        }
    }, {immediate: true});
    return route;
}