Hydration
Hydration is the process of making server-rendered HTML interactive. EmberKit's hydration is lightweight and targeted β instead of re-rendering the entire component tree, it connects signals to specific DOM elements via declarative data-ek-bind attributes.
How It Works
-
SSR renders the full HTML β All components render to HTML on the server. Zero JavaScript is needed to see the page.
-
Browser displays HTML immediately β Fast First Contentful Paint. The page is fully visible and usable.
-
Hydration scans for bindings β A tiny script (
~1KB) runs after the page loads. It finds elements withdata-ek-bindattributes and connects signals to DOM nodes viasignal.subscribe(). -
Targeted updates β When a signal changes, only the bound DOM element updates. No virtual DOM, no diffing, no re-render.
// Static: pure HTML, zero JS
function Footer() {
return <footer>© 2025 EmberKit</footer>;
}
// Interactive: signal-driven DOM binding
function Counter() {
const [count, setCount] = createSignal(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<span data-ek-bind={count}>{count()}</span>
<button onClick={() => setCount(c => c - 1)}>-</button>
</div>
);
}
data-ek-bind Reference
data-ek-bind
Connects a signal getter directly to a DOM element. The framework serializes the signal's identity during SSR and reconnects it during hydration.
| Pattern | Behavior |
|---|---|
<span data-ek-bind={count}>{count()}</span> | Updates textContent when signal changes |
<div data-ek-bind={open} data-ek-show="opacity-100" data-ek-hide="opacity-0"> | Adds/removes CSS classes based on truthiness |
<div data-ek-bind={tab} data-ek-show-when="preview"> | Toggles a hidden class when signal equals a specific string |
textContent Binding (default)
No extra attributes needed β the element's text stays in sync:
const [name, setName] = createSignal('Alice');
return <p data-ek-bind={name}>{name()}</p>;
// When setName('Bob'), the <p> textContent updates.
Class Toggle Binding
Use data-ek-show / data-ek-hide to toggle CSS class sets based on a boolean signal:
const [open, setOpen] = createSignal(false);
return (
<div data-ek-bind={open} data-ek-show="opacity-100" data-ek-hide="opacity-0 pointer-events-none" class="opacity-0 pointer-events-none">
Panel content
</div>
);
String Match Binding
Use data-ek-show-when for tab-like switching:
const [tab, setTab] = createSignal('preview');
return (
<>
<div data-ek-bind={tab} data-ek-show-when="preview" class="p-4">Preview panel</div>
<div data-ek-bind={tab} data-ek-show-when="code" class="p-4 hidden">Code panel</div>
</>
);
Customize the hide class with data-ek-hide-class (defaults to "hidden").
Components Accepting Signals
Interactive components detect when a prop is a signal (has __idx / subscribe) and apply bindings automatically. This keeps consumer code clean:
const [open, setOpen] = createSignal(false);
// Pass the signal β Modal handles data-ek-bind internally
<Modal open={open} onClose={() => setOpen(false)} />
// Or pass a plain boolean β SSR only, no hydration
<Modal open={true} />
What Gets Hydrated
Only elements with data-ek-bind or event handlers receive JavaScript. Click handlers are serialized as data-ekclick attributes and wired on load. Everything else stays as pure HTML.
function App() {
return (
<div>
{/* Zero JS β pure HTML */}
<header>
<h1>My App</h1>
<nav>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
{/* Gets hydration β signal binding on value */}
<span data-ek-bind={count}>{count()}</span>
{/* Zero JS β pure content */}
<article>
<p>This content never needs JavaScript.</p>
</article>
</div>
);
}
Viewport lazy loading (LazyInView)
Defer rendering heavy UI until it is near the viewport. The docs site uses this for the homepage sections below the hero, the icon grid, and large UI demos.
Different from image lazy loading
LazyInView delays component HTML and client work for a section. Image loading="lazy" only defers image requests β see Image Optimization.
How it works
- SSR (default
ssr="lazy") β The server sends a lightweight host element with yourfallback(placeholder), not the full children. - Client navigation / hydration β After each
render(), EmberKit runshydrateLazyInView()on the app root. - IntersectionObserver β When the host enters the viewport (plus
rootMargin), children are rendered into the host andhydrateSubtree()wires click handlers anddata-ek-bindinside that section.
Flow: page load β fallback visible β user scrolls near section β LazyInView mounts children β hydrateSubtree wires that host.
Basic usage
import { LazyInView } from '@emberkit/core';
function HomePage() {
return (
<div>
<Hero />
<LazyInView
minHeight="24rem"
rootMargin="200px"
fallback={
<div aria-hidden="true" className="min-h-96 animate-pulse rounded-2xl border border-white/5 bg-white/[0.02]" />
}
>
<FeaturesSection />
</LazyInView>
</div>
);
}
Use a minHeight (or fallback with fixed height) so the layout does not jump when content appears.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | JSXNode or () => JSXNode | β | Content to render when visible |
fallback | JSXNode | null | Placeholder shown before load |
rootMargin | string | '200px' | Passed to IntersectionObserver (preload before visible) |
minHeight | string | number | β | Reserved space on the host element |
once | boolean | true | Unregister loader after first mount |
ssr | 'lazy' | 'eager' | 'lazy' | eager = render children on the server too |
as | string | 'div' | Host tag name |
className | string | β | Classes on the host |
SSR modes
| Mode | First HTML | Best for |
|---|---|---|
lazy (default) | Fallback only | Long pages, demos, marketing sections below the fold |
eager | Full children | SEO-critical body copy that must be in view source |
Lazy render prop
Pass a function when children pull in large modules:
<LazyInView fallback={<SectionSkeleton />}>
{() => {
// Evaluated only when the section enters the viewport
return <HeavyChart data={data} />;
}}
</LazyInView>
Host attributes (debugging)
Inspect the DOM while testing:
| Attribute | Meaning |
|---|---|
data-ek-lazy-in-view | Registry id for this host |
data-ek-lazy-root-margin | Observer margin |
data-ek-lazy-loaded="true" | Children have been mounted |
aria-busy="true" | Still waiting (removed after load) |
Testing locally
- Start the docs app:
pnpm devfrom the repo root (orpnpm devinapps/docs). - Open
/,/docs/icons, or/docs/ui. - Confirm pulse placeholders above the fold where sections are lazy.
- Scroll down β content should replace placeholders; hosts gain
data-ek-lazy-loaded. - Optional: DevTools β Network β throttle Fast 3G, hard refresh, and compare first paint vs after scroll.
Framework tests:
pnpm --filter @emberkit/core test -- src/viewport src/hydration
data-hydrate="lazy" (interactive islands)
For elements that need hydration but not immediately, mark them lazy so the hydration analyzer treats them as low priority:
<div data-hydrate="lazy" onClick={handleClick}>
Below-the-fold control
</div>
This complements LazyInView: lazy in view defers rendering; data-hydrate="lazy" defers wiring event handlers for already-rendered HTML.
Dynamic lists after SSR
Hydration vs .map() lists
After SSR, EmberKit hydrates existing HTML. It does not re-run JSX .map() when a signal updates β only nodes with data-ek-bind stay in sync.
What goes wrong
Typical flow on an SSR home page:
- Server renders with an empty list (no API on the server).
- Client preloads
/api/...intosetCache()before or afterrender(). - Hydration keeps the serverβs empty grid in the DOM.
- A signal is updated with fetched data, but the list markup does not change.
The API can return 200 and the cache can be full while the UI still shows an empty section.
Recommended patterns
1. Force a client re-render of the route when async data arrives and the list DOM is still empty:
function refreshCurrentRoute() {
const url =
window.location.pathname + window.location.search + window.location.hash;
history.replaceState(null, '', url);
}
createEffect(() => {
void (async () => {
const list = await fetchProjects();
setProjects(list);
const grid = document.getElementById('portfolio-grid');
if (list.length > 0 && (grid?.children.length ?? 0) === 0) {
refreshCurrentRoute();
}
})();
});
Patched history.replaceState in render() calls renderCurrentRoute() again with hydrate: false, so the route HTML is rebuilt from JSX.
2. Imperative DOM updates (like the docs blog index pattern): render cards with innerHTML or createElement inside createEffect instead of relying on .map() in JSX.
3. SSR loaders β fetch list data in a route loader so the server HTML includes items. See SSR & SSG.
4. LazyInView with ssr="eager" β when SEO needs full HTML at first paint.
Input bindings (0.8.0)
data-ek-bind now syncs form controls during hydration:
| Element | Bound property |
|---|---|
<input>, <textarea> | .value |
<select> | .value |
<button> (boolean signal) | disabled |
| Other elements | textContent (default) |
Performance Impact
| Approach | Bundle Size | FCP | TTI |
|---|---|---|---|
| Traditional SPA | ~100KB+ | Slow | Slow |
| EmberKit hydration | ~1-2KB | Fast | Fast |
| Static only | 0KB | Instant | Instant |
| + LazyInView below fold | Same route chunk; less initial render/hydration work | Faster perceived load on long pages | Sections hydrate as user scrolls |
Next Steps
- Signals β Signal API and subscribe
- SSR β Server-side rendering
- Release 0.8.0 β Dev API, view transitions, hydration notes
- Components β Static vs interactive components
- API Reference β
LazyInView,hydrateLazyInView,clearLazyRegistry