hotstate
v0.1.0
Published
Lightweight real-time state sync over WebSockets
Maintainers
Readme
hotstate
Real-time state synchronization over WebSocket. Server is the source of truth — clients get live updates automatically.
Three concepts
Stores — Shared state. Define it on the server, subscribe from the client. Changes sync automatically via patches or snapshots.
Functions — Request/response. Client calls a function, server handles it and returns a result. Like an API endpoint, but over WebSocket.
Events — Fire-and-forget messages. Server pushes data to clients without storing it. Good for notifications, logs, ephemeral signals.
Quick examples
Stores
Server owns the state, clients subscribe:
// server
import Hotstate from "hotstate";
const server = Hotstate();
const todos = server.createStore("Todos", { items: [] });
// mutate from anywhere on the server
todos.update((draft) => {
draft.items.push({ id: 1, text: "Buy milk", done: false });
});
server.listen({ port: 4400 });// client
import Hotstate from "hotstate/client";
const client = Hotstate();
client.connect("ws://localhost:4400");
const todos = client.subscribe("Todos", (state) => {
console.log("Todos changed:", state);
});
await todos.ready();
console.log(todos.getState()); // { items: [{ id: 1, text: "Buy milk", done: false }] }Functions
Client calls, server handles:
// server
server.handle("todos.add", (args) => {
const id = Date.now();
todos.update((draft) => {
draft.items.push({ id, text: args.text, done: false });
});
return { id };
});// client
const { id } = await client.call("todos.add", { text: "Buy milk" });Events
Server pushes, clients listen:
// server — broadcast to all
server.emit("notification", { title: "Deployed", body: "v2.1 is live" });
// targeted — one client only
server.emit("alert", { message: "Your build finished" }, { clientId });// client
client.on("notification", (data) => {
console.log(data.title); // "Deployed"
});Realistic example
A collaborative todo app with live rooms:
// ── server.js ───────────────────────────────────────────────
import Hotstate from "hotstate";
const server = Hotstate();
// Static stores
const todos = server.createStore("Todos", { items: [] });
const config = server.createStore("Config", { theme: "dark" });
// Dynamic store — instances created on demand
const rooms = server
.createStore("Room:*", { messages: [], users: [] })
.onCreate((roomId) => ({ name: roomId }))
.onIdle((roomId) => rooms.reset(roomId));
// Functions
let nextId = 0;
server.handle("todos.add", (args) => {
const id = ++nextId;
todos.update((draft) => {
draft.items.push({ id, text: args.text, done: false });
});
return { id };
});
server.handle("todos.toggle", (args) => {
todos.update((draft) => {
const todo = draft.items.find((t) => t.id === args.id);
if (todo) todo.done = !todo.done;
});
});
server.handle("room.send", (args, { clientId }) => {
rooms.update(args.room, (draft) => {
draft.messages.push({ from: clientId, text: args.text, ts: Date.now() });
});
});
// Events
server.on("connection", (id) => {
server.emit("user.joined", { id });
});
server.listen({ port: 4400 });// ── client.js ───────────────────────────────────────────────
import Hotstate from "hotstate/client";
const client = Hotstate();
client.connect("ws://localhost:4400");
await client.ready();
// Subscribe to stores — renders update automatically via onChange
client.subscribe("Todos", (state) => {
renderTodos(state.items);
});
client.subscribe("Room:general", (state) => {
renderChat(state.messages);
});
// Call functions — only needs client.ready(), not store.ready()
await client.call("todos.add", { text: "Ship it" });
await client.call("room.send", { room: "general", text: "Hello!" });
// Listen for events
client.on("user.joined", ({ id }) => {
console.log(`${id} joined`);
});API Reference
Server
serve()
Create a server instance. Define stores and functions, then start listening.
import Hotstate from "hotstate"
const server = Hotstate()
// Define your stores and functions...
const todos = server.createStore("Todos", { items: [] })
server.handle("todos.add", args => { ... })
// Then start:
server.listen({ port: 4400 })server.listen(options)
Start a standalone WebSocket server.
server.listen({ port: 4400 }); // ws://localhost:4400
server.listen({ port: 4400, path: "/ws" }); // ws://localhost:4400/ws
server.listen({ port: 4400, host: "0.0.0.0" });| Option | Default | Description |
| ------------- | --------- | ---------------------------------------- |
| port | 4400 | Port to listen on |
| path | all paths | WebSocket endpoint path |
| host | localhost | Bind address |
| heartbeatMs | 30000 | Client keepalive interval (0 to disable) |
server.attach(httpServer, options?)
Attach to an existing HTTP server. Shares the same port — the WebSocket upgrades alongside your REST API.
server.attach(app.server); // ws://localhost:3000
server.attach(app.server, { path: "/ws" }); // ws://localhost:3000/ws| Option | Default | Description |
| ------------- | --------- | ---------------------------------------- |
| path | all paths | WebSocket endpoint path |
| heartbeatMs | 30000 | Client keepalive interval (0 to disable) |
server.createStore(name, data)
Create a store and return a handle. Both static and dynamic stores share the same handle API.
// Static store — single instance
const config = server.createStore("Config", { theme: "dark" });
// Dynamic store — multiple instances created on demand
const rooms = server.createStore("Room:*", { messages: [] });Store handle
Static stores operate directly. Dynamic stores take a key as first argument:
// Static // Dynamic
config.update(d => { d.theme = "light" }) rooms.update("general", d => { ... })
config.set({ theme: "dark" }) rooms.set("general", { messages: [] })
config.get() rooms.get("general")
config.has() rooms.has("general")
config.delete() rooms.delete("general")
config.reset() rooms.reset("general")
config.init() rooms.init("general")
config.isInitialized() rooms.isInitialized("general")
config.subscriberCount() rooms.subscriberCount("general")| Method | Description |
| ------------------- | ---------------------------------------------- |
| update(fn) | Mutate via Immer draft — sends JSON Patch diff |
| set(data) | Replace entire state — sends full snapshot |
| get() | Read current state |
| has() | Check if store instance exists |
| delete() | Remove store, notify subscribers |
| reset() | onTerminate → raw defaults, uninitialized |
| init() | Eagerly create (runs onCreate) |
| isInitialized() | true after init/subscribe/mutation |
| subscriberCount() | Number of connected clients |
Lazy initialization
Stores are lazy — createStore() registers the definition, but onCreate only runs on first actual use (subscribe, mutation, or init()). This applies to both static and dynamic stores.
const config = server.createStore("Config", { theme: "dark" }).onCreate(() => {
const saved = loadFromDb();
return saved || { theme: "dark" };
});
config.isInitialized(); // false — onCreate hasn't run yet
config.init(); // triggers onCreate
config.isInitialized(); // trueLifecycle hooks
All hooks work on both static and dynamic stores. Chainable.
const rooms = server
.createStore("Room:*", { messages: [] })
.onCreate((key) => {
// Return overrides (merged with default data)
// Runs on first subscribe, mutation, init — NOT on reset
return { name: key };
})
.onTerminate((key) => {
// Called before reset() or delete() — save state, clean up
saveToDb(key, rooms.get(key));
})
.onIdle((key) => {
// Last subscriber left — store still exists
// Choose: reset, delete, or do nothing
rooms.reset(key);
});For static stores, hooks receive null as key:
const config = server
.createStore("Config", { theme: "dark" })
.onCreate(() => loadConfigFromDb())
.onTerminate(() => saveConfigToDb(config.get()));| Hook | Fires when | Store after |
| ------------- | ---------------------------------------- | --------------- |
| onCreate | First use (subscribe, mutation, or init) | Initialized |
| onTerminate | Before reset() or delete() | About to change |
| onIdle | Last subscriber leaves | Still alive |
init/subscribe/mutation → onCreate → [initialized]
↓
reset() → onTerminate → [uninitialized, raw defaults]
↓
next use → onCreate again
delete() → onTerminate → [gone]
last sub leaves → onIdle (you decide: reset/delete/nothing)Updating stores
Two ways to push state to clients:
| | update(fn) | set(data) |
| ----------- | -------------------- | -------------------- |
| Wire format | JSON Patch (diff) | Full snapshot |
| Best for | Editing a few fields | Replacing everything |
Mix them freely:
config.update((d) => {
d.theme = "light";
}); // patch
config.set({ theme: "dark", lang: "en" }); // full replace
config.update((d) => {
d.lang = "pt";
}); // patch againGlobal store accessors
server.stores["Config"]; // get handle by full name
server.hasStore("Room:general"); // check if a store instance exists
server.deleteStore("Config"); // delete by full name
server.listStores(); // ["Config", "Room:general", ...]server.handle(name, handler)
Register a function:
server.handle("todos.add", (args, { clientId }) => {
// args = whatever the client sent
// clientId = caller's ID
// return value is sent back to the client
// throw to send an error
// can be async
return { id: 1 };
});server.emit(channel, data, options?)
Send an event to clients.
server.emit("notification", { title: "Deployed" }); // all clients
server.emit("alert", { msg: "Done" }, { clientId: "c1" }); // specific clientserver.on(event, listener)
Server lifecycle events:
| Event | Args | Description |
| -------------- | ------------------ | ------------------------------ |
| connection | clientId | Client connected |
| disconnect | clientId | Client disconnected |
| subscribe | name, clientId | Client subscribed to a store |
| unsubscribe | name, clientId | Client unsubscribed |
| storeCreated | name, key | Dynamic store instance created |
| storeDeleted | name | Store deleted |
| error | error, clientId? | Error occurred |
server.close()
Shut down. Terminates all connections and cleans up.
Client
Hotstate(options?)
Create a client instance. Set up subscriptions and listeners, then connect.
import Hotstate from "hotstate/client";
const client = Hotstate({
reconnect: true, // auto-reconnect on disconnect
reconnectDelayMs: 1000, // initial retry delay
reconnectMaxMs: 10000, // max retry delay (exponential backoff)
rpcTimeoutMs: 10000, // function call timeout
});
// Set up subscriptions before connecting
client.subscribe("Todos", (state) => renderTodos(state.items));
// Then connect
client.connect("ws://localhost:4400");You can also pass the URL directly for quick setup:
const client = Hotstate("ws://localhost:4400"); // connects immediatelyclient.connect(url)
Connect to the server. Subscriptions registered before connecting will automatically sync once connected.
client.ready()
Resolves when the WebSocket connection is established.
Connection and readiness
await client.ready()only means the socket is open. It does not mean any store data has arrived yet.await handle.ready()means that specific subscription has received its first snapshot.- Subscriptions can be registered before
connect(). They will automatically subscribe once the socket connects. - Function calls only require
client.ready(). Store reads requirehandle.ready().
client.subscribe(name, onChange?)
Subscribe to a store. Returns a handle immediately:
const todos = client.subscribe("Todos", (state) => {
// fires on every state change (including initial snapshot)
console.log(state);
});
todos.getState(); // current state (undefined until first snapshot)
todos.getServerState(); // raw server state (before optimistic overlay)
todos.hasData(); // true after first snapshot arrives
await todos.ready(); // resolves when first snapshot arrives
todos.unsubscribe(); // stop receiving updatesIf the server cannot fulfill the subscription, handle.ready() rejects and the client also emits an error event.
Multiple subscribe() calls to the same store are supported. Each handle stays active independently until it is unsubscribed.
client.call(name, args)
Call a server function. Returns a promise:
const result = await client.call("todos.add", { text: "Buy milk" });
// result = { id: 1 }Throws if the server handler throws or the call times out.
Optimistic updates
Optimistic state can be applied on the client before the server responds:
const todos = client.subscribe("Todos");
await todos.ready();
await client.call(
"todos.add",
{ text: "New" },
{
store: "Todos",
optimistic: (state) => ({
...state,
items: [...state.items, { id: "temp", text: "New", done: false }],
}),
},
);getState()returns the optimistic view.getServerState()returns the last confirmed server snapshot.- If the RPC fails or times out, the optimistic overlay is rolled back automatically.
client.on(channel, handler)
Listen for server events. Returns an unsubscribe function:
const unsub = client.on("notification", (data) => {
console.log(data);
});
// later
unsub();Also emits lifecycle events: connected, disconnected, reconnecting, error, storeDeleted.
Errors
- Missing store subscription:
handle.ready()rejects andclient.on("error")fires. - RPC handler throws:
client.call()rejects with the server error message. - RPC timeout:
client.call()rejects withRPC timeout. - Disconnect during RPC: pending calls reject with
Disconnected. - Store deleted on the server: client emits
storeDeletedand local handles stop receiving updates.
Reconnection semantics
- With
reconnect: true, the client reconnects using exponential backoff betweenreconnectDelayMsandreconnectMaxMs. - Existing subscriptions are re-sent automatically after reconnect.
- If the client missed patches while disconnected, it re-subscribes and accepts a fresh snapshot.
- On reconnect, server state remains the source of truth. Any stale optimistic state is dropped.
client.destroy()
Disconnect and stop reconnecting.
Integrations
Server
hotstate attaches to any Node.js HTTP server. Use path to namespace the WebSocket endpoint when sharing a port with your API.
Express
import express from "express";
import { createServer } from "http";
import Hotstate from "hotstate";
const app = express();
const httpServer = createServer(app);
app.get("/api/health", (req, res) => res.json({ ok: true }));
const state = Hotstate();
state.createStore("Todos", { items: [] });
state.attach(httpServer, { path: "/ws" });
httpServer.listen(3000);
// API: http://localhost:3000/api/health
// WebSocket: ws://localhost:3000/wsFastify
import Fastify from "fastify";
import Hotstate from "hotstate";
const app = Fastify();
app.get("/api/health", async () => ({ ok: true }));
const state = Hotstate();
state.createStore("Todos", { items: [] });
await app.listen({ port: 3000 });
state.attach(app.server, { path: "/ws" });Hono (Node.js)
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import Hotstate from "hotstate";
const app = new Hono();
app.get("/api/health", (c) => c.json({ ok: true }));
const httpServer = serve({ fetch: app.fetch, port: 3000 });
const state = Hotstate();
state.createStore("Todos", { items: [] });
state.attach(httpServer, { path: "/ws" });Standalone
import Hotstate from "hotstate";
const server = Hotstate();
server.createStore("Todos", { items: [] });
server.listen({ port: 4400 });Client
Zustand
Bind all store state into a single Zustand store:
import { create } from "zustand";
import Hotstate from "hotstate/client";
const useStore = create(() => ({
status: "disconnected",
stores: {},
}));
const client = Hotstate();
client.bindZustand(useStore);
client.subscribe("Todos");
client.subscribe("Config");
client.connect("ws://localhost:3000/ws");Use in React:
function TodoList() {
const todos = useStore((s) => s.stores["Todos"]);
const status = useStore((s) => s.status);
if (!todos) return <div>Loading...</div>;
return (
<ul>
{todos.items.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
);
}Optimistic updates:
await client.call(
"todos.add",
{ text: "New" },
{
store: "Todos",
optimistic: (state) => ({
...state,
items: [...state.items, { id: -1, text: "New", done: false }],
}),
},
);Wire protocol
Binary MessagePack with single-letter keys for minimal overhead.
| Type | Direction | Description |
| ------- | --------------- | -------------------- |
| sub | Client → Server | Subscribe to a store |
| unsub | Client → Server | Unsubscribe |
| rpc | Client → Server | Function call |
| snap | Server → Client | Full state snapshot |
| patch | Server → Client | JSON Patch delta |
| res | Server → Client | Function response |
| del | Server → Client | Store deleted |
| evt | Server → Client | Event |
Install
npm install hotstateLicense
MIT
