---
title: "Type into the app"
audience: "anyone whose terminal tool takes typed text"
description: "You'll add tasks from a text box and show a spinner while the list waits to be saved. Both come from npm packages FSR provides, so there is nothing to install."
snippet_caption: "app/routes/page.tsx"
snippet: "import Spinner from \"ink-spinner\";\nimport TextInput from \"ink-text-input\";\n\n<TextInput value={draft} onChange={setDraft} onSubmit={add} placeholder=\"what needs doing\" />"
---

import { Install } from "@src/ui/Install";

You'll add tasks from a text box and show a spinner while the list waits to be saved. Both come from npm packages FSR provides, so there is nothing to install. It starts from the app you built in [630](https://snapfirers.com/fsr/learn/630-save-to-a-file).

<Install captured="0.x" />

## The packages FSR provides

A terminal page does not follow npm packages. A few that nearly every Ink tool uses are provided instead, written in TypeScript the way the packages are written, so you import them by their usual names:

| Import | What you get |
| --- | --- |
| `ink-text-input` | `TextInput` and `UncontrolledTextInput` |
| `ink-spinner` | `Spinner`, with the spinners of `cli-spinners` |
| `@snapfire/fsr/terminal` | `ms`, `stripAnsi`, `stringWidth` and `wrapAnsi` |

`TextInput` reads keys with `useInput` like any other component and `Spinner` steps on a timer like any other component. Another package from npm is refused.

## Add the imports and the state

```tsx title="app/routes/page.tsx" {1,4-6}
import { useEffect, useState } from "react";
import type { RootProps } from "@generated/client";
import { file, path } from "@snapfire/fsr/host";
import { Box, Spacer, Text, useInput } from "ink";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";
```

`Newline` is no longer used, since the footer becomes a row. Inside `Tasks`, below `selected`, add three pieces of state:

```tsx title="app/routes/page.tsx"
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
```

`adding` says whether the text box is open, `draft` is what is typed into it and `saving` says a change has not been written yet.

## Save half a second after a change

In 630 every change wrote the file at once. This step waits until the changes stop and writes 500 ms after the last one, which is what the spinner shows. Below the state, add the effect that does it:

```tsx title="app/routes/page.tsx"
useEffect(() => {
  if (!saving) return;
  const timer = setTimeout(() => {
    save(where, tasks);
    setSaving(false);
  }, 500);
  return () => clearTimeout(timer);
}, [tasks]);
```

The effect runs after each render that changed `tasks`. Its cleanup clears the timer before the next run, so a second change inside the half second starts the wait again. On the first render `saving` is false, so opening the app writes nothing.

## Open the text box

Change the handler. While the box is open, the list's keys stay quiet and Esc closes it. `a` opens it. Space now marks the list as needing a save instead of writing at once:

```tsx title="app/routes/page.tsx"
useInput((input, key) => {
  if (adding) {
    if (key.escape) setAdding(false);
    return;
  }
  if (key.upArrow && selected > 0) setSelected(selected - 1);
  if (key.downArrow && selected < shown.length - 1) setSelected(selected + 1);
  if (input === "a") setAdding(true);
  if (input === " ") {
    setTasks(tasks.map((task, index) => (index === selected ? { ...task, done: !task.done } : task)));
    setSaving(true);
  }
});

const add = (title: string) => {
  setAdding(false);
  setDraft("");
  if (title.trim() === "") return;
  setTasks([...tasks, { title: title.trim(), done: false }]);
  setSaving(true);
};
```

`TextInput` handles the typing itself: characters, backspace and the cursor. `onChange` hands you the new text and `onSubmit` runs on Enter with it.

## Draw the box and the spinner

Replace the footer `Text` at the bottom of the outer `Box` with the box and a footer row:

```tsx title="app/routes/page.tsx"
{adding ? (
  <Box>
    <Text color="cyan">new: </Text>
    <TextInput value={draft} onChange={setDraft} onSubmit={add} placeholder="what needs doing" />
  </Box>
) : null}
<Box>
  <Text dimColor>{adding ? "Enter adds, Esc cancels" : "a adds, space marks one done"}</Text>
  <Spacer />
  {saving ? (
    <Text color="yellow">
      <Spinner type="dots" /> saving
    </Text>
  ) : (
    <Text dimColor>saved</Text>
  )}
</Box>
```

`type` picks a spinner from `cli-spinners` by name, `dots` or `line` for example. The spinner steps at the interval `cli-spinners` gives it.

## Run it

Start with no saved list:

```shell-session
$ rm -rf ~/.tasks
$ fsr dev app --terminal
```

Press `a`. The box opens with its placeholder:

```text
╭──────────────────────────────────────────╮
│ Tasks tasks.json                  1 open │
│                                          │
│ > Write the first task                   │
│                                          │
│ new: what needs doing                    │
│ Enter adds, Esc cancels            saved │
╰──────────────────────────────────────────╯
```

Type `Ship it`. The space goes into the text and does not mark a task done, because the list's handler returns while the box is open. Press Enter. The task is added and the spinner runs:

```text
╭──────────────────────────────────────────╮
│ Tasks tasks.json                  2 open │
│                                          │
│ > Write the first task                   │
│   Ship it                                │
│                                          │
│ a adds, space marks one done    ⠋ saving │
╰──────────────────────────────────────────╯
```

Half a second later it reads `saved` and the file has both tasks:

```shell-session
$ cat ~/.tasks/tasks.json
[
  {
    "title": "Write the first task",
    "done": false
  },
  {
    "title": "Ship it",
    "done": false
  }
]
```

Next up: [650. Move focus between panes](https://snapfirers.com/fsr/learn/650-move-focus-between-panes).
