voxal docs
SDK · CoreSDK · UI (React)

serve and render

Mount a React tree on each SSH connection with serve, or take manual control with render

The UI layer has two entry points. serve is the normal one and wires up the whole app. render is lower level and hands you a single mounted instance you drive yourself. Both take the same options.

serve

serve(factory: (conn) => ReactElement, options?: RenderOptions): App

serve is the entry point most apps use. factory is called once per SSH connection and returns the React element for that session. Each connection gets its own React tree and is isolated from the others. serve wires keyboard input, resize, and close for you, and closes the connection when the app exits (for example when a component calls useApp().exit()). It returns the running app.

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

function App({ id }) {
  return (
    <Box borderStyle="round" padding={1}>
      <Text>hello, your connection id is <Text bold>{id}</Text></Text>
    </Box>
  );
}

serve((conn) => <App id={conn.id} />);

Pass the connection through factory when a component needs its id or initial size. For live size that updates on resize, use useWindowSize inside the tree instead.

Fullscreen

app.jsx
serve(() => <App />, { fullscreen: true });

Fullscreen uses the terminal's alternate screen and a cell diff renderer, so your UI owns the whole viewport and the user's scrollback is restored on exit. Without it, the UI renders inline and grows the terminal as needed.

render

render(node: ReactElement, conn, options?: RenderOptions): Instance

render mounts one element on one connection and returns an instance handle. Unlike serve, it does not wire input or resize. You forward those yourself, which is what you want when you are embedding a UI inside an app that already manages its own connection lifecycle with the core SDK.

app.jsx
import { createApp } from '@voxalsh/sdk';
import { render } from '@voxalsh/sdk/ui';
import { App } from './App.jsx';

const instances = new Map();

createApp()
  .onConnect((conn) => instances.set(conn.id, render(<App />, conn)))
  .onKey((conn, data) => instances.get(conn.id)?.handleData(data))
  .onResize((conn) => instances.get(conn.id)?.resize())
  .onClose((conn) => {
    instances.get(conn.id)?.unmount();
    instances.delete(conn.id);
  })
  .listen();

The instance handle

render returns an object with these methods.

MethodDescription
rerender(node)Re-mount the tree with a new root element.
unmount(error?)Tear down the tree. Pass an error to reject waitUntilExit.
clear()Clear the rendered output from the terminal.
waitUntilExit()Returns a promise that resolves when the app exits, or rejects with the unmount error.
handleData(data)Feed a raw input string into the tree (this is what drives useInput).
resize()Re-read the connection size and re-render. Call it from onResize.

Most apps should use serve, which calls all of this for you.

RenderOptions

The options object is shared by serve and render.

NameTypeDefaultDescription
fullscreenbooleanfalseUse the alternate screen and a cell diff renderer. alternateScreen is an alias.
exitOnCtrlCbooleantrueExit the app when the user presses Ctrl-C. While on, Ctrl-C is not delivered to your useInput handler.
colors'truecolor' | '256' | '16' | 'none''truecolor'Downsample color output for the client terminal. See Styling.
mouseboolean | 'all'falseEnable mouse events. Only honored in fullscreen mode.

Tip

Each connection runs its own renderer, but they share one app isolate. Component state is per connection; module-level variables are shared across all of them. See Sharing state.

On this page