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:

FieldTypeMeaning
connectionStateConnectionState"connecting", "connected", "disconnected", …
myPeerIdstring | nullThis peer's ID once joined.
isHostbooleanWhether this peer is the room host.
hostIdstring | nullThe current host's peer ID.
peersreadonly string[]Remote peer IDs in the room.
peerCountnumberNumber of remote peers.
presencesReadonlyMap<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>
	);
}
Next.js: importing the package is SSR-safe, but WebRTC and WebSockets are browser side effects. Mark the component "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 5 runes: the official Svelte examples wrap the store in a .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)