Skip to Content
ReferenceClient APILazyCollection

LazyCollection

Lazy-loaded collection that extends Collection with auto-kick loading and array-like API.

Import

import { LazyCollection } from '@gluonic/client'

Class Signature

class LazyCollection<T> extends Collection<T> { // Constructor constructor(loader: () => Promise<T[]>) // Properties hydrated: boolean get elements(): T[] get suspense(): T[] get length(): number // Methods hydrate(): Promise<this> refresh(): void // Inherited from Collection at(index: number): T | undefined map<U>(fn: (item: T, i: number) => U): U[] forEach(fn: (item: T, i: number) => void): void filter(fn: (item: T, i: number) => boolean): T[] find(fn: (item: T, i: number) => boolean): T | undefined every(fn: (item: T, i: number) => boolean): boolean toArray(): T[] push(item: T): number }

Created by decorators: LazyCollection instances are created automatically by @OneToMany decorator. You typically don’t construct them manually.


Constructor

constructor(loader: () => Promise<T[]>)

Create a lazy collection with loader function.

Parameters:

ParameterTypeDescription
loader() => Promise<T[]>Function to load collection items

Example:

// Created by decorator (automatic) @OneToMany('issue', 'team') issues = new Collection<Issue>() // Runtime: LazyCollection<Issue> // Manual creation (rare) const issuesCol = new LazyCollection<Issue>( async () => bridge.getCollectionModels('issue', 'teamId', teamId) )

Properties

hydrated

hydrated: boolean

Observable flag indicating if collection has been loaded.

Example:

const IssueList = observer(({ team }) => { if (!team.issues.hydrated) { return <Spinner>Loading issues...</Spinner> } return <div>{team.issues.length} issues loaded</div> })

elements

get elements(): T[]

Synchronous array accessor - auto-kicks hydration.

Behavior:

  • Returns empty array [] if not loaded
  • Auto-kicks hydration in background on first access
  • Returns loaded items after hydration completes
  • Reactive - updates when items change

Example:

const TeamView = observer(({ team }) => { const issues = team.issues.elements // First access: [], starts loading // After load: [issue1, issue2, ...] return ( <div> {issues.length === 0 && !team.issues.hydrated && <Spinner />} {issues.map(issue => ( <IssueCard key={issue.id} issue={issue} /> ))} </div> ) })

suspense

get suspense(): T[]

Suspense-compatible accessor - throws promise if not loaded.

Behavior:

  • Returns array if loaded (guaranteed)
  • Throws promise if not loaded (for React Suspense)
  • Reactive - updates when items change

Example:

<Suspense fallback={<Spinner />}> <IssueList team={team} /> </Suspense> const IssueList = observer(({ team }) => { const issues = team.issues.suspense // Guaranteed loaded return ( <div> <h2>Issues ({issues.length})</h2> {issues.map(issue => ( <IssueRow key={issue.id} issue={issue} /> ))} </div> ) })

length

get length(): number

Number of items in collection.

Behavior:

  • Returns 0 if not loaded
  • Auto-kicks hydration on access
  • Returns actual count after loaded

Example:

const TeamStats = observer(({ team }) => { return <div>{team.issues.length} issues</div> })

Methods

hydrate()

hydrate(): Promise<this>

Explicitly load collection data and return promise.

Returns: Promise<this> - Resolves to collection instance

Behavior:

  • Returns immediately if already hydrated
  • Starts loading if not hydrated
  • Returns existing promise if already loading
  • Sets hydrated flag when complete

Example:

// Explicit pre-loading await team.issues.hydrate() console.log(team.issues.length) // Guaranteed loaded // Pre-load on mount useEffect(() => { void team.issues.hydrate() }, [team]) // Wait for loading const [loading, setLoading] = useState(true) useEffect(() => { team.issues.hydrate().then(() => setLoading(false)) }, [team])

refresh()

refresh(): void

Refresh collection from pool without re-fetching from network.

Behavior:

  • Re-runs loader to get updated items from ObjectPool
  • Only works if collection is already hydrated
  • Does NOT fetch from network
  • Useful after pool updates

Important: Unlike LazyReference, LazyCollection does NOT automatically detect when new items matching its query are added to the pool. This is intentional because:

  1. Production code uses React hooks (useCollectionModels) which handle pool changes via MobX observers
  2. Direct collection access outside hooks is rare
  3. Auto-invalidation would require observing all pool changes for the related type

If you need to refresh outside React hooks, call refresh() manually.

Example:

// Manually refresh after pool update await store.create('issue', id, { teamId: team.id, title: 'New' }) // Collection doesn't auto-update console.log(team.issues.length) // Still old count // Manual refresh team.issues.refresh() console.log(team.issues.length) // New count ✓

Array Methods (Auto-Kick)

All array methods auto-kick hydration and return empty/default values until loaded:

map()

map<U>(fn: (item: T, i: number) => U): U[]

Behavior:

  • Returns [] if not hydrated, kicks hydration
  • Returns mapped array if hydrated

Example:

const titles = team.issues.map(issue => issue.title) // First access: [], starts loading // After load: ['Issue 1', 'Issue 2', ...]

forEach()

forEach(fn: (item: T, i: number) => void): void

Behavior:

  • Does nothing if not hydrated, kicks hydration
  • Iterates if hydrated

filter()

filter(fn: (item: T, i: number) => boolean): T[]

Behavior:

  • Returns [] if not hydrated, kicks hydration
  • Returns filtered array if hydrated

Example:

const doneIssues = team.issues.filter(issue => issue.done)

find()

find(fn: (item: T, i: number) => boolean): T | undefined

Behavior:

  • Returns undefined if not hydrated, kicks hydration
  • Returns first match if hydrated

Example:

const urgentIssue = team.issues.find(i => i.priority === 'urgent')

every()

every(fn: (item: T, i: number) => boolean): boolean

Behavior:

  • Returns true if not hydrated (empty array default), kicks hydration
  • Returns boolean result if hydrated

Example:

const allDone = team.issues.every(issue => issue.done)

at()

at(index: number): T | undefined

Behavior:

  • Returns undefined if not hydrated, kicks hydration
  • Returns item at index if hydrated

Example:

const firstIssue = team.issues.at(0) const lastIssue = team.issues.at(-1)

toArray()

toArray(): T[]

Behavior:

  • Returns [] if not hydrated, kicks hydration
  • Returns copy of items array if hydrated

Example:

const issuesCopy = team.issues.toArray()

push()

push(item: T): number

Behavior:

  • Throws error if not hydrated
  • Adds item if hydrated

Example:

if (team.issues.hydrated) { team.issues.push(newIssue) }

Usage in Models

Define Relationship

import { Model, ClientModel, Property, OneToMany, Collection } from '@gluonic/client' @ClientModel('team') export class Team extends Model { @Property() id = crypto.randomUUID() @Property() name = '' @OneToMany('issue', 'team') issues = new Collection<Issue>() // Runtime: LazyCollection<Issue> }

Type Signature: Clean type Collection<Issue> for TypeScript. At runtime, decorator transforms to LazyCollection<Issue>.


Common Patterns

Progressive Loading

const TeamView = observer(({ team }) => { return ( <div> <h2>{team.name}</h2> <p>{team.issues.length} issues</p> {/* Shows 0 initially, updates when loaded */} {team.issues.map(issue => ( <IssueCard key={issue.id} issue={issue} /> ))} {/* Shows empty initially, populates when loaded */} </div> ) })

With Loading State

const TeamView = observer(({ team }) => { const { hydrated, length } = team.issues return ( <div> <h2>{team.name}</h2> {!hydrated ? ( <Spinner>Loading issues...</Spinner> ) : ( <p>{length} issues</p> )} {team.issues.map(issue => ( <IssueCard 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 until loaded return ( <div> <h2>Issues ({issues.length})</h2> {issues.map(issue => ( <IssueRow key={issue.id} issue={issue} /> ))} </div> ) })

Hydration Sources

LazyCollection tries multiple sources in order:

  1. ObjectPool - Instant (already in memory)
  2. Local storage - Fast (~10ms)
  3. Network batch - Fallback (~100-500ms)
  4. Cache - Result cached in storage for next time

Flow:

// First access (cold start) team.issues.map(...) // Loads from storage → network → cache // Second access (same session) team.issues.map(...) // Instant (from ObjectPool) // After app restart team.issues.map(...) // Fast (from storage)

Performance

Auto-Kick Loading

Collections load automatically when accessed (like Linear):

const TeamList = observer(() => { const teams = useCollectionModels<Team>('team') return ( <div> {teams.map(team => ( <div key={team.id}> {team.name} - {team.issues.length} issues {/* Each team.issues.length triggers lazy load */} </div> ))} </div> ) })

Batch Loading

Multiple collection accesses coalesce into batches:

// 50 teams, each with issues collection teams.forEach(team => { console.log(team.issues.length) }) // Without batching: 50 network requests // With batching: 1 request (~50ms delay)

Memory Efficient

Collections only load when accessed:

const team = useModel<Team>('team', id) // team.issues NOT loaded yet (zero overhead) console.log(team.name) // Issues still not loaded console.log(team.issues.length) // NOW issues load

Prefer useCollectionModels Hook

Recommendation: Use useCollectionModels hook instead of direct collection access. The hook automatically observes pool changes.

Direct access (manual refresh needed):

const TeamView = observer(({ team }) => { const issues = team.issues.elements // New issue created await store.create('issue', id, { teamId: team.id }) // Collection NOT updated automatically! team.issues.refresh() // Manual refresh required return <div>{issues.length} issues</div> })

Hook (auto-updates):

const TeamView = observer(({ team }) => { const issues = useCollectionModels<Issue>( 'issue', issue => issue.teamId === team.id ) // New issue created await store.create('issue', id, { teamId: team.id }) // Issues automatically updates! ✓ return <div>{issues.length} issues</div> })

See Also

Last updated on