Skip to Content
LearnCore ConceptsLazy Collections

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

@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 data

Explicit 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} />)
<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 device

If 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 network

After 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 // ✓ Triggers

Manual Hydration

// Explicit load await team.issues.hydrate() // Suspense mode const issues = team.issues.suspense // Throws if not loaded

Relationship 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 loads

Next Steps

Last updated on