Hydration
Concept: The process of loading data into memory (from local storage or network).
What is Hydration?
Hydration is Gluonic’s term for loading data into the in-memory Object Graph. Data can be hydrated from:
- Local storage (via Drizzle adapter) - Fast (~10ms)
- Network (via API) - Slower (~100-500ms)
Once hydrated, data is available synchronously through the Object Graph.
Three Types of Hydration
1. Automatic Hydration
Accessing lazy data triggers hydration automatically:
const TeamView = observer(({ team }) => {
// Accessing .map() kicks off hydration
return team.issues.map(issue => (
<div>{issue.title}</div>
))
})
// No explicit hydration call needed
// Works automatically2. Explicit Hydration
Manually trigger hydration and wait for completion:
const TeamView = observer(({ team }) => {
const [ready, setReady] = useState(false)
useEffect(() => {
// Explicitly hydrate before rendering
team.issues.hydrate().then(() => setReady(true))
}, [team])
if (!ready) return <Spinner />
return team.issues.map(issue => <div>{issue.title}</div>)
})3. Recursive Hydration
Hydrate a model and all its lazy relationships:
const issue = useModel<Issue>('issue', issueId)
// Hydrate depth 1: Just direct relationships
await issue.hydrate(1)
// Loads: issue.assignee, issue.comments
// Hydrate depth 2: Nested relationships too
await issue.hydrate(2)
// Loads: issue.assignee, issue.comments,
// issue.assignee.profile, issue.comments[].author
// Hydrate depth 3: Even deeper
await issue.hydrate(3)
// And so on...Hydration API
Collections
const issues = team.issues
// Check state
issues.hydrated // false → true after load
issues.loadingPromise // undefined | Promise<Issue[]>
// Automatic hydration
issues.map(...) // Kicks hydration, returns [] initially
// Explicit hydration
await issues.hydrate() // Returns Promise<LazyCollection<Issue>>
// Suspense mode
const loaded = issues.suspense // Throws promise or returns Issue[]References
const author = issue.author
// Check state
author._hydrated // false → true after load
author._promise // undefined | Promise<User>
// Automatic hydration
const user = author.value // Kicks hydration, returns undefined initially
// Explicit hydration
await author.hydrate() // Returns Promise<User>
// Suspense mode
const user = author.suspense // Throws promise or returns UserModels
const issue = useModel<Issue>('issue', issueId)
// Hydrate all lazy properties
await issue.hydrate()
// Hydrate with depth
await issue.hydrate(2) // Recursive hydrationHydration Sources
Gluonic tries multiple sources in order:
Level 1: ObjectPool (Instant)
// Best case: Already in memory
const row = store.pool.get(type, id)
if (row) return instantiateModel(row) // < 1ms ✓Level 2: Local Storage (Fast)
// Not in pool: Try local storage
const row = await storage.getRow(type, id)
if (row) {
store.pool.upsert(row) // Add to pool
return instantiateModel(row) // ~10ms âś“
}Level 3: Network (Fallback)
// Not in storage: Load from network
const row = await mutations.fetch(type, id)
store.pool.upsert(row) // Add to pool
await storage.putRow(row) // Cache for next time
return instantiateModel(row) // ~100-500msThis is the Model Loader pattern - transparent to calling code.
TypeScript: Hydrated Type
After hydration, TypeScript knows data is guaranteed:
@ClientModel('issue')
class Issue extends Model {
@Property()
title!: string
@Property()
assigneeId?: string
@ManyToOne('assignee', 'assigneeId')
assignee?: User
@OneToMany('issue')
comments = new Collection<Comment>()
}
// Before hydration
const issue: Issue = useModel('issue', id)
issue.assignee?.value // User | undefined
issue.comments.elements // Comment[] (may be empty)
// After hydration
const hydrated: Hydrated<Issue> = await issue.hydrate()
hydrated.assignee.value // User (guaranteed after hydration)
hydrated.comments.elements // Comment[] (loaded)The Hydrated<T> type transforms all LazyReference values to non-optional.
Recursive Hydration Example
// Load issue with all nested data
const issue = useModel<Issue>('issue', issueId)
await issue.hydrate(2)
// Now everything is loaded:
console.log(issue.title) // 'Fix bug'
console.log(issue.assignee.value.name) // 'Alice' (loaded)
console.log(issue.comments.length) // 5 (loaded)
console.log(issue.comments.at(0)?.author.value?.name) // 'Bob' (depth 2!)Depth levels:
- Depth 0: No hydration
- Depth 1: Direct lazy properties (issue.assignee, issue.comments)
- Depth 2: Nested lazy properties (issue.comments[].author)
- Depth 3: Even deeper nested properties
Caution: Deep hydration can load a lot of data. Use sparingly and only when needed. For most cases, automatic hydration (accessing as needed) is better.
Hydration Strategies
Strategy 1: Auto-Kick (Simplest)
Let Gluonic handle everything:
const IssueRow = observer(({ issue }) => {
// Just access - hydration happens automatically
return (
<div>
{issue.title}
<span>{issue.assignee.value?.name ?? 'Unassigned'}</span>
</div>
)
})Pros: Simplest code, progressive appearance Cons: May show “Loading…” briefly
Strategy 2: Explicit Hydration (More Control)
Hydrate before rendering:
const IssueDetail = observer(({ issueId }) => {
const issue = useModel<Issue>('issue', issueId)
const [ready, setReady] = useState(false)
useEffect(() => {
issue.hydrate(1).then(() => setReady(true))
}, [issue])
if (!ready) return <Spinner />
return (
<div>
{issue.title}
{issue.assignee.value.name} {/* Guaranteed loaded */}
</div>
)
})Pros: Guaranteed data when renders Cons: Shows spinner, blocks rendering
Strategy 3: Suspense (Cleanest)
Use React Suspense boundaries:
<Suspense fallback={<Spinner />}>
<IssueDetail issue={issue} />
</Suspense>
const IssueDetail = observer(({ issue }) => {
const assignee = issue.assignee.suspense
const comments = issue.comments.suspense
return (
<div>
{issue.title}
{assignee.name} {/* No optionals! */}
{comments.map(c => c.text)}
</div>
)
})Pros: Clean component code, type-safe Cons: Shows spinner, blocks rendering
Hydration Performance
Parallel Hydration
Multiple hydrations run in parallel:
// These all kick off simultaneously
const author = issue.author.value
const assignee = issue.assignee.value
const project = issue.project.value
// 3 parallel requests (if not in pool/storage)
// Not sequential!Deduplication
Same request only happens once:
// Two components access same reference
<ComponentA issue={issue} /> // Kicks hydration
<ComponentB issue={issue} /> // Uses same in-flight promise
// Only 1 network request, not 2 ✓Batch Loading
When Batch Loader is configured, requests are batched:
// 50 issues, each accesses author
issues.map(issue => issue.author.value)
// Without batching: 50 requests
// With batching: 1 request (after 50ms coalescing) ✓Common Patterns
Hydrate Before Navigation
const handleIssueClick = async (issueId: string) => {
const issue = bridge.getModel<Issue>('issue', issueId)
// Pre-hydrate before navigating
await issue.hydrate(1)
// Navigate - data ready!
router.push(`/issues/${issueId}`)
}Hydrate on Hover
const IssueRow = observer(({ issue }) => {
const handleHover = () => {
// Start hydrating on hover
void issue.hydrate(1)
}
return (
<div
onMouseEnter={handleHover}
onClick={() => router.push(`/issues/${issue.id}`)}
>
{issue.title}
</div>
)
})By the time user clicks, data is likely already loaded!
Conditional Hydration
const IssueDetail = observer(({ issue }) => {
// Only hydrate comments when tab is active
const [showComments, setShowComments] = useState(false)
useEffect(() => {
if (showComments) {
void issue.comments.hydrate()
}
}, [showComments, issue])
return (
<div>
<button onClick={() => setShowComments(true)}>
Show Comments
</button>
{showComments && issue.comments.map(c => (
<Comment key={c.id} comment={c} />
))}
</div>
)
})API Reference: model.hydrate() · collection.hydrate() · reference.hydrate()
Next Steps
- Learn about Lazy Collections - How collections hydrate
- Learn about Lazy References - How references hydrate
- Learn about Model Loader - Where data comes from
- Learn about React Suspense - Using
.suspenseaccessors