| name | loading-ux |
| description | Loading UX without flash-of-loading in @suspensive/react: Suspense fallback, Delay (ms, fallback, isDelayed render prop) to hold back spinners and fade skeletons in, DefaultPropsProvider + new DefaultProps for app-wide default fallbacks. Load when writing Suspense fallbacks, skeletons, spinners, or global loading defaults.
|
| 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/Delay.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react/DefaultPropsProvider.mdx","toss/suspensive:packages/react/src/Delay.tsx"] |
Loading UX with Suspense, Delay, and DefaultPropsProvider
This skill builds on react. Read ../SKILL.md first for the client-only constraint and the .with() HOC pattern.
Setup
'use client'
import { Delay, Suspense } from '@suspensive/react'
import { Content } from './Content'
import { Spinner } from './Spinner'
export const Example = () => (
<Suspense
fallback={
<Delay ms={200}>
<Spinner />
</Delay>
}
>
<Content />
</Suspense>
)
Delay postpones exposing its children by ms — if Content resolves within 200ms, the user never sees the spinner. Recommended ms: 100–300 for most UI, 500+ for non-critical sections.
DelayProps is a union: either { ms?, fallback?: ReactNode, children?: ReactNode } or { ms?, children?: ({ isDelayed }) => ReactNode } (no fallback allowed with a render-prop child).
Core Patterns
Fade the skeleton in with the isDelayed render prop
'use client'
import { Delay, Suspense } from '@suspensive/react'
import { Content } from './Content'
import { Skeleton } from './Skeleton'
export const Example = () => (
<Suspense
fallback={
<Delay ms={200}>
{({ isDelayed }) => <Skeleton style={{ opacity: isDelayed ? 1 : 0, transition: 'opacity 200ms' }} />}
</Delay>
}
>
<Content />
</Suspense>
)
When children is a render prop, Delay always renders it and flips isDelayed to true after ms, enabling opacity transitions instead of an abrupt pop-in (available since v3.12).
Show an interim fallback before the delayed children
'use client'
import { Delay } from '@suspensive/react'
export const Example = () => (
<Delay ms={200} fallback={<>Almost there...</>}>
200ms delayed content
</Delay>
)
One default fallback for every Suspense and Delay in the app
'use client'
import { DefaultProps, DefaultPropsProvider, Suspense } from '@suspensive/react'
import { Page } from './Page'
import { Spinner } from './Spinner'
const defaultProps = new DefaultProps({
Delay: { ms: 1200 },
Suspense: { fallback: <Spinner />, clientOnly: false },
})
export const App = () => (
<DefaultPropsProvider defaultProps={defaultProps}>
<Suspense>
<Page />
</Suspense>
</DefaultPropsProvider>
)
<Suspense> and <Delay> without explicit props read defaults from the nearest provider; per-component props always win.
Common Mistakes
MEDIUM Hand-rolling spinner delay with useState/useEffect/setTimeout
Wrong:
import { useEffect, useState } from 'react'
const DelayedSpinner = () => {
const [show, setShow] = useState(false)
useEffect(() => {
const t = setTimeout(() => setShow(true), 200)
return () => clearTimeout(t)
}, [])
return show ? <Spinner /> : null
}
Correct:
import { Delay, Suspense } from '@suspensive/react'
const Example = ({ children }) => (
<Suspense
fallback={
<Delay ms={200}>
<Spinner />
</Delay>
}
>
{children}
</Suspense>
)
Delay does the timeout declaratively, composes inside any Suspense fallback, and picks up default ms from DefaultPropsProvider.
Source: docs/suspensive.org/src/content/en/docs/react/Delay.mdx
MEDIUM Passing a plain object to DefaultPropsProvider
Wrong:
import { DefaultPropsProvider } from '@suspensive/react'
const App = ({ children }) => (
<DefaultPropsProvider defaultProps={{ Suspense: { fallback: <Spinner /> } }}>{children}</DefaultPropsProvider>
)
Correct:
import { DefaultProps, DefaultPropsProvider } from '@suspensive/react'
const defaultProps = new DefaultProps({ Suspense: { fallback: <Spinner /> } })
const App = ({ children }) => <DefaultPropsProvider defaultProps={defaultProps}>{children}</DefaultPropsProvider>
defaultProps must be an instance of the DefaultProps class; a plain object literal is not accepted.
Source: docs/suspensive.org/src/content/en/docs/react/DefaultPropsProvider.mdx
MEDIUM Abrupt skeleton-to-content swap instead of isDelayed fade
Wrong:
import { Delay } from '@suspensive/react'
const Fallback = () => (
<Delay ms={200}>
<FadeIn>
<Spinner />
</FadeIn>
</Delay>
)
Correct:
import { Delay } from '@suspensive/react'
const Fallback = () => (
<Delay ms={200}>
{({ isDelayed }) => <Spinner style={{ opacity: isDelayed ? 1 : 0, transition: 'opacity 200ms' }} />}
</Delay>
)
The render-prop child receives isDelayed (v3.12+) exactly for this; agents unaware of it wire CSS animation components by hand.
Source: docs/suspensive.org/src/content/en/docs/react/Delay.mdx (#1312)
MEDIUM Reading Delay ms as extending fallback display time
Wrong:
import { Delay, Suspense } from '@suspensive/react'
const Example = ({ children }) => (
<Suspense
fallback={
<Delay ms={3000}>
<Spinner />
</Delay>
}
>
{children}
</Suspense>
)
Correct:
import { Delay, Suspense } from '@suspensive/react'
const Example = ({ children }) => (
<Suspense
fallback={
<Delay ms={200}>
<Spinner />
</Delay>
}
>
{children}
</Suspense>
)
Delay postpones exposing its children/fallback; it never prolongs how long a fallback stays visible — a 1s fetch with ms={3000} shows no spinner at all, not 4s of spinner.
Source: https://github.com/toss/suspensive/issues/182
See also: the declarative-queries skill in @suspensive/react-query-4/@suspensive/react-query-5 — Delay composes inside Suspense fallbacks that wrap SuspenseQuery.