Skip to Content

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:

  1. Local storage (via Drizzle adapter) - Fast (~10ms)
  2. 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 automatically

2. 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 User

Models

const issue = useModel<Issue>('issue', issueId) // Hydrate all lazy properties await issue.hydrate() // Hydrate with depth await issue.hydrate(2) // Recursive hydration

Hydration 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-500ms

This 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> ) })

Next Steps

Last updated on