| name | ssr-client-only |
| description | SSR control and client-only rendering in @suspensive/react: Suspense clientOnly prop to skip SSR per boundary, ClientOnly component, useIsClient hook — all useSyncExternalStore-based and hydration-mismatch-safe. Load when skipping SSR for a subtree, accessing browser APIs (window, localStorage), fixing hydration mismatches, or replacing next/dynamic ssr:false or useEffect mounted flags.
|
| metadata | {"type":"sub-skill","library":"@suspensive/react","library_version":"3.21.2","framework":"react"} |
| requires | ["react"] |
| sources | ["toss/suspensive:docs/suspensive.org/src/content/en/docs/react/Suspense.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react/ClientOnly.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react/useIsClient.mdx"] |
SSR control with clientOnly, ClientOnly, and useIsClient
This skill builds on react. Read ../SKILL.md first for the client-only constraint and the .with() HOC pattern.
Setup
'use client'
import { Suspense } from '@suspensive/react'
import { BrowserWidget } from './BrowserWidget'
import { Skeleton } from './Skeleton'
export const Example = () => (
<Suspense clientOnly fallback={<Skeleton />}>
<BrowserWidget />
</Suspense>
)
With clientOnly, <Suspense/> returns fallback on the server and children on the client. It does not rely on useEffect or mount timing — it distinguishes server and client via getServerSnapshot/getSnapshot from useSyncExternalStore, so children are never server-side rendered and there is no hydration mismatch.
Core Patterns
Render a browser-API-dependent subtree only on the client
'use client'
import { ClientOnly } from '@suspensive/react'
export const Example = () => (
<ClientOnly fallback={<div>Server fallback</div>}>
<div>Window width: {window.innerWidth}px</div>
</ClientOnly>
)
ClientOnly renders children only in the client environment and fallback on the server; ClientOnly.with({ fallback: <div>server</div> }, Component) is the HOC form.
Branch on environment with useIsClient
'use client'
import { useIsClient } from '@suspensive/react'
export const Example = () => {
const isClient = useIsClient()
const handleClick = () => {
if (isClient) {
localStorage.setItem('clicked', 'true')
}
}
return <button onClick={handleClick}>Click me</button>
}
useIsClient returns false during SSR and true on the client via useSyncExternalStore (getSnapshot: () => true, getServerSnapshot: () => false) — no extra render, no tearing.
Re-enable SSR gradually
'use client'
import { Suspense } from '@suspensive/react'
import { Widget } from './Widget'
export const Example = () => (
<Suspense fallback={<Skeleton />}>
<Widget />
</Suspense>
)
Reverting a client-only boundary to full SSR is deleting the clientOnly prop — no file moves or component indirection.
Common Mistakes
HIGH useEffect mounted-flag instead of useIsClient/ClientOnly
Wrong:
import { useEffect, useState } from 'react'
const Example = ({ children }) => {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return <>{children}</>
}
Correct:
import { useIsClient } from '@suspensive/react'
const Example = ({ children }) => {
const isClient = useIsClient()
if (!isClient) return null
return <>{children}</>
}
The mounted-flag idiom causes an extra post-mount render and tearing risk; useIsClient (and <ClientOnly fallback={null}>{children}</ClientOnly>) uses useSyncExternalStore with getServerSnapshot so server and client snapshots stay consistent.
Source: docs/suspensive.org/src/content/en/docs/react/useIsClient.mdx, docs/suspensive.org/src/content/en/docs/react/ClientOnly.mdx
HIGH Using removed Suspense.CSROnly
Wrong:
import { Suspense } from '@suspensive/react'
const Example = ({ children }) => <Suspense.CSROnly fallback={<Spinner />}>{children}</Suspense.CSROnly>
Correct:
import { Suspense } from '@suspensive/react'
const Example = ({ children }) => (
<Suspense clientOnly fallback={<Spinner />}>
{children}
</Suspense>
)
Suspense.CSROnly was renamed to the clientOnly prop and no longer exists. Fixed/removed in v2 but agents trained on older code still generate this pattern.
Source: docs/suspensive.org/src/content/en/docs/react/migration/migrate-to-v2.mdx
HIGH Passing callback fallback from a Server Component
Wrong:
import { ErrorBoundary } from '@suspensive/react'
export default function Page() {
return (
<ErrorBoundary fallback={({ error }) => <p>{error.message}</p>}>
<Content />
</ErrorBoundary>
)
}
Correct:
import { ErrorBoundary } from '@suspensive/react'
import { ErrorFallbackClient } from './ErrorFallbackClient'
export default function Page() {
return (
<ErrorBoundary fallback={<ErrorFallbackClient />}>
<Content />
</ErrorBoundary>
)
}
'use client'
import { useErrorBoundaryFallbackProps } from '@suspensive/react'
export const ErrorFallbackClient = () => {
const { error, reset } = useErrorBoundaryFallbackProps()
return <button onClick={reset}>{error.message}</button>
}
In RSC, functions cannot cross the server-to-client serialization boundary; pass JSX as fallback and read error/reset via useErrorBoundaryFallbackProps in a client component.
Source: docs/suspensive.org/src/content/en/docs/react/ErrorBoundary.mdx (useErrorBoundaryFallbackProps section)
MEDIUM next/dynamic ssr:false for a single subtree
Wrong:
import dynamic from 'next/dynamic'
const Widget = dynamic(() => import('./Widget'), { ssr: false })
Correct:
import { Suspense } from '@suspensive/react'
import { Widget } from './Widget'
const Example = () => (
<Suspense clientOnly fallback={<Skeleton />}>
<Widget />
</Suspense>
)
dynamic(..., { ssr: false }) forces a separate file and component indirection; Suspense clientOnly achieves the same per-boundary SSR skip inline and can be reverted by removing one prop.
Source: docs/suspensive.org/src/content/en/docs/react/Suspense.mdx (clientOnly section)
MEDIUM Misreading ClientOnly fallback as an error/loading state
Wrong:
import { ClientOnly } from '@suspensive/react'
const Example = () => (
<ClientOnly fallback={<Spinner />}>
<Widget />
</ClientOnly>
)
Correct:
import { ClientOnly } from '@suspensive/react'
const Example = () => (
<ClientOnly fallback={<WidgetPlaceholder />}>
<Widget />
</ClientOnly>
)
The ClientOnly fallback (and Suspense clientOnly fallback) is the server-rendered HTML shown only before hydration; it never reappears on later client-side mounts, so it is not a loading or error state.
Source: https://github.com/toss/suspensive/issues/1158, https://github.com/toss/suspensive/issues/873
HIGH Tension: clientOnly simplicity vs SSR value
Skipping SSR with clientOnly is the easiest way to silence a hydration error, but it throws away streaming SSR and server prefetch: a boundary under clientOnly renders nothing on the server, so QueriesHydration/server prefetch under it is wasted. When the hydration issue comes from server-fetched query data, the correct fix is proper server prefetch + hydration — see the ssr-hydration skill in @suspensive/react-query-4/@suspensive/react-query-5. Conversely, do not build a QueriesHydration pipeline for a browser-API widget that should simply be client-only.
See also: the ssr-hydration skill in @suspensive/react-query-4/@suspensive/react-query-5 — clientOnly on a boundary removes the need for QueriesHydration under it (no server prefetch happens).