@suspensive/react
Version:
Suspensive interfaces for react
273 lines (199 loc) • 8.32 kB
Markdown
---
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
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'use client'
import { Suspense } from '@suspensive/react'
import { Widget } from './Widget'
// step 1: client-only while the subtree is not SSR-safe
// <Suspense clientOnly fallback={<Skeleton />}><Widget /></Suspense>
// step 2: remove one prop when it becomes SSR-safe
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:
```tsx
import { useEffect, useState } from 'react'
const Example = ({ children }) => {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return <>{children}</>
}
```
Correct:
```tsx
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:
```tsx
import { Suspense } from '@suspensive/react'
const Example = ({ children }) => <Suspense.CSROnly fallback={<Spinner />}>{children}</Suspense.CSROnly>
```
Correct:
```tsx
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:
```tsx
// app/page.tsx — Server Component
import { ErrorBoundary } from '@suspensive/react'
export default function Page() {
return (
<ErrorBoundary fallback={({ error }) => <p>{error.message}</p>}>
<Content />
</ErrorBoundary>
)
}
```
Correct:
```tsx
// app/page.tsx — Server Component
import { ErrorBoundary } from '@suspensive/react'
import { ErrorFallbackClient } from './ErrorFallbackClient'
export default function Page() {
return (
<ErrorBoundary fallback={<ErrorFallbackClient />}>
<Content />
</ErrorBoundary>
)
}
```
```tsx
// ErrorFallbackClient.tsx
'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:
```tsx
import dynamic from 'next/dynamic'
const Widget = dynamic(() => import('./Widget'), { ssr: false })
```
Correct:
```tsx
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:
```tsx
import { ClientOnly } from '@suspensive/react'
// expecting this fallback to show while the widget "loads" on every mount
const Example = () => (
<ClientOnly fallback={<Spinner />}>
<Widget />
</ClientOnly>
)
```
Correct:
```tsx
import { ClientOnly } from '@suspensive/react'
// fallback is what the SERVER renders into the HTML — a static placeholder
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).