Lazy Collections
Concept: Collections that load data on-demand while maintaining a synchronous array-like API.
What is a LazyCollection?
A LazyCollection is Gluonic’s implementation of a collection that:
- Looks like an array - Has map, filter, find, length, etc.
- Loads on-demand - Data loads when first accessed
- Feels synchronous - Returns immediately (empty initially)
- Updates automatically - MobX re-renders when data loads
Basic Usage
Progressive Pattern (Recommended)
@ClientModel('team')
class Team extends Model {
@OneToMany('team')
issues: LazyCollection<Issue>
}
const TeamView = observer(({ team }) => {
// First render: issues.map() returns []
// Kicks hydration automatically
// Second render: issues.map() returns loaded data
return (
<div>
{team.issues.map(issue => (
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)
})Suspense Pattern
<Suspense fallback={<Spinner />}>
<TeamView team={team} />
</Suspense>
const TeamView = observer(({ team }) => {
const issues = team.issues.suspense // Throws promise if not loaded
// Only renders when data is ready
return issues.map(issue => (
<IssueRow key={issue.id} issue={issue} />
))
})Array-Like API
LazyCollection mimics the JavaScript array API:
const team = useModel<Team>('team', teamId)
// All these work synchronously:
team.issues.map(i => i.title) // [] initially, then populated
team.issues.filter(i => i.done) // []
team.issues.find(i => i.id === 'x') // undefined
team.issues.length // 0
team.issues.at(0) // undefined
team.issues.forEach(i => {...}) // No-op initially
team.issues.toArray() // []Each access automatically kicks off hydration in the background.
Auto-Kick Behavior
When you access a LazyCollection for the first time:
// Step 1: Access
const titles = team.issues.map(issue => issue.title)
// Step 2: Behind the scenes
if (!this.hydrated && !this.loadingPromise) {
void this.hydrate() // Kick off background load
}
// Step 3: Return immediately
return [] // Empty array (synchronous!)
// Step 4: Background loading
// - Try local storage first (~10ms)
// - Fallback to network if needed (~100-500ms)
// Step 5: When data arrives
runInAction(() => {
this.items = loadedIssues
this.hydrated = true
})
// Step 6: MobX triggers re-render
// Component re-runs with populated dataExplicit Hydration
Sometimes you want to wait for data to load:
Option 1: Await Hydration
const [ready, setReady] = useState(false)
useEffect(() => {
team.issues.hydrate().then(() => setReady(true))
}, [team])
if (!ready) return <Spinner />
return team.issues.map(issue => <IssueRow issue={issue} />)Option 2: Use Suspense (Recommended)
<Suspense fallback={<Spinner />}>
<IssueList team={team} />
</Suspense>
const IssueList = observer(({ team }) => {
const issues = team.issues.suspense // Throws promise
return issues.map(issue => <IssueRow issue={issue} />)
})Hydration Sources
LazyCollections load data from two sources (in order):
1. Local Storage (Fast)
// First try: Load from storage via Drizzle adapter
const rows = await storage.listByIndex(type, indexKey, indexVal)
// ~10ms on deviceIf data is found in local storage, it’s loaded immediately.
2. Network (Fallback)
// If not in storage: Load from network
const rows = await batch.fetchCollection(type, indexKey, indexVal)
// ~100-500ms depending on networkAfter loading from network, data is cached in local storage for next time.
Collection States
LazyCollections have observable state:
const issues = team.issues
// Check if loaded
issues.hydrated // false → true after load
// Check loading state
issues.loadingPromise // undefined | Promise<Issue[]>
// Access current items
issues.elements // Always returns current items (auto-kicks hydration)Progressive Enhancement Example
const TeamView = observer(({ team }) => {
const issueCount = team.issues.length
return (
<div>
<h2>Issues ({issueCount})</h2>
{issueCount === 0 ? (
<div className='opacity-50'>
{team.issues.hydrated ? 'No issues' : 'Loading issues...'}
</div>
) : (
team.issues.map(issue => (
<IssueRow key={issue.id} issue={issue} />
))
)}
</div>
)
})UX:
- First render: “Loading issues…” (0 items, not hydrated)
- After load: Shows issues OR “No issues” (hydrated = true)
- No blocking spinner - progressive appearance
When Collections Hydrate
Automatic Hydration Triggers
// Any array method access triggers hydration
team.issues.map(...) // ✓ Triggers
team.issues.filter(...) // ✓ Triggers
team.issues.find(...) // ✓ Triggers
team.issues.length // ✓ Triggers
team.issues.at(0) // ✓ Triggers
team.issues.elements // ✓ TriggersManual Hydration
// Explicit load
await team.issues.hydrate()
// Suspense mode
const issues = team.issues.suspense // Throws if not loadedRelationship to Object Graph
LazyCollections are how the Object Graph connects models:
User → assignedIssues (LazyCollection) → [Issue, Issue, Issue]
↓
Each Issue has:
- team (Reference)
- assignee (LazyReference) → back to User
- comments (LazyCollection)All relationships use either Collection, LazyCollection, Reference, or LazyReference to maintain the graph structure.
Performance
N+1 Prevention
// Without lazy loading - N+1 queries
users.forEach(user => {
// Each iteration makes a query
const issues = await fetchIssues(user.id) // N queries!
})
// With LazyCollections
users.forEach(user => {
// First iteration kicks hydration for ALL users' issues
user.assignedIssues.length // Batched into 1 request ✓
})LazyCollections can be batch-loaded efficiently (when Batch Loader is configured).
Memory Efficient
Collections only load when needed:
// If you never access team.issues, they never load
const team = useModel<Team>('team', teamId)
return <h1>{team.name}</h1> // Issues not loaded ✓
// Only loads when accessed
return <div>{team.issues.length} issues</div> // Now loadsAPI Reference: LazyCollection · @OneToMany · @ManyToMany
Next Steps
- Learn about Lazy References - Single relationships
- Learn about Hydration - The loading mechanism
- Learn about Object Graph - How relationships form a graph
- Learn about React Suspense - Using Suspense boundaries