Using Ink UI with React to build interactive, custom CLIs
Tags: react, webdev
Published: 2023-09-08 13:50:46

**Written by [Georgey V B](https://blog.logrocket.com/author/georgeyvb/)✏️**

CLIs are fantastic! With just one command from your terminal, you can execute several scripts to automate the majority of the repetitive procedures. 

However, the dull terminal fonts provided by most CLIs can make for a pretty boring user experience. It would be far better if you could make your CLI more custom and interactive. 

The good news is that thanks to Ink, you can create CLIs that leverage the power of React! Bringing React’s capabilities to the world of CLIs allows you to create an interactive UI with reusable components. 

This article will explore the power of Ink and its accompanying collection of UI components, Ink UI, through a demo project where we’ll create a basic to-do CLI tool. We will cover:

*   [What is Ink, and what’s Ink UI?](#what-ink-ink-ui)
*   [Setting up an Ink project](#setting-up-ink-project)
*   [Installing the Ink UI package](#installing-ink-ui-package)
*   [Creating reusable components using Ink, Ink UI, and React](#creating-reusable-components-ink-ink-ui-react)
*   [Setting up the logic for creating and completing tasks](#setting-up-logic-creating-completing-tasks)
*   [Displaying our task list in our CLI](#displaying-task-list-cli)

You can refer to [the project’s GitHub repo](https://github.com/GeoBrodas/ink-cli) to follow along with this tutorial

## What is Ink, and what’s Ink UI? <a name="what-ink-ink-ui">

Ink is a framework that provides the same user experience seen in React applications, but for a CLI. It uses the power of React to build component-based command-line applications, making it a great way to build interactive command-line tools. 

Ink leverages Yoga, a layout engine that helps build Flexbox layouts into the application. It also goes hand-in-hand with Ink UI, a library comparable to Ant UI, Next UI, and Chakra UI for React applications that provides a variety of useful components. 

Using Ink and Ink UI together, you can utilize components such as text inputs, alerts, lists, and more to customize your CLI.

## Setting up an Ink project <a name="setting-up-ink-project">

The best way to learn about any new technology is to build something with it. By the end of this tutorial, you'll be able to build an interactive to-do CLI application using Ink and Ink UI. 

Let’s start off by first installing Ink. Fortunately, Ink has provided `create-ink-app` as an easy way to do so. Go ahead and install Ink using the command below:

```bash
npx create-ink-app --typescript todo-cli
```

Here’s the file structure you can expect to see after installing: ![Visual Studio Code Open To Show Expected File Structure After Installing Ink](https://blog.logrocket.com/wp-content/uploads/2023/08/img1-Expected-file-structure-installing-Ink.png) 

The `source` folder, which contains the `app.tsx` file, is the only folder you'll work with while developing. 

You can define many flags or variables to use when typing the command in the terminal using the `cli.tsx` file. For instance, build the application once using the `npm run build` command and then type the following in the terminal where your project is located to use the CLI immediately:

```plaintext
todo-cli name=Elon
```

You’ll be welcomed with a message reading `Hello Elon`. Defining this `name` flag allows you to use it to invoke any customized action in the CLI tool.

## Installing the Ink UI package <a name="installing-ink-ui-package">

Next up, let’s start building our CLI tool by installing the Ink UI package:

```bash
npm install @inkjs/ui
```

With that done, let’s just see if everything's working as expected. In `app.tsx`, replace the component getting returned with an Ink UI component:

```typescript
import { Select } from "@inkjs/ui";

type Props = {
    name: string | undefined;
};

export default function App({name = 'Stranger'}: Props) {
  return (
    <Select
      options={[
      {label: 'Help', value: 'help'},
      {label: 'Create Task', value: 'create-task'},
      {label: 'Delete Task', value: 'delete-task'},
      {label: 'Complete Task', value: 'complete-task'},
      {label: 'View tasks', value: 'view-task'},
      {label: 'Exit', value: 'exit'},
     ]}
    onChange={value => {
      console.log(value);
    }}
  />
  )
}
```

The code above establishes the basic layout of the CLI tool. The user will be able to create, delete, and complete tasks by interacting with the `Select` component after we build the tool. Let’s run the CLI to see what we have so far. 

Note that instead of running `npm run build` each time to build the CLI tool, we’ll run the `npm run dev` command. This will open up an instance that continuously checks for changes in the codebase and rebuilds instantly with the latest version of the CLI tool. 

Go ahead and run the CLI: ![Simple Ui Rendered After Running Default Ink Cli Showing Simple List Of To Do Options](https://blog.logrocket.com/wp-content/uploads/2023/08/img2-Running-CLI.png) Once that’s done, let's see how we can create reusable components by utilizing React's features.

## Creating reusable components using Ink, Ink UI, and React <a name="creating-reusable-components-ink-ink-ui-react">

Create a new folder under `source` and give it the name `components`. 

Then, create a new component called `ContainerElement.tsx`. We'll also use a few elements from the built-in Ink library here:

```typescript
import {Box, Text} from 'ink';
import BigText from 'ink-big-text';
import Gradient from 'ink-gradient';
import React from 'react';

function ContainerElement({children}: {children: any}) {
    return (
        <Box flexDirection="row" gap={5}>
            <Box flexDirection="column" width={'70%'}>
                <Gradient name="fruit">
                    <BigText text="To-Do Bro" />
                </Gradient>
                <Text>
                    Lorem ipsum, dolor sit amet consectetur adipisicing elit. In....
                </Text>
            </Box>
            <Box justifyContent="center" flexDirection="column">
                {children}
            </Box>
        </Box>
    );
}

export default ContainerElement;
```

The `Box` component is quite useful when creating the CLI application layout, as the same [Flexbox rules apply as in web development](https://blog.logrocket.com/how-to-build-a-basic-flexbox-layout-a-tutorial-with-examples/). 

Meanwhile, we’re providing many options for the user to navigate in our CLI tool. We can reuse our newly created `ContainerElement` component to help contain and organize all of them. Let’s start using it now by wrapping it around the `Select` component we started with:

```typescript
<ContainerElement>
  <Select
    options={[
      {label: 'Help', value: 'help'},
      {label: 'Create Task', value: 'create-task'},
      {label: 'Delete Task', value: 'delete-task'},
      {label: 'Complete Task', value: 'complete-task'},
      {label: 'View tasks', value: 'view-task'},
      {label: 'Exit', value: 'exit'},
    ]}
    onChange={value => {
      console.log(value);
    }}
  />
</ContainerElement>
```

You should now be able to see the following upon running the CLI: ![Custom Cli Shown After Applying New Custom Reusable Containerelement Component Showing New Styled To Do List Title, Lorem Ipsum Dummy Text, And List Of Options To Create, Delete, Complete, Or View Tasks Rendered At Right Side](https://blog.logrocket.com/wp-content/uploads/2023/08/img3-CLI-new-ContainerElement.png) Next, we need to decide exactly how we will use the CLI program to perform the various actions available, such as adding a new task, deleting an existing task, and more. 

Fortunately, [React’s `useState` Hook](https://blog.logrocket.com/guide-usestate-react/) can help us monitor the status of the CLI application at any given time. Every time the state changes, the entire application is re-rendered to reflect those changes, giving users a fresh experience. 

Add the following to the `Select` component:

```typescript
<Select
  options={[
    {label: 'Help', value: 'help'},
    {label: 'Create Task', value: 'create-task'},
    {label: 'Delete Task', value: 'delete-task'},
    {label: 'Complete Task', value: 'complete-task'},
    {label: 'View tasks', value: 'view-task'},
    {label: 'Exit', value: 'exit'},
  ]}
  onChange={value => {
    setFeature(value);
    if (value === 'exit') process.exit();
  }}
/>
```

Next, we’ll use the `useState` Hook to set which of the `options` above is currently selected rather than logging the selected value to the console:

```typescript
const [feature, setfeature] = useState("");
```

Now, whenever the user selects an option, the state is set to that particular option's `value` property, which is defined inside the `options` array. If the user selects `Exit`, then we exit the current CLI instance.

## Setting up the logic for creating and completing tasks <a name="setting-up-logic-creating-completing-tasks">

Let’s set up the basic logic for creating a task. Here, we can make use of the [filesystem `fs` module from Node.js](https://blog.logrocket.com/file-processing-node-js-comprehensive-guide/) to create local files to read and write the task onto it. 

Note that if we were working with a conventional React application, we wouldn’t be able to use the `fs` module — it only works on the server side, not on the client side. But in this case, since we are using it inside the terminal, it’s not an issue. 

Inside the `app.tsx` file, add an `if...else` condition like so:

```typescript
if (feature === "create-task") {
  // render Create task section
} else {
  // render the home screen
}
```

In the `Create Task` section, we’ll simply render a `TextInput` component for the user to enter their task information:

```typescript
import fs from "fs";

// inside the App component
if (feature === 'create-task') {
  return (
    <ContainerElement>
      <TextInput
        placeholder="Enter your task"
        onSubmit={text=> {
          fs.readFile('task.txt', (err, data) => {
            // is no file exists, create a new file
            if (err || data.length === 0) {
              fs.writeFile('task.txt', text, err => {
                if (err) throw err;
              });
            }

            // append tasks to existing file
            fs.appendFile('task.txt', `\n${text}`, err => {
              if (err) throw err;
            });
          });

          // goes back to home screen
          setFeature('');
          }}
      />
    </ContainerElement>
  );
}
```

Let’s review what happens when the user enters a new task:

*   First, `fs.readFile()` tries to check if there’s any file with the file name `task.txt`
*   If there’s no such file available, or if there is no content inside the file, then the `fs.writeFile()` is invoked, which writes onto the file and creates the task
*   If there’s a file present with content in it, then `fs.appendFile()` will append the user-entered text onto the file. Note that we are adding the newline character `\n` so that the next task starts from a new line
*   Finally, the `feature` state is set to an empty string, hence switching over to the startup screen of the CLI application

Next, let’s set up the logic for completing a task. We could simply remove that particular task from the existing list of tasks. But to do so, we have to fetch all the current tasks first. We’ll make use of the `fs` module again for this:

```typescript
const [readData, setReadData] = useState([]);

// if-else statements
else if (feature === 'complete-task') {
  fs.readFile('task.txt', 'utf-8', (err, data) => {
    if (err) throw err;
    let split = data.split('\n');
    setReadData(split);
  });

// to be continued...
```

Since the data inside the text file is separated by the newline character, we can use the `split` method to get all the tasks in the form of an array, which can be set as a state. Then, we’ll display these tasks using the `Select` component:

```typescript
<ContainerElement>
  <Select
    options={readData?.map((item: any, index: number) => ({
      label: item,
      value: index.toString(),
      }))}
    onChange={value => {
      let data = readData.filter((item: any, index) => index !== +value);
      if (data) {
        fs.writeFile('task.txt', data.join('\n'), err => {
          if (err) throw err;
        });
      setFeature('');
      }
    }}
  />
</ContainerElement> 
```

The `map` method is used here to pass the options to the `Select` component. Upon selecting a particular task to mark as completed, the `readData` state is filtered through to return only the elements excluding that task. 

Finally, using the `file-system` module, the new list of current tasks is written onto the same `task.txt` file, and the `feature` state is set to an empty string. With this done, you should be now able to complete tasks by removing them from the task list file. 

Alternatively, we could also mark tasks as complete by shifting them to a separate file that exclusively contains completed tasks. Later, these completed tasks can also be fetched in the CLI to be displayed. 

Here’s how our custom CLI should look so far: ![Custom Cli With New Tasks Added](https://blog.logrocket.com/wp-content/uploads/2023/08/img4-CLI-new-tasks-added.png)

## Displaying our task list in our CLI <a name="displaying-task-list-cli">

Let’s work on our `view-task` feature. We want to display these tasks in an ordered list and give the user the option to return to the main menu, so we’ll be using two Ink UI components called `OrderedList` and `ConfirmInput`. 

We’ll start by getting all the current tasks from the `task.txt` file:

```typescript
else if (feature === 'view-task') {
  fs.readFile('task.txt', 'utf-8', (err, data) => {
    if (err) throw err;
    let split = data.split('\n');
    setReadData(split);
  });
}
```

Next, using the `OrderedList` component, we’ll display our list of tasks in a numbered list. They will show up in the order in which we created them. Go ahead and create a new file in the `components` folder with the following code:

```typescript
import {OrderedList} from '@inkjs/ui';
import {Text} from 'ink';
import React from 'react';

interface Props {
    items: Array<string>;
}

function DisplayItems({items}: Props) {
    return (
        <OrderedList>
            {items.map((item, index) => (
                <OrderedList.Item key={index}>
                    <Text>{item}</Text>
                </OrderedList.Item>
            ))}
        </OrderedList>
    );
}
export default DisplayItems;
```

This function simply displays the tasks in order using Ink UI’s `OrderedList` component. We’ll go back to the `app.tsx` file and import this component. Let’s also make sure the user has the option to return to the main page with the `ConfirmInput` component:

```typescript
return (
  <ContainerElement>
    <DisplayItems items={readData} />
    <Box marginTop={1} flexDirection="row" gap={2}>
      <Text>Go back?</Text>
      <ConfirmInput
        onCancel={() => setFeature('view-task')}
        onConfirm={() => setFeature('')}
      />
    </Box>
  </ContainerElement>
);
```

The `Box` and the `Text` components come with Ink, so make sure to import them in the file before using them. The `Box` component helps you to create flex layouts just like you would in a web application. 

The `ConfirmInput` component provides two callbacks — `onCancel` and `onConfirm`. When the user confirms, they will be redirected to the home screen. We achieve this functionality by setting the `feature` state to an empty string. If the user cancels, they will stay on the same page. 

Now, as you can see, we ask the user if they want to `Go back?` and take a simple “yes” or “no” `Y/n` input from the user using the `ConfirmInput` component. Once the user confirms “yes” using `Y`, we'll take them back to the main page: ![Custom Cli Displaying Ordered Task List And Option To Return To Main Screen](https://blog.logrocket.com/wp-content/uploads/2023/08/img5-CLI-ordered-task-list-option-return-main-screen.png) To see your complete application, hit `npm run build` — or if you’re in development mode, `npm run dev`. Ink automatically creates a new build every time you make any change. 

Congratulations on reaching the end of this tutorial! Now that you’ve seen Ink and Ink UI in action, you can go ahead and make your own CLI application seamlessly 🎉

## Conclusion

Ink is becoming a lot more popular due to its low learning curve, which is made possible thanks to its React context. Many well-known tech giants like Gatsby, GitHub Copilot, Prisma, Shopify, and others use Ink to develop their CLI tools. 

With its declarative component-based approach, Ink has emerged as a game-changer in the realm of CLI application development. It leverages the power of React to allow developers to harness their existing knowledge and experience, making it an accessible choice for React developers. 

In this tutorial, we covered how to use a few of the components and capabilities of Ink and Ink UI to create our demo project. You can check out what else is available to use in [Ink UI's GitHub documentation](https://github.com/vadimdemedes/ink-ui). 

I’m excited to see what you will create with Ink! If you have any questions, feel free to comment below.

---
## Get set up with LogRocket's modern React error tracking in minutes:

1. Visit https://logrocket.com/signup/ to get an app ID.
2. Install LogRocket via NPM or script tag. `LogRocket.init()` must be called client-side, not server-side.

NPM:
```bash
$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
```
Script Tag:
```javascript
Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
```

3.(Optional) Install plugins for deeper integrations with your stack:
*   Redux middleware
*   ngrx middleware
*   Vuex plugin

[Get started now](https://lp.logrocket.com/blg/signup)