Skip to content

@insession/space-state

A dependency-free state store for realtime shared rooms — members, chat, presence, typing indicators, pinned messages and pluggable per-room apps.

Most realtime state layers fuse three things that want to be separate: the reducer that folds inbound messages, the socket that carries them, and the side effects that fire when something happens (a sound, a notification, a timer). Fuse them and you can no longer test the interesting part — the state transitions — without standing up a server and a browser.

This store keeps them apart:

  • Inbound messages fold through a pure reducer. receive(msg) runs reduceSpace, which is a plain function of (state, msg, ctx). No I/O.
  • Outbound messages are handed off, not sent. Local actions such as chat.send() produce a message and pass it to whatever you registered with onSend. The store never opens a socket.
  • Side effects are returned as descriptors, never executed. “Play the chat sound”, “show a notification”, “clear this typing indicator in 3s” arrive at your onEffect handler as data. What that means in your app is your call.
  • Zero runtime dependencies. No React, no WebSocket, no DOM. Tests run under node --test with no server, no browser and no sockets.
Terminal window
npm install @insession/space-state

Published as a built ESM package (dist/index.js + dist/index.d.ts), no runtime dependencies. To bind it to React, add @insession/space-state-react.

import { createSpaceStore } from '@insession/space-state';
const store = createSpaceStore({
selfName: 'alice',
t: (key) => key, // any string resolver; pass your i18n `t`, or identity in tests
getPresence: () => 'active',
});
// Outbound: wire local actions to your transport. Actually sending is your job.
store.onSend((msg) => ws.send(JSON.stringify(msg)));
// Effects: the store describes them, you execute them.
store.onEffect((effect) => {
if (effect.type === 'sound' && effect.sound === 'chat') playChatSound();
if (effect.type === 'notify-chat') notify(`${effect.name}: ${effect.text}`);
});
// Inbound: feed raw server messages in. The reducer folds them and notifies subscribers.
ws.onmessage = (ev) => store.receive(JSON.parse(ev.data));
// Read state / subscribe to changes (useSyncExternalStore contract:
// getState() returns the same reference while nothing has changed).
store.getState().members;
const unsubscribe = store.subscribe(() => render());
// Local actions: send to the server, and optimistically reflect locally where it matters.
store.chat.send('hello');
store.chat.react(messageId, '🎉');
store.presence.change('away');
store.settings.update({ theme: 'dark' });

Because receive takes a plain object and effects are only descriptors, a full state transition is assertable in-process:

const store = createSpaceStore({ selfName: 'alice', t: (k) => k, getPresence: () => 'active' });
const effects = [];
store.onEffect((e) => effects.push(e));
store.receive({ type: 'chat', name: 'bob', text: 'hi' });
store.getState().chatLines.at(-1).text;
// 'hi'
effects;
// [{ type: 'typing-timer-clear', name: 'bob' },
// { type: 'sound', sound: 'chat' },
// { type: 'notify-chat', name: 'bob', text: 'hi' }]
OptionDefaultMeaning
selfNameThe local user’s display name. Used to mark messages as your own. Replaceable later via setSelfName.
tString resolver (key, ...args) => string for system chat lines. Pass your i18n t, or the identity function. Replaceable later via setT.
getPresence() => 'active' | 'away'. Read whenever the reducer needs your current presence.
nowDate.nowClock. Inject to make tests deterministic.
genClientMsgIdcrypto.randomUUID with fallbackGenerates the temporary id that ties a locally echoed chat line to the server’s eventual id.
plugins[]Per-room app clients (see Plugins). The core knows nothing app-specific on its own.
initialSettings{}Default value for state.settings. The store never reads inside settings — the shape belongs to your wire contract, so you inject the defaults.
MemberMeaning
receive(msg)Fold a raw inbound message. Updates state and dispatches effects.
getState() / subscribe(fn)useSyncExternalStore-compatible pair. getState() returns the same reference while state is unchanged. subscribe returns an unsubscribe function.
onSend(fn) / send(msg)Register a transport / push a raw outbound message. Returns an unsubscribe function.
onEffect(fn)Register an effect executor. Returns an unsubscribe function.
chat.send(text, replyTo?)Send a chat message and echo it locally right away (no round-trip wait).
chat.sendSticker(imageUrl)Send an image message; imageUrl is a URL you uploaded beforehand.
chat.react(messageId, emoji)Toggle an emoji reaction, optimistically applied locally. No-op when messageId is null (the message has no server id yet).
chat.pin(messageId)Pin a message, or unpin with null. The server stays authoritative.
chat.typing()Announce typing. Safe to call on every keystroke — calls within 1s are throttled away.
settings.update(patch)Send a settings patch. The store does not interpret its contents.
presence.change(p)Send 'active' / 'away'.
stage.change(stage)Send which card the local user is currently showing (null for none).
addChatLine(line)Append a local system line.
clearTyping(name) / expireAgentStatus(id, requestId)Called by you when the corresponding typing-timer / agent-timer effect fires.
reset()Reset connection-scoped state on disconnect.
setT(fn) / setSelfName(name)Swap the resolver / display name without reconnecting.

onEffect receives a SpaceEffect — a discriminated union on type:

typePayloadWhat it asks you to do
soundsound: 'join' | 'chat'Play a sound.
notify-join / notify-chatname, and text for chatShow a notification. Wording and mention detection are yours.
plugin-sound / plugin-notifyappId, sound / textSame, but originating from a plugin. Mapping appId to an actual sound is yours.
history-titletitleUpdate your local visit history.
sendmessageSend this message (reducer-initiated, e.g. re-announcing presence).
typing-timer / typing-timer-clearnameStart a 3s timer that calls clearTyping(name) / cancel it.
agent-timer / agent-timer-clearagentId, requestIdStart a safety timer that calls expireAgentStatus(...) / cancel it.

A room can host apps. The core carries no app-specific logic; each app supplies a PluginClient and the reducer calls it only for its own appId:

import { definePluginClient } from '@insession/space-state';
const timer = definePluginClient({
id: 'timer',
// Seed this plugin's local slice (state.pluginLocal['timer']) on join/reconnect.
// Record previous values here only — deciding and emitting effects on join
// makes them fire every time someone enters the room.
initLocal: (appState) => ({ phase: appState?.phase ?? null }),
// Called on each app-state message for this id. The core has already stored
// the latest value in state.apps[id]; return only your local slice, chat
// lines to append, and effects to emit.
onAppState: ({ local, msg }) =>
local.phase === msg.phase
? {}
: { local: { phase: msg.phase }, effects: [{ type: 'plugin-sound', appId: 'timer', sound: 'ding' }] },
});
createSpaceStore({ /* … */ plugins: [timer] });

state.settings is deliberately opaque (Record<string, any>). The store holds and replaces it wholesale and never looks inside, so the settings type — and its defaults, via initialSettings — stay part of your wire contract rather than this package’s. This is the same reasoning that keeps the store free of a server and of persistence.

Terminal window
node --test

The reducer tests assert state transitions directly. No server, no browser, no sockets, no wall-clock waits.

Extracted from InSession’s realtime rooms, where it backs synchronized watch parties, shared timers and in-room chat. Generalizing it meant removing the product’s wire-contract types (settings became opaque), moving side effects out of the reducer into descriptors, and pushing all app-specific behaviour behind the plugin contract.

MIT