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:
- What is Ink, and what’s Ink UI?
- Setting up an Ink project
- Installing the Ink UI package
- Creating reusable components using Ink, Ink UI, and React
- Setting up the logic for creating and completing tasks
- Displaying our task list in our CLI
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
Here’s the file structure you can expect to see after installing:
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
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
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);
}}
/>
)
}
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: 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;
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>
You should now be able to see the following upon running the CLI: 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();
}}
/>
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("");
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
}
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>
);
}
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 nametask.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...
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>
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:
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);
});
}
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;
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>
);
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: 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:
- Visit https://logrocket.com/signup/ to get an app ID.
- 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');
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>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (0)