React Suspense Integration
Concept: Use React Suspense boundaries to handle loading states declaratively.
What is Suspense?
React Suspense is a mechanism for handling asynchronous operations declaratively. When a component needs data that isn’t ready, it “suspends” by throwing a promise. React catches this and shows a fallback until the promise resolves.
Gluonic provides .suspense accessors that integrate seamlessly with React Suspense.
Basic Usage
import { Suspense } from 'react'
import { observer, useModel } from '@gluonic/client'
<Suspense fallback={<Spinner />}>
<IssueDetail issueId={issueId} />
</Suspense>
const IssueDetail = observer(({ issueId }) => {
const issue = useModel<Issue>('issue', issueId)
// .suspense throws promise if not loaded
const assignee = issue.assignee.suspense
const comments = issue.comments.suspense
// Code only runs when data is ready
return (
<div>
<h1>{issue.title}</h1>
<p>Assigned to: {assignee.name}</p> {/* No optional chaining! */}
{comments.map(c => <Comment key={c.id} comment={c} />)}
</div>
)
})Progressive vs Suspense Patterns
Progressive Pattern (.value)
Handle loading inline with optional chaining:
const IssueRow = observer(({ issue }) => {
// May be undefined - handle with optional chaining
const assignee = issue.assignee.value
return (
<div>
{issue.title}
<span>{assignee?.name ?? 'Unassigned'}</span>
</div>
)
})Pros: Progressive appearance, no blocking Cons: Must handle undefined everywhere
Suspense Pattern (.suspense)
Guarantee data is loaded before rendering:
<Suspense fallback={<Spinner />}>
<IssueRow issue={issue} />
</Suspense>
const IssueRow = observer(({ issue }) => {
// Guaranteed loaded - no optionals needed
const assignee = issue.assignee.suspense
return (
<div>
{issue.title}
<span>{assignee.name}</span> {/* No ?. */}
</div>
)
})Pros: Guaranteed data, cleaner code Cons: Shows spinner, blocks rendering
How .suspense Works
LazyReference.suspense
class LazyReference<T> {
get suspense(): T {
if (this._hydrated) {
return this._value as T // Loaded - return it
}
if (!this._promise) {
// Start loading
this._promise = this.loader().then(val => {
runInAction(() => {
this._value = val
this._hydrated = true
})
return val
})
}
throw this._promise // Not loaded - throw for Suspense
}
}When you access .suspense:
- If loaded: Returns value immediately
- If loading: Throws the in-flight promise
- If not started: Creates promise, throws it
React Suspense catches the thrown promise and shows fallback.
LazyCollection.suspense
class LazyCollection<T> {
get suspense(): T[] {
if (this.hydrated) {
return this.items // Loaded - return array
}
if (!this.loadingPromise) {
// Start loading
this.loadingPromise = this.loader().then(items => {
runInAction(() => {
this.items = items
this.hydrated = true
})
return items
})
}
throw this.loadingPromise // Not loaded - throw for Suspense
}
}Same pattern as LazyReference.
Multiple Suspense Boundaries
You can nest Suspense boundaries for granular control:
<Suspense fallback={<PageSpinner />}>
<IssueDetail issueId={issueId} />
</Suspense>
const IssueDetail = observer(({ issueId }) => {
const issue = useModel<Issue>('issue', issueId)
const assignee = issue.assignee.suspense // May suspend here
return (
<div>
<h1>{issue.title}</h1>
<p>Assigned to: {assignee.name}</p>
{/* Nested Suspense for comments */}
<Suspense fallback={<div>Loading comments...</div>}>
<CommentList issue={issue} />
</Suspense>
</div>
)
})
const CommentList = observer(({ issue }) => {
const comments = issue.comments.suspense // May suspend here
return comments.map(c => <Comment key={c.id} comment={c} />)
})Result:
- Issue details show immediately
- Comments show spinner while loading
- Progressive disclosure without manual loading states
TypeScript Benefits
.suspense provides better types than .value:
// With .value - must handle undefined
const assignee: User | undefined = issue.assignee.value
const name: string | undefined = assignee?.name // Optional chaining
// With .suspense - guaranteed non-undefined
const assignee: User = issue.assignee.suspense
const name: string = assignee.name // Direct access!This is especially valuable for complex components:
const UserProfile = observer(({ user }) => {
// Would be tedious with optional chaining everywhere
const profile = user.profile.suspense
const settings = profile.settings.suspense
const theme = settings.theme.suspense
return <div style={{ background: theme.backgroundColor }}>...</div>
})Hydrated Type
After explicit hydration, TypeScript knows data is guaranteed:
const issue = useModel<Issue>('issue', issueId)
// Before hydration
issue.assignee.value // User | undefined
// After hydration
const hydrated = await issue.hydrate()
hydrated.assignee.value // User (no undefined!)
// TypeScript type:
type Hydrated<Issue> = Issue & {
assignee: LazyReference<User> & { value: User } // Non-optional!
}When to Use Suspense
Use Suspense When:
- âś… Component requires guaranteed data to render
- ✅ Complex rendering logic can’t handle undefined
- âś… You want cleaner code (no optional chaining)
- âś… Loading spinner is acceptable UX
// Complex component - needs all data
<Suspense fallback={<Spinner />}>
<DataVisualization issue={issue} />
</Suspense>
const DataVisualization = observer(({ issue }) => {
const assignee = issue.assignee.suspense
const comments = issue.comments.suspense
const project = issue.project.suspense
// Complex calculation requiring all data
const stats = calculateStats(issue, assignee, comments, project)
return <Chart data={stats} />
})Use Progressive When:
- âś… Data is optional or enhancing
- âś… Can show something useful while loading
- âś… Want better perceived performance
- âś… Prefer no blocking spinners
// Simple component - can handle partial data
const IssueRow = observer(({ issue }) => {
return (
<div>
{issue.title}
<span>{issue.assignee.value?.name ?? 'Unassigned'}</span>
</div>
)
})Mixing Patterns
You can mix both patterns in the same component:
const IssueDetail = observer(({ issueId }) => {
const issue = useModel<Issue>('issue', issueId)
// Critical data - use Suspense
const assignee = issue.assignee.suspense
// Optional data - use progressive
const project = issue.project.value
return (
<div>
<h1>{issue.title}</h1>
<p>Assigned to: {assignee.name}</p> {/* Guaranteed */}
{project && <Badge>{project.name}</Badge>} {/* Optional */}
</div>
)
})Best Practice: Use progressive pattern by default. Only use Suspense when you genuinely need guaranteed data or when it significantly simplifies your component code.
Suspense Boundaries Best Practices
1. Place Boundaries Strategically
// ❌ Too granular - many spinners
<Suspense fallback={<Spinner />}>
<Field1 />
</Suspense>
<Suspense fallback={<Spinner />}>
<Field2 />
</Suspense>
// âś… One boundary for related data
<Suspense fallback={<Spinner />}>
<IssueForm /> {/* Loads all data together */}
</Suspense>2. Provide Meaningful Fallbacks
// ❌ Generic spinner
<Suspense fallback={<Spinner />}>
// âś… Contextual loading message
<Suspense fallback={<div>Loading issue details...</div>}>
// âś… Skeleton matching content
<Suspense fallback={<IssueSkeleton />}>3. Combine with Error Boundaries
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<IssueDetail issueId={issueId} />
</Suspense>
</ErrorBoundary>Pre-hydration with Suspense
Pre-hydrate data before accessing .suspense to avoid suspending:
const IssueDetail = observer(({ issueId }) => {
const issue = useModel<Issue>('issue', issueId)
useEffect(() => {
// Pre-hydrate in background
void issue.assignee.hydrate()
void issue.comments.hydrate()
}, [issue])
// Later, when you access .suspense, might already be loaded
const assignee = issue.assignee.suspense // Might not suspend!
return <div>...</div>
})This is useful for preloading on hover or navigation intent.
Comparison
| Approach | Code Style | Loading Handling | Type Safety |
|---|---|---|---|
| Progressive (.value) | Optional chaining | Inline | User | undefined |
| Suspense (.suspense) | Direct access | Declarative | User (guaranteed) |
| Explicit hydrate() | await/then | Imperative | User (after await) |
API Reference: LazyReference.suspense · LazyCollection.suspense
Next Steps
- Learn about Lazy Collections - Using
.suspenseon collections - Learn about Lazy References - Using
.suspenseon references - Learn about Hydration - Explicit hydration control
- React Suspense docs - Official React documentation