DEV Community

Cover image for Using Ink UI with React to build interactive, custom CLIs
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Using Ink UI with React to build interactive, custom CLIs

Written by Georgey V B✏️

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:

You can refer to the project’s GitHub repo to follow along with this tutorial

What is Ink, and what’s 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

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:

npx create-ink-app --typescript todo-cli
Enter fullscreen mode Exit fullscreen mode

Here’s the file structure you can expect to see after installing: Visual Studio Code Open To Show Expected File Structure After Installing Ink

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:

todo-cli name=Elon
Enter fullscreen mode Exit fullscreen mode

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

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

npm install @inkjs/ui
Enter fullscreen mode Exit fullscreen mode

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:

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);
    }}
  />
  )
}
Enter fullscreen mode Exit fullscreen mode

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 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

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:

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;
Enter fullscreen mode Exit fullscreen mode

The Box component is quite useful when creating the CLI application layout, as the same Flexbox rules apply as in web development.

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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 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 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:

<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();
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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:

const [feature, setfeature] = useState("");
Enter fullscreen mode Exit fullscreen mode

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

Let’s set up the basic logic for creating a task. Here, we can make use of the filesystem fs module from Node.js 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:

if (feature === "create-task") {
  // render Create task section
} else {
  // render the home screen
}
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

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...
Enter fullscreen mode Exit fullscreen mode

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:

<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> 
Enter fullscreen mode Exit fullscreen mode

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

Displaying our task list in our 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:

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

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:

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;
Enter fullscreen mode Exit fullscreen mode

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:

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>
);
Enter fullscreen mode Exit fullscreen mode

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 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.

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:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

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

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)