Skip to content

Instantly share code, notes, and snippets.

@subvertallchris
Last active August 10, 2023 01:26
Show Gist options
  • Save subvertallchris/782de8f59588eefc200bd712d3070338 to your computer and use it in GitHub Desktop.
Save subvertallchris/782de8f59588eefc200bd712d3070338 to your computer and use it in GitHub Desktop.
Next.js app directory client cache busting
'use server';
import { revalidatePath } from 'next/cache';
// eslint-disable-next-line @typescript-eslint/require-await
export const revalidateAction = async (path: string) => {
revalidatePath(path);
};
'use server';
import * as React from 'react';
import { revalidateAction } from './cacheBusterAction';
import { ClientCacheBusterContainer } from './ClientCacheBuster';
export const CacheBusterContainer = ({ children }: { children: React.ReactNode }) => {
return <ClientCacheBusterContainer revalidateAction={revalidateAction}>{children}</ClientCacheBusterContainer>;
};
'use client';
import Link, { LinkProps } from 'next/link';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useTransition } from 'react';
interface ClientCacheBusterContextValue {
queueInvalidation: (route: string) => void;
}
export const ClientCacheBusterContext = createContext<ClientCacheBusterContextValue>({
queueInvalidation: () => {},
});
export const ClientCacheBusterContainer = ({
children,
revalidateAction,
}: {
children: React.ReactNode;
revalidateAction: (pathname: string) => Promise<void>;
}) => {
const pathname = usePathname();
const invalidationQueueRef = useRef<string[]>([]);
const [_, startTransition] = useTransition();
const queueInvalidation = useCallback((route: string) => {
invalidationQueueRef.current.push(route);
}, []);
useEffect(() => {
if (invalidationQueueRef.current?.at(0) === pathname) {
return;
}
const head = invalidationQueueRef.current.shift();
if (head) {
startTransition(async () => {
await revalidateAction(head);
});
}
}, [revalidateAction, pathname, startTransition]);
return (
<ClientCacheBusterContext.Provider value={{ queueInvalidation }}>{children}</ClientCacheBusterContext.Provider>
);
};
type LinkPropsReal = React.PropsWithChildren<
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & LinkProps
>;
export const ExtendedLink = forwardRef<HTMLAnchorElement, LinkPropsReal & { href: string }>(
function ExtendedLinkFunction({ onClick, ...props }, ref) {
const context = useContext(ClientCacheBusterContext);
const wrappedOnClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (props.href) {
context.queueInvalidation(props.href);
}
if (onClick) {
onClick(e);
}
},
[context, onClick, props.href],
);
return <Link {...props} ref={ref} onClick={wrappedOnClick} />;
},
);

What is this?

This is an approach to navigation using the Next.js 13 App Router that will step around the client cache rules. It addresses the concerns described at this monster GitHub issue: vercel/next.js#42991.

Just tell me how to use it.

My, you're in a hurry! Please read the rest but to get started:

  1. Wrap the entire app in the server-rendered CacheBusterContainer.
  2. Anywhere we have a link that we do not want to cache, we use ExtendedLink instead of link.

That's it.

Why is this necessary?

The Next.js App Router has opinionated rules about caching. Among them is a default by which any page visited by following a Link (<Link href={somePath}>) will be held in a client-side cache for 5 minutes or until the cache is manually cleared using revalidatePath. The only supported alternative to this is setting prefetch={false}, which reduces the 5 minute cache to 30 seconds. For many uses cases, 30 seconds is too long.

Consider the following ecommerce site scenario:

  1. A customer visits a product page. They look at it for a monent and then browse back to look at more items.
  2. While they are elsewhere, the product sells out.
  3. They return to the page but they see the cached result. This is held in the client -- there is no way for them to know the content is expired. They try to add it to their cart and receive an error message. They think the site is broken, they leave.

What we would like to see happen: they return to the page, it renders new content from the server, they see it is sold out, they are sad they missed their opportunity.

How does this work?

  1. The server container's job is to pass an action, revalidateAction, down to the client container. This works around a bug, something about cache... something. I don't have it in front of me. Try it without the server component and see. :-)
  2. The client container provides a context object. This context object exposes a callback that accepts a string of a path to invalidate.
  3. When an ExtendedLink is clicked, it:
  • Calls the callback defined by the client container. It provides the path of the link that was just clicked.
  • The client container also has a mutable ref of type string[]. When the callback is called, the pathname provided as an argument is added to this array.
  • The client container has a useEffect that is triggered when the pathname changes. On every pathname change, it compares the head (element 0) of the mutable ref to the new path. If element 0 is not empty and it does not match the new path, it calls the server action which wraps revalidatePath.

In other words, we hold a list of routes that we want to invalidate (ExtendedLink links clicked) and we wait until someone leaves the paeg before we invalidate them.

What are alternatives to this?

A handful of alternatives exist. You can read about some of them here. I provided instructions on using patch-package here.

What should I be worried about?

  1. You're using a server action. Server actions are in alpha, the interface might change.
  2. Your server will be doing more work. If you're on a serverless platform, this will make your functions work harder. That could be a problem! Especially if you're on Vercel's free tier, which apparently limits your revalidatePath calls to 100 per month. Be careful!
  3. I have not tested this thoroughly. I do not know what potential side-effects it might cause. Please use this carefully!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment