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:
| Parameter | Type | Description |
|---|---|---|
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: booleanObservable 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(): numberNumber of items in collection.
Behavior:
- Returns
0if 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
hydratedflag 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(): voidRefresh 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:
- Production code uses React hooks (
useCollectionModels) which handle pool changes via MobX observers - Direct collection access outside hooks is rare
- 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): voidBehavior:
- 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 | undefinedBehavior:
- Returns
undefinedif 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): booleanBehavior:
- Returns
trueif 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 | undefinedBehavior:
- Returns
undefinedif 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): numberBehavior:
- 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:
- ObjectPool - Instant (already in memory)
- Local storage - Fast (~10ms)
- Network batch - Fallback (~100-500ms)
- 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 loadPrefer 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
- Collection - Eager collection (base class)
- LazyReference - Lazy reference class
- useCollectionModels - Hook (recommended)
- Decorators - @OneToMany decorator
- Lazy Collections Concept - Conceptual overview
- Relationships Guide - Complete guide