Display

Client Only

A wrapper that renders its children only after mount — the server and the first hydration render both emit the fallback, so browser-only values can never produce a hydration mismatch.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * "Has this subtree mounted on the client yet?" modelled as an external store.
 * This shape — not an effect that flips a boolean — is what makes the component
 * correct AND lint-clean (`react-hooks/set-state-in-effect` forbids a synchronous
 * setState in an effect body).
 *
 * - `getServerSnapshot` returns false, so the server render and the *first*
 *   hydration render both take the fallback branch. They agree byte for byte,
 *   which is the whole point: a component whose job is to prevent hydration

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/client-only.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ClientOnly" component — a wrapper that
renders its children only after the component has mounted in the browser, so a
subtree whose value cannot be computed on the server never participates in
hydration.

Contract
- Export a forwardRef component extending
  Omit<React.HTMLAttributes<HTMLElement>, "children">; the wrapper element is
  always rendered (both phases) so it can reserve space and accept ref,
  className, data-* and native handlers.
- Props: children (ReactNode, required — the browser-only subtree),
  fallback (ReactNode, default null — what the server and the first hydration
  render output instead), as (React.ElementType, default "div" — use "span"
  inside a sentence, "td"/"li" inside a table or list so the placeholder phase
  stays valid HTML).
- The root carries data-client-only="pending" | "mounted" for styling and tests.
- No delay/idle/viewport props. Deferring *when* to mount is a different
  component's job; this one only draws the SSR/CSR line.

Behavior
- "Has this mounted?" is read through useSyncExternalStore, NOT an effect that
  calls setState:
    subscribe          -> () => () => {}   (no-op; the value transitions once)
    getSnapshot        -> true
    getServerSnapshot  -> false
  getServerSnapshot returning false is the load-bearing part: the server render
  and the FIRST client (hydration) render produce byte-identical output, so the
  component can never be the cause of the mismatch it exists to prevent. After
  hydration React re-reads getSnapshot, sees true, and re-renders the subtree
  into the real children on its own.
- Both getters must return cached primitives (constants), or React's Object.is
  comparison reports a change every render ("The result of getSnapshot should be
  cached to avoid an infinite loop").
- An effect-based `const [mounted, setMounted] = useState(false); useEffect(() =>
  setMounted(true), [])` is the folklore version of this and is also what
  eslint's react-hooks/set-state-in-effect rule rejects — a synchronous setState
  in an effect body. Use the store.
- Asymmetry to preserve: in a purely client-rendered tree (createRoot, or
  mounting into an already-hydrated page) React never calls getServerSnapshot,
  so children appear immediately with no placeholder flash. The fallback is only
  paid where server HTML actually exists.
- Children are not rendered at all before mount — not rendered-and-hidden. That
  is what makes it safe for children to touch window / document / localStorage /
  Date.now() / Math.random() during render or in a useState initializer.
- No animation on the swap: a fade would delay content and would then need a
  prefers-reduced-motion escape hatch. The swap is instant.

Rendering & styling
- Semantic tokens only; the wrapper sets no color, spacing or radius of its own,
  it only merges the consumer's className through cn(). Whatever the fallback
  and children look like is the consumer's.
- Layout stability is the one real design duty. The box must be the same size in
  both phases, and there are exactly two ways to get that: size the wrapper
  (className="h-40") or make the fallback the same size as the children (a
  skeleton, a dash placeholder, an approximation). Do one. With neither, the box
  collapses to zero height until mount and the page jumps.
- Accessibility/SEO cost, to be stated and not softened: the children are absent
  from server HTML. They are invisible to crawlers that do not execute JS, to
  in-page find (Ctrl+F) before hydration, to the first screen-reader pass, and to
  printing / "view source" of the raw document. Wrap the smallest piece that
  genuinely needs the browser — never a page, a nav or an article body.

Customization levers
- Fallback strategy: skeleton bars (unknown content), a dash/placeholder string
  of the same width (a value, e.g. "--:--:--"), or a server-computable
  approximation (UTC time, a default preference) that gets corrected on mount —
  the third one is the best for perceived quality when it is possible.
- Sizing: put a fixed height/aspect on the wrapper when the content's size is
  known, otherwise shape the fallback to match. Both are className changes.
- Element: `as` covers inline ("span"), table ("td"), list ("li") and section
  ("section") contexts.
- Scope: prefer several small ClientOnly wrappers around the specific offending
  values over one big wrapper around a section — smaller wrappers keep more of
  the page in the server HTML.
- Styling by phase: data-client-only="pending" can drive a
  [data-client-only=pending]:opacity-60 style rule if you want the placeholder
  visually de-emphasised without a separate fallback tree.

Concepts

  • getServerSnapshot is the whole trick — the server render and the first hydration render must return the same branch, so the mount flag is read from an external store whose server snapshot is hard-coded false. React re-reads the client snapshot only after hydration commits, and re-renders the subtree itself.
  • Not "rendered then hidden" — children are never invoked before mount, so it is safe for them to read window, localStorage or a clock during render or in a useState initializer. Hiding with CSS would still execute them on the server.
  • Equal-size fallback, or you buy the jump — the wrapper exists in both phases; give the box its size either on the wrapper (className="h-40") or via a fallback shaped like the real content. Passing neither collapses the box to zero and shifts everything below it.
  • The cost is content invisibility, not just a spinner — anything wrapped is missing from the server HTML: crawlers without JS, in-page find before hydration, the first screen-reader pass, and printing the raw document all see the fallback. It is a boundary to draw as tightly as possible, not a default page shell.
  • Client-rendered trees skip the placeholder entirely — React only calls getServerSnapshot while hydrating, so in a CSR-only app or when mounting into an already-hydrated page the children appear on the first frame with no flash.
  • A different question from "when should this mount" — deciding to defer until a subtree scrolls into view or the browser goes idle is a separate concern (lazy-render); this component answers only "may the server render this at all".

On This Page