Taskboard 6. SSR and hydration
Add static SSR and client hydration to the accumulated Taskboard.
Table of contents
Stage 6. Add static SSR and hydration
- Starting state
- Complete Stage 5 so Taskboard has shared state, routes, asynchronous initial data and a local validated form.
- Applied change
- Create the accumulated app with createTaskboardApp. router.go(path) resolves and renders the route and returns routeHtml. v-html inserts that already serialized fragment into the shell, and render() serializes the outer document. Embed snapshotTaskboardState(app.store.state) and recreate the app from that snapshot in the browser.
- Observable result
- The generated document contains the full Tasks route. mountRouter hydrates that same body, and Toggle details, About and the FormStore form continue from the prerendered DOM.
- Next step
- Continue with Stage 7 and add local offline delivery plus a worker update flow.
Build and observe hydration
First inspect the generated HTML and confirm that Taskboard and the Tasks route are already present before the client bundle loads. Then open the page and use Toggle details, About or the form to confirm that mountRouter hydrated that body and connected interaction.
build.mjs
jsimport fs from "node:fs";
import { v } from "valyrian.js";
import { inline, render } from "valyrian.js/node";
const client = await inline("./src/client-entry.tsx");
const prerender = await inline("./src/prerender-entry.tsx");
fs.writeFileSync("./prerender.cjs", prerender.raw);
await import("./prerender.cjs");
const { routeHtml, initialState } = await globalThis.__TASKBOARD_PRERENDER__;
fs.rmSync("./prerender.cjs");
const serialized = JSON.stringify(initialState)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const body =
routeHtml +
"<script>window.__INITIAL_STATE__=" +
serialized +
';</script><script src="/client-entry.js"></script>';
const documentHtml =
"<!doctype html>" +
render(
v("html", { lang: "en" },
v("head", {},
v("meta", { charset: "UTF-8" }),
v("meta", { name: "viewport", content: "width=device-width,initial-scale=1" }),
v("title", {}, "Taskboard"),
),
v("body", { "v-html": body }),
),
);
fs.mkdirSync("./public", { recursive: true });
fs.writeFileSync("./public/client-entry.js", client.raw);
fs.writeFileSync("./public/index.html", documentHtml);
src/state.ts
tsimport { createPulseStore } from "valyrian.js/pulses";
export type Task = { id: number; title: string };
export type TaskboardState = {
tasks: Task[];
showDetails: boolean;
nextId: number;
loaded: boolean;
};
export function createTaskboardStore(
initial: Partial<TaskboardState> = {},
) {
return createPulseStore(
{
tasks: (initial.tasks ?? [{ id: 1, title: "Learn Valyrian.js" }]).map(
(task) => ({ ...task }),
),
showDetails: initial.showDetails ?? false,
nextId: initial.nextId ?? 2,
loaded: initial.loaded ?? false,
},
{
toggleDetails(state) {
state.showDetails = !state.showDetails;
},
addTask(state, task: Task) {
state.tasks.push(task);
state.nextId = Math.max(state.nextId, task.id + 1);
},
replaceTasks(state, tasks: Task[]) {
state.tasks = tasks;
state.loaded = true;
state.nextId = tasks.reduce(
(nextId, task) => Math.max(nextId, task.id + 1),
1,
);
},
},
);
}
export type TaskboardStore = ReturnType<typeof createTaskboardStore>;
export function snapshotTaskboardState(
state: TaskboardState,
): TaskboardState {
return {
tasks: state.tasks.map((task) => ({ ...task })),
showDetails: state.showDetails,
nextId: state.nextId,
loaded: state.loaded,
};
}
src/prerender-entry.tsx
tsximport { snapshotTaskboardState } from "./state";
import { createTaskboardApp } from "./taskboard";
declare global {
var __TASKBOARD_PRERENDER__: Promise<{
routeHtml: string;
initialState: ReturnType<typeof snapshotTaskboardState>;
}>;
}
globalThis.__TASKBOARD_PRERENDER__ = (async () => {
const app = createTaskboardApp({
tasks: [
{ id: 1, title: "Learn Valyrian.js" },
{ id: 2, title: "Build Taskboard" },
],
loaded: true,
nextId: 3,
});
return {
routeHtml: (await app.router.go("/")) || "",
initialState: snapshotTaskboardState(app.store.state),
};
})();
src/client-entry.tsx
tsximport { mountRouter } from "valyrian.js/router";
import { createTaskboardApp } from "./taskboard";
import type { TaskboardState } from "./state";
declare global {
interface Window {
__INITIAL_STATE__?: TaskboardState;
}
}
const app = createTaskboardApp(window.__INITIAL_STATE__);
mountRouter("body", app.router);