Start with the working server from the server rendering lesson. Suppose the server knows the count is seven, while a fresh browser would start at zero. Replace RefSubject.make with RefSubject.hydrate in Counter.ts:
export const Counter = component(function* () {
// The browser restores the server snapshot instead of replacing it with its default.
const count = yield* RefSubject.hydrate(
Schema.Finite,
Effect.sync(() => (typeof document === "undefined" ? 7 : 0)),
);
Schema.Finite checks the transferred value. The server serializes its state; the browser restores it before live bindings run. The environment check makes the differing initializers visible in this small example.
Attach the subject to an element with ref=${count}. This carries the state handoff:
// The root ref connects this subject to its serialized state during rendering.
return html`<section class="counter-demo" aria-label="Counter" ref=${count}>
<div class="counter-demo__controls">
<button type="button" onclick=${RefSubject.decrement(count)}>Decrease</button>
<output aria-live="polite">${count}</output>
<button type="button" onclick=${RefSubject.increment(count)}>Increase</button>
</div>
</section>`;
This version keeps just the controls so you can watch the restored count. Server and browser use this same template. The server document, development runner, and browser entry from the previous lesson stay the same.
Replace src/Counter.ts with the complete file and reload http://127.0.0.1:5174. The count should start at 7; click Increase to reach 8. The preview below uses this exact component and the same state handoff.
Complete files
src/Counter.ts
import { component } from "@typed/ui/Component";
import { RefSubject } from "@typed/fx";
import { html } from "@typed/template";
import { Effect, Schema } from "effect";
export const Counter = component(function* () {
// The browser restores the server snapshot instead of replacing it with its default.
const count = yield* RefSubject.hydrate(
Schema.Finite,
Effect.sync(() => (typeof document === "undefined" ? 7 : 0)),
);
// The root ref connects this subject to its serialized state during rendering.
return html`<section class="counter-demo" aria-label="Counter" ref=${count}>
<div class="counter-demo__controls">
<button type="button" onclick=${RefSubject.decrement(count)}>Decrease</button>
<output aria-live="polite">${count}</output>
<button type="button" onclick=${RefSubject.increment(count)}>Increase</button>
</div>
</section>`;
});
src/client.ts
import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
import { Effect, Layer } from "effect";
import { Counter } from "./Counter.js";
const host = document.getElementById("app");
if (host === null) throw new Error("Missing #app host");
// Attach inside the server's host so this view can reuse its existing DOM.
await render(Counter, host).pipe(
Fx.drainLayer,
Layer.provide(DomRenderTemplate.using(document)),
Layer.launch,
Effect.runPromise,
);
Try changing the server initializer to twelve. Reload and check that the first click produces 13. If the count resets to zero, inspect the response’s state marker, the ref attachment, and the matching #app mount target.
Continue with hydrating Typed HTML, or build the client-only TodoMVC tutorial to practice application boundaries.