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 -
.valuereturns 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
Progressive Pattern (Recommended)
@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
undefinedif 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.nameBehavior:
- 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 userExplicit 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 timeProgressive 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 datasetsGluonic 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 synchronousType 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 referencesCircular 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
| Feature | Direct Reference | LazyReference |
|---|---|---|
| API | issue.author | issue.author.value |
| Loading | Must load all | On-demand |
| Memory | All in memory | Only what’s needed |
| Scales | ❌ Limited | ✅ Any size |
| Synchronous | ✅ Yes | ✅ Yes (feels sync) |
API Reference: LazyReference · @ManyToOne · @OneToOne
Next Steps
- Learn about Lazy Collections - Collection relationships
- Learn about Hydration - The loading mechanism
- Learn about Object Graph - How references form a graph
- Learn about React Suspense - Using
.suspenseaccessor