Guides
Framework guides
The SDK core is plain TypeScript with zero framework coupling. Official adapters ship as
subpath exports for React, Vue, and Svelte — each one is a thin bridge from the store's
subscribe/getSnapshot contract to the framework's reactivity system.
The store contract
Every guide below starts from the same primitive:
createDendriStore(options) returns a DendriStore whose reactive
snapshot contains:
| Field | Type | Meaning |
|---|---|---|
connectionState | ConnectionState | "connecting", "connected", "disconnected", … |
myPeerId | string | null | This peer's ID once joined. |
isHost | boolean | Whether this peer is the room host. |
hostId | string | null | The current host's peer ID. |
peers | readonly string[] | Remote peer IDs in the room. |
peerCount | number | Number of remote peers. |
presences | ReadonlyMap<string, unknown> | Presence data per remote peer. |
All guides share this options module (see Configuration):
const serverUrl = new URL(import.meta.env.VITE_DENDRI_URL ?? "http://127.0.0.1:9876");
export const DENDRI_OPTIONS = {
host: serverUrl.hostname,
port: Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
secure: serverUrl.protocol === "https:",
path: serverUrl.pathname,
apiKey: import.meta.env.VITE_DENDRI_API_KEY,
fetchTurnCredentials: true,
enableRelay: true,
};
React
Live demo: react-basic.dendri.dev (source: react-basic).
The React adapter is a hook built on useSyncExternalStore, so components re-render
exactly when the snapshot changes — concurrent-mode safe, no manual subscriptions. React 18+ is
an optional peer dependency.
npm create vite@latest dendri-react -- --template react
cd dendri-react
npm install @afterrealism/dendri-client
import { createDendriStore } from "@afterrealism/dendri-client";
import { useDendriStore } from "@afterrealism/dendri-client/react";
import { useEffect, useRef, useState } from "react";
import { DENDRI_OPTIONS } from "./dendri-options";
export default function Chat() {
// Create the store once per component instance — never in render.
const storeRef = useRef(null);
if (storeRef.current === null) {
storeRef.current = createDendriStore(DENDRI_OPTIONS);
}
const store = storeRef.current;
// Reactive snapshot: re-renders on connection/peer/presence changes.
const { connectionState, isHost, peerCount, myPeerId } = useDendriStore(store);
const [messages, setMessages] = useState([]);
const [draft, setDraft] = useState("");
useEffect(() => {
// Receive messages published on the "chat" topic.
const unsubscribe = store.subscribe("chat", (data, peerId) => {
setMessages((prev) => [...prev, { from: peerId, text: data.text }]);
});
store.join("react-room");
return () => {
unsubscribe();
store.destroy(); // leaves the room and closes the connection
};
}, [store]);
function send() {
const text = draft.trim();
if (!text) return;
store.broadcast({ text }, { topic: "chat" });
setMessages((prev) => [...prev, { from: "me", text }]);
setDraft("");
}
return (
<main>
<h1>Dendri × React</h1>
<p>
{connectionState === "connected" ? "Connected" : "Connecting…"} ·{" "}
{isHost ? "host" : "client"} · {peerCount} peer{peerCount === 1 ? "" : "s"}
</p>
<ul>
{messages.map((m, i) => (
<li key={i}>
<strong>{m.from === "me" ? "me" : m.from.slice(0, 6)}:</strong> {m.text}
</li>
))}
</ul>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Type a message…"
/>
<button onClick={send} disabled={peerCount === 0}>Send</button>
</main>
);
}
"use client" and keep
join() inside useEffect — exactly as above.
Vue
Live demo: vue-basic.dendri.dev (source: vue-basic).
The Vue adapter returns a read-only shallowRef that tracks the snapshot and
automatically unsubscribes when the component unmounts. Vue 3.3+ is an optional peer dependency.
npm create vite@latest dendri-vue -- --template vue
cd dendri-vue
npm install @afterrealism/dendri-client
<script setup>
import { createDendriStore } from "@afterrealism/dendri-client";
import { useDendriStore } from "@afterrealism/dendri-client/vue";
import { onMounted, onUnmounted, ref } from "vue";
import { DENDRI_OPTIONS } from "./dendri-options";
const store = createDendriStore(DENDRI_OPTIONS);
// Reactive snapshot ref — auto-unsubscribes with the component.
const snapshot = useDendriStore(store);
const messages = ref([]);
const draft = ref("");
const unsubscribe = store.subscribe("chat", (data, peerId) => {
messages.value.push({ from: peerId.slice(0, 6), text: data.text });
});
onMounted(() => store.join("vue-room"));
onUnmounted(() => {
unsubscribe();
store.destroy();
});
function send() {
const text = draft.value.trim();
if (!text) return;
store.broadcast({ text }, { topic: "chat" });
messages.value.push({ from: "me", text });
draft.value = "";
}
</script>
<template>
<main>
<h1>Dendri × Vue</h1>
<p>
{{ snapshot.connectionState === "connected" ? "Connected" : "Connecting…" }} ·
{{ snapshot.isHost ? "host" : "client" }} · {{ snapshot.peerCount }} peers
</p>
<ul>
<li v-for="(m, i) in messages" :key="i">
<strong>{{ m.from }}:</strong> {{ m.text }}
</li>
</ul>
<input v-model="draft" placeholder="Type a message…" @keydown.enter="send" />
<button :disabled="snapshot.peerCount === 0" @click="send">Send</button>
</main>
</template>
Svelte
The Svelte adapter converts the store to the standard Svelte store contract, so
$snapshot auto-subscription just works — in Svelte 4 and Svelte 5. This adapter has
no peer dependency at all.
npx sv create dendri-svelte
cd dendri-svelte
npm install @afterrealism/dendri-client
<script>
import { createDendriStore } from "@afterrealism/dendri-client";
import { toSvelteStore } from "@afterrealism/dendri-client/svelte";
import { onDestroy, onMount } from "svelte";
import { DENDRI_OPTIONS } from "$lib/dendri-options";
const store = createDendriStore(DENDRI_OPTIONS);
const snapshot = toSvelteStore(store); // standard store contract → use $snapshot
let messages = [];
let draft = "";
const unsubscribe = store.subscribe("chat", (data, peerId) => {
messages = [...messages, { from: peerId.slice(0, 6), text: data.text }];
});
// join() in onMount so it never runs during SSR.
onMount(() => store.join("svelte-room"));
onDestroy(() => {
unsubscribe();
store.destroy();
});
function send() {
const text = draft.trim();
if (!text) return;
store.broadcast({ text }, { topic: "chat" });
messages = [...messages, { from: "me", text }];
draft = "";
}
</script>
<main>
<h1>Dendri × Svelte</h1>
<p>
{$snapshot.connectionState === "connected" ? "Connected" : "Connecting…"} ·
{$snapshot.isHost ? "host" : "client"} · {$snapshot.peerCount} peers
</p>
<ul>
{#each messages as m}
<li><strong>{m.from}:</strong> {m.text}</li>
{/each}
</ul>
<input bind:value={draft} placeholder="Type a message…" on:keydown={(e) => e.key === "Enter" && send()} />
<button on:click={send} disabled={$snapshot.peerCount === 0}>Send</button>
</main>
.svelte.ts module using $state and expose getters — see
the cursors demo
for the full pattern.
Vanilla JavaScript
No adapter needed — subscribe and re-render:
import { createDendriStore } from "@afterrealism/dendri-client";
import { DENDRI_OPTIONS } from "./dendri-options";
const store = createDendriStore(DENDRI_OPTIONS);
store.subscribe(() => {
const s = store.getSnapshot();
document.querySelector("#status").textContent =
`${s.connectionState} · ${s.peerCount} peers · ${s.isHost ? "host" : "client"}`;
});
store.subscribe("chat", (data, peerId) => {
console.log(`${peerId}:`, data.text);
});
store.join("vanilla-room");
Any other framework
The adapters are each ~20 lines. If your framework isn't listed (Solid, Angular, Lit, Preact, …), wrap the same two functions:
// 1. Read the current state (immutable snapshot)
const snapshot = store.getSnapshot();
// 2. Get notified when it changes (returns an unsubscribe function)
const unsubscribe = store.subscribe(() => {
render(store.getSnapshot());
});
For example, in Solid: createStore + onCleanup(store.subscribe(…)); in
Angular: a service exposing a signal updated inside the subscription.
Cleanup rules (all frameworks)
- Create the store once per app/component lifetime — never in a render body.
- Call every unsubscribe function returned by
subscribe/onData/onPeerJoinon teardown. - Call
store.destroy()last — it leaves the room, closes connections, and removes all listeners. - In dev with hot-module reload, stale stores keep sockets open; destroying in the teardown hook (as in each example above) prevents ghost peers.