Skip to Content
LearnCore ConceptsReact Suspense

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:

  1. If loaded: Returns value immediately
  2. If loading: Throws the in-flight promise
  3. 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

ApproachCode StyleLoading HandlingType Safety
Progressive (.value)Optional chainingInlineUser | undefined
Suspense (.suspense)Direct accessDeclarativeUser (guaranteed)
Explicit hydrate()await/thenImperativeUser (after await)

Next Steps

Last updated on