Counter
Twice the count: 14
Continue with the working server from the server rendering lesson. The server will initialize the count to seven while the browser’s default remains zero. Restore the server value before the Counter becomes interactive by replacing 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}>
<h2>Counter</h2>
<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>
<p>Twice the count: ${doubled}</p>
</section>`;
The controls and doubled value stay the same; only state initialization and the root ref change. 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, with the doubled value changing from 14 to 16. The preview below uses this exact component and the same state handoff.
Complete files
src/Counter.ts
import { RefSubject } from "@typed/fx";
import { component, 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)),
);
const doubled = RefSubject.map(count, (value) => value * 2);
// The root ref connects this subject to its serialized state during rendering.
return html`<section class="counter-demo" aria-label="Counter" ref=${count}>
<h2>Counter</h2>
<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>
<p>Twice the count: ${doubled}</p>
</section>`;
});
src/client.ts (unchanged)
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.
The server handoff is complete. For adoption diagnostics, consult hydrating Typed HTML.