# Build a chat app

> Build a complete React chat TUI with a scrolling transcript, a live input box, a fetch command, and deploy it

In this guide you will build a complete chat app as a terminal UI: a transcript that scrolls into history, a live input box you can type into, a `/fact` command that calls an external API, and a `/quit` command to exit. By the end you will have a runnable `app.jsx` and the one command to ship it.

## What you will build

A single screen with two parts. The top is the transcript, rendered with `<Static>` so each message is printed once and scrolls naturally into the terminal's scrollback. The bottom is a bordered input box, redrawn on every keystroke, showing your draft and a block cursor.

## Set up the messages and draft

Start with `serve()` and two pieces of per-connection state: the list of `messages` and the current `draft`. A small `push` helper appends a message.

```jsx title="app.jsx"

function Chat() {
  const [messages, setMessages] = useState([
    { id: 0, role: 'bot', text: 'hi! type a message and press enter. /fact for a cat fact, /quit to leave.' },
  ]);
  const [draft, setDraft] = useState('');

  const push = (role, text) =>
    setMessages((m) => [...m, { id: m.length, role, text }]);

  // ...
}
```

## Edit the draft and submit on Enter

`useInput` drives the input box. `key.return` submits, `key.backspace` (or `key.delete`) erases the last character, and any plain typed character is appended. Guard against modifier combinations with `!key.ctrl && !key.meta`.

```jsx title="app.jsx"
useInput((input, key) => {
  if (key.return) {
    const value = draft;
    setDraft('');
    submit(value);
  } else if (key.backspace || key.delete) {
    setDraft((d) => d.slice(0, -1));
  } else if (input && !key.ctrl && !key.meta) {
    setDraft((d) => d + input);
  }
});
```

## Add commands and a fetch

`submit` decides what to do with a line. Plain text is echoed back. `/quit` calls `exit()` from `useApp()`. `/fact` sets a `busy` flag, calls `fetch` for a cat fact, and clears the flag in a `finally` so it resets even on error.

```jsx title="app.jsx"
const { exit } = useApp();
const [busy, setBusy] = useState(false);

const submit = async (value) => {
  const text = value.trim();
  if (!text) return;
  push('you', text);
  if (text === '/quit') return exit();
  if (text === '/fact') {
    setBusy(true);
    try {
      const res = await fetch('https://catfact.ninja/fact');
      push('bot', '🐱 ' + JSON.parse(res.body).fact);
    } catch (err) {
      push('bot', '(fetch failed: ' + err.message + ')');
    } finally {
      setBusy(false);
    }
    return;
  }
  push('bot', 'you said: ' + text);
};
```

<Callout>
`<Static>` renders its items once and never repaints them, which is exactly what you want for a chat transcript or a log. New messages append below; old ones stay put in scrollback. Only the input box below it is redrawn each keystroke.
</Callout>

## The full app

Here is the complete `app.jsx`, ready to run.

```jsx title="app.jsx"

function Message({ role, text }) {
  const isYou = role === 'you';
  return (
    <Box>
      <Text color={isYou ? 'cyan' : 'green'} bold>
        {isYou ? 'you' : 'bot'}
      </Text>
      <Text>{'  '}</Text>
      <Text>{text}</Text>
    </Box>
  );
}

function Chat({ id }) {
  const [messages, setMessages] = useState([
    { id: 0, role: 'bot', text: 'hi! type a message and press enter. /fact for a cat fact, /quit to leave.' },
  ]);
  const [draft, setDraft] = useState('');
  const [busy, setBusy] = useState(false);
  const { exit } = useApp();

  const push = (role, text) =>
    setMessages((m) => [...m, { id: m.length, role, text }]);

  const submit = async (value) => {
    const text = value.trim();
    if (!text) return;
    push('you', text);
    if (text === '/quit') return exit();
    if (text === '/fact') {
      setBusy(true);
      try {
        const res = await fetch('https://catfact.ninja/fact');
        push('bot', '🐱 ' + JSON.parse(res.body).fact);
      } catch (err) {
        push('bot', '(fetch failed: ' + err.message + ')');
      } finally {
        setBusy(false);
      }
      return;
    }
    push('bot', 'you said: ' + text);
  };

  useInput((input, key) => {
    if (key.return) {
      const value = draft;
      setDraft('');
      submit(value);
    } else if (key.backspace || key.delete) {
      setDraft((d) => d.slice(0, -1));
    } else if (input && !key.ctrl && !key.meta) {
      setDraft((d) => d + input);
    }
  });

  return (
    <>
      {/* Transcript: each message printed once, scrolls into terminal history. */}
      <Static items={messages}>
        {(m) => <Message key={m.id} role={m.role} text={m.text} />}
      </Static>

      {/* Live input box, redrawn every keystroke. */}
      <Box borderStyle="round" borderColor="gray" paddingX={1}>
        <Text color="cyan">{'❯ '}</Text>
        <Text>{draft}</Text>
        <Text inverse> </Text>
        <Spacer />
        <Text dimColor>{busy ? '…thinking' : `#${String(id).slice(0, 4)}`}</Text>
      </Box>
    </>
  );
}

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

## Run and deploy

Preview locally with `voxal dev`, then ship it:

```bash
voxal deploy
```

Anyone can now reach your chat with:

```bash
ssh <name>@voxal.sh
```

## Next steps

<Cards>
  <Card title="Make it multiplayer" href="/guides/sharing-state" description="Share the transcript across every connection" />
  <Card title="Fetch data from an API" href="/guides/fetching-data" description="More on the host-bridged fetch and its limits" />
  <Card title="Deploy reference" href="/cli/deploy" description="Everything voxal deploy does" />
</Cards>

---

Source: https://docs.voxal.sh/guides/chat-app
