Skip to Content
LearnCore ConceptsLazy References

Lazy References

Concept: Object references that load related data on-demand while maintaining synchronous access.

What is a LazyReference?

A LazyReference is a pointer to another model object that:

  • Loads on-demand - Related object loads when first accessed
  • Feels synchronous - .value returns immediately (undefined initially)
  • Auto-hydrates - Accessing triggers background loading
  • Updates automatically - MobX re-renders when data loads
  • Smart invalidation - Detects FK changes and reloads

Basic Usage

@ClientModel('issue') class Issue extends Model { @Property() assigneeId?: string @ManyToOne('assignedIssues', 'assigneeId') assignee: LazyReference<User> } const IssueRow = observer(({ issue }) => { // First render: assignee.value is undefined // Kicks hydration automatically // Second render: assignee.value is User object return ( <div> <h3>{issue.title}</h3> <p>Assigned to: {issue.assignee.value?.name ?? 'Unassigned'}</p> </div> ) })

Suspense Pattern

<Suspense fallback={<Spinner />}> <IssueRow issue={issue} /> </Suspense> const IssueRow = observer(({ issue }) => { const assignee = issue.assignee.suspense // Throws promise if not loaded // Only renders when assignee is loaded return ( <div> <h3>{issue.title}</h3> <p>Assigned to: {assignee.name}</p> {/* No optional chaining! */} </div> ) })

Two Accessors

.value (Progressive)

Synchronous accessor that may return undefined:

const assignee = issue.assignee.value // User | undefined // Use with optional chaining const name = issue.assignee.value?.name // Or handle explicitly if (issue.assignee.value) { console.log(issue.assignee.value.name) }

Behavior:

  • Returns undefined if not loaded yet
  • Automatically kicks off hydration in background
  • MobX re-renders component when data loads
  • Next access returns the loaded User object

.suspense (Suspense)

Throws a promise if not loaded (for React Suspense):

const assignee = issue.assignee.suspense // User (guaranteed) // No optional chaining needed const name = assignee.name

Behavior:

  • Throws promise if not loaded yet
  • React Suspense catches it and shows fallback
  • Component only renders when data is ready
  • Returns guaranteed non-undefined User object

Auto-Kick Behavior

When you access .value for the first time:

// Step 1: Access const author = issue.author.value // Step 2: Behind the scenes if (!this._hydrated && !this._promise) { void this.hydrate() // Kick off background load } // Step 3: Return immediately return undefined // Synchronous! // Step 4: Background loading // - Get FK: issue.authorId // - Try local storage first (~10ms) // - Fallback to network if needed (~100-500ms) // Step 5: When data arrives runInAction(() => { this._value = loadedUser this._hydrated = true }) // Step 6: MobX triggers re-render // Component re-runs with loaded user

Explicit Hydration

Sometimes you want to wait for data to load:

// Await hydration const author = await issue.author.hydrate() console.log(author.name) // Guaranteed loaded // Check if loaded if (issue.author._hydrated) { console.log(issue.author.value.name) }

Smart FK Change Detection

LazyReferences automatically detect when the foreign key changes:

const issue = useModel<Issue>('issue', issueId) // First assignee console.log(issue.assignee.value?.name) // 'Alice' // Change assignee await store.save('issue', issueId, { assigneeId: 'bob' }) // LazyReference detects FK changed // Automatically invalidates cached value console.log(issue.assignee.value) // undefined (loading Bob) // Background: Loads Bob // MobX: Re-renders console.log(issue.assignee.value?.name) // 'Bob'

How it works:

get value(): T | undefined { // Check if FK changed const currentFk = this.getForeignKey() // issue.assigneeId const cachedFk = this._value?.id if (currentFk !== cachedFk) { this._reset() // Invalidate cache void this.hydrate() // Reload with new FK } return this._value }

This ensures references always point to the correct related object.

Hydration Sources

LazyReferences load data from two sources (in order):

1. ObjectPool (Instant)

// Best case: Data already in pool const row = store.pool.get(type, foreignKeyValue) if (row) return instantiateModel(row) // Instant!

2. Storage → Network (Fallback)

// Not in pool: Load via Model Loader 1. Try local storage via Drizzle adapter (~10ms) 2. Try network if not found (~100-500ms) 3. Cache in storage for next time

Progressive Enhancement Example

const IssueDetail = observer(({ issue }) => { const author = issue.author.value const assignee = issue.assignee.value return ( <div> <h1>{issue.title}</h1> <div className='metadata'> <span> Created by: {author?.name ?? ( <span className='opacity-50'>Loading...</span> )} </span> <span> Assigned to: {assignee?.name ?? 'Unassigned'} </span> </div> <p>{issue.description}</p> </div> ) })

UX:

  • Issue renders immediately
  • Author/assignee show “Loading…” briefly
  • Data appears when ready
  • No blocking spinner

When to Use Each Pattern

Use Progressive (.value)

// ✅ Optional data const author = issue.author.value?.name ?? 'Unknown' // ✅ Can show fallback const assignee = issue.assignee.value || { name: 'Unassigned' } // ✅ Progressive appearance {issue.author.value ? <Avatar user={issue.author.value} /> : <Skeleton />}

Use Suspense (.suspense)

// ✅ Required data for rendering const author = issue.author.suspense return <AuthorProfile author={author} /> // Needs full author // ✅ Complex component that can't handle undefined const assignee = issue.assignee.suspense return <UserCard user={assignee} /> // Component expects User, not User | undefined // ✅ Cleaner code (no optionals everywhere) return <div>{issue.author.suspense.name}</div>

Best Practice: Use progressive pattern (.value) for better UX. Only use Suspense when you genuinely need guaranteed data for rendering logic.

Comparison to Direct References

Old Way (Direct Reference)

// Before lazy loading @ClientModel('issue') class Issue extends Model { author: User // Direct JavaScript reference } // Problem: All users must be loaded into memory // Problem: Can't load on-demand // Problem: Doesn't scale to large datasets

Gluonic Way (Lazy Reference)

// With lazy loading @ClientModel('issue') class Issue extends Model { @ManyToOne('issues', 'authorId') author: LazyReference<User> // Lazy reference } // Benefits: Loads on-demand // Benefits: Scales to any size // Benefits: Feels synchronous

Type Safety

LazyReference is fully type-safe:

@ManyToOne('posts', 'authorId') author: LazyReference<User> // Progressive access const name: string | undefined = issue.author.value?.name // ✓ Type-safe // Suspense access const author: User = issue.author.suspense // ✓ Guaranteed User const name: string = author.name // ✓ No undefined // After explicit hydration const hydrated = await issue.hydrate() const name: string = hydrated.author.value.name // ✓ Guaranteed (Hydrated<T> type)

Multiple References

Issues can have multiple lazy references:

@ClientModel('issue') class Issue extends Model { @ManyToOne('assignedIssues', 'assigneeId') assignee: LazyReference<User> @ManyToOne('createdIssues', 'creatorId') creator: LazyReference<User> @ManyToOne('issues', 'projectId') project: LazyReference<Project> @ManyToOne('issues', 'milestoneId') milestone: LazyReference<Milestone> } // Each hydrates independently const assignee = issue.assignee.value // Loads just assignee const project = issue.project.value // Loads just project // Or hydrate all at once await issue.hydrate() // Loads all lazy references

Circular References

The Object Graph can have cycles:

// Comment points to Issue @ClientModel('comment') class Comment extends Model { @ManyToOne('comments', 'issueId') issue: LazyReference<Issue> } // Issue points back to Comments @ClientModel('issue') class Issue extends Model { @OneToMany('issue') comments: LazyCollection<Comment> } // Usage - navigate both directions const issue = comment.issue.value const comments = issue?.comments const firstComment = comments?.at(0) const backToIssue = firstComment?.issue.value // All the same issue instance! (identity mapping)

Caution: Be careful with recursive hydration on circular graphs. Use hydrate(depth) to limit how deep you go.

Comparison

FeatureDirect ReferenceLazyReference
APIissue.authorissue.author.value
LoadingMust load allOn-demand
MemoryAll in memoryOnly what’s needed
Scales❌ Limited✅ Any size
Synchronous✅ Yes✅ Yes (feels sync)

API Reference: LazyReference · @ManyToOne · @OneToOne

Next Steps

Last updated on