Skip to content

Instantly share code, notes, and snippets.

@denk0403
Forked from OliverJAsh/SkipRenderOnClient.tsx
Last active April 20, 2026 06:04
Show Gist options
  • Select an option

  • Save denk0403/be1365575f961b6349de89f7655ffedf to your computer and use it in GitHub Desktop.

Select an option

Save denk0403/be1365575f961b6349de89f7655ffedf to your computer and use it in GitHub Desktop.
<SkipRenderOnClient shouldRenderOnClient={() => window.innerWidth <= 500}>
<MyComponent />
</SkipRenderOnClient>
import {
Activity,
memo,
ReactNode,
useEffect,
useRef,
type FC,
type PropsWithChildren
} from "react";
type SkipRenderOnClientProps = PropsWithChildren<{
/**
* A function that returns a boolean indicating if the children should be
* rendered on the client.
* @returns `true` if the children should be rendered on the client, `false` otherwise.
*/
shouldRenderOnClient: () => boolean;
}>;
/**
* Empty subscribe function.
* @returns An empty function.
*/
const emptySubscribe = () => () => {};
const always = () => true;
const never = () => false;
/**
* When using React's server-side rendering, we often need to render components
* on the server even if they are conditional on the client e.g. hidden based on
* window width.
*
* In order for hydration to succeed, the first client render must
* match the DOM (which is generated from the HTML returned by the server),
* otherwise we will get hydration mismatch errors. This means the component
* must be rendered again during the first client render.
*
* However, hydration is expensive, so we really don't want to pay that penalty
* only for the element to be hidden or removed immediately afterwards.
*
* For example, imagine we have two components: one for mobile and one for
* desktop. Usually we would render both components on the server and on the
* client (to avoid hydration mismatch errors) and toggle their visibility using
* CSS. This means we would be hydrating both components even though only one of
* them is currently shown to the user.
*
* `SkipRenderOnClient` conditionally skips hydrating children by using `Activity` to
* defer their hydration to a low priority render while scheduling a high priority
* render to remove them. This ensures there are no hydration mismatch errors.
*
* Following on from the example above, this is how we would apply
* `SkipRenderOnClient`:.
*
* ```tsx
* <SkipRenderOnClient shouldRenderOnClient={() => window.innerWidth <= 500}>
* <MyMobileComponent className={styles.showOnMobile} />
* </SkipRenderOnClient>
* <SkipRenderOnClient shouldRenderOnClient={() => window.innerWidth > 500}>
* <MyDesktopComponent className={styles.showOnDesktop} />
* </SkipRenderOnClient>
* ```
*/
export const SkipRenderOnClient: FC<SkipRenderOnClientProps> = ({
children,
shouldRenderOnClient,
}) => {
/**
* Whether this component is rendering for the "server".
*
* During hydration, this hook will use the "server" value,
* after which it will schedule a high priority render to update to the "client" value.
* When mounting on the client, this hook will always use the "client" value.
* @see {@link https://tkdodo.eu/blog/avoiding-hydration-mismatches-with-use-sync-external-store#usesyncexternalstore Avoiding Hydration Mismatches with useSyncExternalStore}
*
* `useDeferredValue` ensures the update however doesn't block the main thread, providing better INP.
* @see {@link https://kurtextrem.de/posts/react-uses-hydration Concurrent Hydration with useSyncExternalStore}
*/
let isServer = useDeferredValue(useSyncExternalStore(emptySubscribe, never, always));
/**
* This component is separated from its "internal" counterpart so that
* the `isServer` value is computed at a higher level in the tree.
*
* This matters because `isServer` relies on `useSyncExternalStore`,
* which triggers an additional high-priority render during hydration.
*
* Without any safeguards, that second render would cause the entire subtree
* to re-render and hydrate prematurely.
*
* However, because `isServer` also uses `useDeferredValue`, the value
* returned during that second render is intentionally kept the same as the
* first render. The updated value (`false`) is deferred to a later,
* lower-priority render.
*
* This effectively makes the hydration pass "stable", allowing React to
* bail out of rendering unchanged subtrees (via `memo` or the React Compiler).
* As a result, nodes inside `Activity` boundaries can be removed before they
* are ever hydrated, avoiding unnecessary work and preventing UI blocking.
*
* However, if an "immediate"-priority render comes from a parent component
* (such as by a state update from a layout effect, or another `useSyncExternalStore`
* update) before `isServer` becomes `false`, we must override its value to ensure
* mismatches aren't hydrated.
*
* We do this by storing the initial props in a ref and checking if the
* current props are the same. If they are different, that means this update
* was initiated by parent at a higher priority, and we should treat it as the
* "client" render.
*/
const ref = useRef<{ children: ReactNode; shouldRenderOnClient: () => boolean } | null>({ children, shouldRenderOnClient });
const realIsServer =
isServer &&
children === ref.current?.children &&
shouldRenderOnClient === ref.current?.shouldRenderOnClient;
useEffect(() => {
// Reset the ref when the component has hydrated to avoid memory leaks
if (!isServer) ref.current = null;
}, [isServer]);
return (
<InternalSkipRenderOnClient
isServer={realIsServer}
shouldRenderOnClient={shouldRenderOnClient}
>
{children}
</InternalSkipRenderOnClient>
);
};
type InternalSkipRenderOnClientProps = SkipRenderOnClientProps & {
/** Wether this render should be treated like a server render. */
isServer: boolean;
};
const InternalSkipRenderOnClient: FC<InternalSkipRenderOnClientProps> = memo(
function InternalSkipRenderOnClient({ children, isServer, shouldRenderOnClient }) {
// For the "server", always render the children.
if (isServer) {
// Activity defers the hydration of the children to a low priority render.
return <Activity>{children}</Activity>;
}
// For the "client", only render the children if the `shouldRenderOnClient` function returns `true`.
if (shouldRenderOnClient()) {
// Since the children's hydration is deferred to a low priority render, if the function returns
// `false`, the children will be removed without ever being hydrated.
return <Activity>{children}</Activity>;
}
return null;
},
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment