voxal docs
Guides

Share state across connections

Understand the one-instance-serves-everyone model and keep per-connection and shared state straight

The single most important thing to understand about voxal is this: one app instance serves every connected user. When five people ssh into your app, they are all running inside the same sandbox, multiplexed by connection. This guide shows how to keep per-connection state separate and how to share state across everyone on purpose.

The mental model

Your app boots once. Each SSH session becomes a connection with its own conn.id (a unique string). The same handlers and the same module-level variables are shared by all of those connections. So there are two kinds of state:

  • Per-connection state belongs to one user (their cursor, their draft, their view).
  • Shared state is visible to everyone (a chat room, a presence list, a global counter).

Mixing these up is the most common bug in a multiplayer terminal app. Below is how each layer handles them.

Core SDK: key state by conn.id

In the core SDK, hold per-connection state in a Map keyed by conn.id, and clean it up in onClose so it does not leak as users come and go.

app.js
import { createApp } from '@voxalsh/sdk';

const sessions = new Map(); // conn.id -> per-user state

createApp()
  .onConnect((conn) => {
    sessions.set(conn.id, { count: 0 });
    conn.write('your count is 0. press space to add.\r\n');
  })
  .onKey((conn, data) => {
    const state = sessions.get(conn.id);
    if (data === ' ') {
      state.count += 1;
      conn.write('your count is ' + state.count + '\r\n');
    }
  })
  .onClose((conn) => {
    sessions.delete(conn.id); // always clean up
  })
  .listen();

If you store count in a single module-level variable instead of a per-id map, every user shares one counter and they will stomp on each other. Use the map for anything that should be private.

Sharing state across connections

To make state shared, hoist it out of the per-id map into a plain module-level variable. To keep everyone in sync, hold the set of live connections and write to each one when the shared state changes.

app.js
import { createApp } from '@voxalsh/sdk';

const conns = new Set();
let total = 0; // shared across everyone

function broadcast(line) {
  for (const c of conns) c.write(line);
}

createApp()
  .onConnect((conn) => {
    conns.add(conn);
    conn.write('shared total is ' + total + '. press space.\r\n');
  })
  .onKey((conn, data) => {
    if (data === ' ') {
      total += 1;
      broadcast('total is now ' + total + '\r\n');
    }
  })
  .onClose((conn) => {
    conns.delete(conn);
  })
  .listen();

UI layer: component state is per-connection

The UI layer makes this split natural. serve() mounts a separate React tree for every connection, so any useState lives with that one user. Module-level variables, declared outside the component, are still shared by everyone.

app.jsx
import React, { useState } from 'react';
import { serve, Box, Text, useInput } from '@voxalsh/sdk/ui';

// Shared: lives outside the component, one copy for all connections.
let visitors = 0;

function App({ id }) {
  // Per-connection: each user gets their own clicks.
  const [clicks, setClicks] = useState(0);

  useInput((input) => {
    if (input === ' ') setClicks((c) => c + 1);
  });

  return (
    <Box flexDirection="column" padding={1}>
      <Text>your clicks: <Text bold>{clicks}</Text></Text>
      <Text dimColor>connection {String(id).slice(0, 4)}</Text>
    </Box>
  );
}

serve((conn) => {
  visitors += 1; // shared counter bumps for every new connection
  return <App id={conn.id} />;
});

clicks is private to each user because it is component state in their own tree. visitors is shared because it is a module-level variable. For a live shared view (a chat room where one person's message appears on everyone's screen), keep the shared data at module level and trigger a re-render on the other connections, for example by holding their state setters and calling them when the shared data changes.

Module-level state resets when the app is dropped to zero (scale-to-zero) and re-boots on the next connection. Treat it as in-memory and ephemeral, not durable storage.

Next steps

On this page