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

  1. SSR renders the full HTML β€” All components render to HTML on the server. Zero JavaScript is needed to see the page.

  2. Browser displays HTML immediately β€” Fast First Contentful Paint. The page is fully visible and usable.

  3. Hydration scans for bindings β€” A tiny script (~1KB) runs after the page loads. It finds elements with data-ek-bind attributes and connects signals to DOM nodes via signal.subscribe().

  4. 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>&copy; 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

PatternBehavior
<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.

How it works

  1. SSR (default ssr="lazy") β€” The server sends a lightweight host element with your fallback (placeholder), not the full children.
  2. Client navigation / hydration β€” After each render(), EmberKit runs hydrateLazyInView() on the app root.
  3. IntersectionObserver β€” When the host enters the viewport (plus rootMargin), children are rendered into the host and hydrateSubtree() wires click handlers and data-ek-bind inside 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

PropTypeDefaultDescription
childrenJSXNode or () => JSXNodeβ€”Content to render when visible
fallbackJSXNodenullPlaceholder shown before load
rootMarginstring'200px'Passed to IntersectionObserver (preload before visible)
minHeightstring | numberβ€”Reserved space on the host element
oncebooleantrueUnregister loader after first mount
ssr'lazy' | 'eager''lazy'eager = render children on the server too
asstring'div'Host tag name
classNamestringβ€”Classes on the host

SSR modes

ModeFirst HTMLBest for
lazy (default)Fallback onlyLong pages, demos, marketing sections below the fold
eagerFull childrenSEO-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:

AttributeMeaning
data-ek-lazy-in-viewRegistry id for this host
data-ek-lazy-root-marginObserver margin
data-ek-lazy-loaded="true"Children have been mounted
aria-busy="true"Still waiting (removed after load)

Testing locally

  1. Start the docs app: pnpm dev from the repo root (or pnpm dev in apps/docs).
  2. Open /, /docs/icons, or /docs/ui.
  3. Confirm pulse placeholders above the fold where sections are lazy.
  4. Scroll down β€” content should replace placeholders; hosts gain data-ek-lazy-loaded.
  5. 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

What goes wrong

Typical flow on an SSR home page:

  1. Server renders with an empty list (no API on the server).
  2. Client preloads /api/... into setCache() before or after render().
  3. Hydration keeps the server’s empty grid in the DOM.
  4. 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.

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:

ElementBound property
<input>, <textarea>.value
<select>.value
<button> (boolean signal)disabled
Other elementstextContent (default)

Performance Impact

ApproachBundle SizeFCPTTI
Traditional SPA~100KB+SlowSlow
EmberKit hydration~1-2KBFastFast
Static only0KBInstantInstant
+ LazyInView below foldSame route chunk; less initial render/hydration workFaster perceived load on long pagesSections 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