Building Reactive CLIs with Ink - React CLI library
Tags: react, javascript, programming, tutorial
Published: 2023-11-03 15:35:45

As I said in the [previous article](https://codecryrepeat.hashnode.dev/learn-how-to-create-a-beautiful-cli-application-with-the-oclif-and-clackprompts) about building CLIs:

> I'm in love with CLIs.

But, I'm also in love with React, somehow it just clicks with me and even though it has received a lot of hate lately (if you haven't noticed it - you're not on [
Twitter
 X](https://twitter.com/SergiiKirianov)) I still love it 🤷‍♂️

But did you know that you can build CLI apps with React?!

![Hell Yeaah!](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fik9pt3qbr8czuwqpzrp.gif)

In this article, let's learn how to build reactive CLIs with React Ink library 🫰

## Getting Started

Before we start though, let's take a look at the result CLI app that we will build together 👀

![Result CLI app - Simplified File Explorer GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/awkb19lv73s48rldhxdk.gif)

Looks cool, right? Building a similar UI in the terminal without any library would be quite hard, though, thanks to [Ink](https://github.com/vadimdemedes/ink) it's almost as easy as building any frontend UI with React.

### Starting a project

First, we will need to create a new project, for this, we will use the Ink command to generate a new project.

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

I'm going to use TypeScript, but it's not necessary and you can simply follow this guide omitting types.


![Generating a new Ink project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9zr2y3ipct58i2qoxapu.png)

Let's open the project in the IDE and investigate the project structure.

![Ink project structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wlvotz4aqbb58itj5xyn.png)

The project structure looks familiar if you have experience with React applications, the only significant difference is the `cli.tsx` file. Let's take a look at both the `cli.tsx` and `app.tsx` files closely:

```typescript
//app.tsx

import React from 'react';
import {Text} from 'ink';

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

export default function App({name = 'Stranger'}: Props) {
	return (
		<Text>
			Hello, <Text color="green">{name}</Text>
		</Text>
	);
}
```

If you ever worked with React Native, this will look very similar to what you do in there. Instead of HTML elements, we are using built-in components that use Terminal APIs under the hood to render them. Ink provides a set of [Components](https://github.com/vadimdemedes/ink#components) to use in your application. On top of the components, you can also observe that they receive props - `color` in this example. Every component has a bunch of available props that mimic known CSS variables, you can find more about style props in the [Ink's documentation](https://github.com/vadimdemedes/ink#components).

`app.tsx` simply exports `App` component, that receives `name` prop.

```typescript
// cli.tsx

import React from 'react';
import {render} from 'ink';
import meow from 'meow';
import App from './app.js';

const cli = meow(
	`
	Usage
	  $ my-ink-cli

	Options
		--name  Your name

	Examples
	  $ my-ink-cli --name=Jane
	  Hello, Jane
`,
	{
		importMeta: import.meta,
		flags: {
			name: {
				type: 'string',
			},
		},
	},
);

render(<App name={cli.flags.name} />);
```

As you can see, `cli.tsx` also looks pretty similar to React's root file, but, it has something more to offer using [`meow` library](https://github.com/sindresorhus/meow). `meow` is a popular library that helps you build nice CLI applications gives you access to create usage docs and handles args and flags.

At the end of the file, you can find `render` method, similar to what Web React does - renders top `App.tsx` file.

### First CLI application with Ink

Let's get to the fun part and create our first tiny CLI app. First, we will remove unnecessary flags, props and replace everything inside `App` component with a simple "Hello, World!" text.

```typescript
//cli.tsx
import React from 'react';
import {render} from 'ink';
import App from './app.js';

render(<App />);
```

```typescript
//app.tsx
import React from 'react';
import {Text} from 'ink';

export default function App() {
	return (
		<Text>Hello, World!</Text>
	);
}
```

This will simply print the text in the console.

Let's try it out! Remember, after every change in the CLI source code - you need to run `npm run build` to create an executable file.

After you build an application, you need to run it. To do that, you can run `cli.js` file inside `dist` folder like this:

![How to run CLI app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p0nby3aj607xr2akqxxi.png)

If everything was done right, you would get "Hello, World!" printed in the terminal - nothing much for now, but the first step towards our goal!

## Building CLI File Explorer

To build File Explorer we would need to do a few things:

* Show the current Path
    
* Get the list of folders/files in the current directory
    
* Store them
    
* Render the list
    
* Navigate the list Up and Down
    
* Go In and Out of directories using Enter and Delete keys (Backspace for Windows)
    
* Update the changes to the UI to display the current path, selection, and current folders/files in a new directory
    

Sounds complicated, but bear with me, it will be easier than you think 😉

Let's start with the simplest - show the current path and get a list of folders/files.

### Current Path and List of Folders/Files

To simplify the process of running the commands, I will use [execa](https://www.npmjs.com/package/execa) - abstraction library on top of Node.js `child_process` methods.

Install it:

```bash
npm i execa
```

execa will help us to run commands like `ls` and `pwd` inside the CLI application.

First, let's create variables to store our path and folders/files. But...how should we do it? `let`? `var`? Nope 😌

Remember, Ink is built on top of React, which means - we can use React hooks! Inside your `App` component, add two variables using React's `useState` hook:

```typescript
const [path, setPath] = useState('');
const [files, setFiles] = useState<string[]>([]);

//if you don't use TypeScript, you can ommit <string[]>
```

Now, as in traditional React applications, whenever the `set*` function is called and the value of `path`/`files` are changed, React will re-render respective components.

Right now these values are empty, so, let's use another famous hook to populate them on `App` component mount.

```typescript
useEffect(() => {
    execa('ls', ['-p']).then((result) => {
	    setFiles([...result.stdout.split('\n')]);
	});
	execa('pwd').then((result) => {
		setPath(result.stdout);
	});
}, [])
```

So, what's going on here?

When `useEffect` hook is called with an **empty** dependencies array, which means it will run only once on component mount. When this happens, two things are executed:

1. `execa` runs `ls` command to list all folders/files in the current directory and `-p` flag means that `ls` command will append `/` slash to the end of the folder names, which will help us later on to differentiate between folders and files
    
    * Once `execa` completes the command, we get access to `result` object. Since the resulted `stdout` is a text with every new folder/file representing a new line, we need to split them by `/n` and then we assign the resulting array to `files` variable.
        
2. `execa` runs `pwd` command to get the current path
    
    * Once `execa` completes the command, we get access to `result` object and set our `path` variable to `result.stdout` using `setPath` function.
        

Now, this is how our `app.tsx` file looks:

```typescript
import React, { useEffect, useState } from 'react';
import { Text } from 'ink';
import { execa } from 'execa';

export default function App() {
	const [path, setPath] = useState('');
	const [files, setFiles] = useState<string[]>([]);

	useEffect(() => {
		execa('ls', ['-p']).then((result) => {
			setFiles([...result.stdout.split('\n')]);
		});
		execa('pwd').then((result) => {
			setPath(result.stdout);
		});
	}, [])

	return (
		<Text>Hello, World!</Text>
	);
}
```

We have all the data we need for now: current path and list of folders/files. Next, let's build a UI!

### Path and Files UI

Since this is just a guide, we will not go over our heads to create something stunning, but we will cover the main features of Ink.

Let's start by creating a 
div
...I mean, `Box` and display the data that we've got:

```typescript
<Box>
	<Text color="yellow">Path: <Text color="cyan">{path}</Text></Text>
	<Box>
		{files.map((file, index) => <Text key={index}>{file}</Text>)}
	</Box>
</Box>
```

As you can see, we used `Box` and `Text` component, that represents components similar to `div` and `span` respectively. We have used `color` prop, to give them distinctive colors.

Inside the top `Text` component we display `path` value and inside the `Box` we iterate over `files` array and render `Text` component with it's contents.

Let's run the application and see the result (don't forget to build it before).

![First run](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mz90ztq5vivytyyc9wmy.png)

If you didn't get any errors during the build, you will see something similar.

LOOKS UGLY!

But, it does what we wanted - congrats! Now, let's yassify it 💅

```typescript
<Box flexDirection='column'>
	<Text color="yellow">Path: <Text color="cyan">{path}</Text></Text>
	<Box flexDirection='column' flexWrap='wrap' height={8}>
		{files.map((file, index) => (
			<Text paddingLeft={1} color='grey'>{file}</Text>
		))}
	</Box>
</Box>
```

Build the CLI application again and run it:

![Yassified output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h3s93d5zcb2hwa1efsr8.png)

"Feel the difference 💅"

The output looks way-way better! In the code above we have used CSS props that might have reminded you about inline CSS in JS. This is great, since if you know them, you will be super comfortable working with Ink!

We have "fetched" and rendered the current path and folders/files - next, we need to add some interactivity and reactivity to our CLI.

### User Input Handling

Ink provides us with a lot of tools to build incredible and interactive CLIs. One of these is `useInput` hook. `useInput` hook allows us to watch for user input and based on the key they pressed react in any way we want.

So, if you remember, one of the goals of our File Explorer application is to be able to navigate the files and go Up and Down the folders. Let's do just that.

#### Navigating files in the current directory

Let's think about how we can allow users to navigate between the files. Since `files` variable is an array, which means that we can navigate this array left and right if only we know the index, or better say - pointer.

![pointer GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wknfmfeyre21zyuupy3j.gif)

The animation above describes perfectly the approach we need to follow. We will create a variable `pointer` that will be responsible for what the user is currently looking in the array. When the user hits `down` key, we will increment the `pointer` by 1 - meaning, the user now looks at `files[1]` file. And if the user hits `up` key, we will decrement the pointer by 1 - meaning, the user now looks at `files[0]` file. Sounds like a plan 😌

Let's implement it in the code using [`useInput` hook](https://github.com/vadimdemedes/ink#useinputinputhandler-options) from the Ink library.

```typescript
//app.tsx
//...

useInput((_, key) => {
	if (key.upArrow) {
		setPointer((prev) => (prev <= 0 ? 0 : prev - 1));
	}

	if (key.downArrow) {
		setPointer((prev) => (prev >= files.length - 1 ? files.length - 1 : prev + 1));
	}
});

//...
```

In the code above, we are using built-in `useInput` hook that gives us access to `key` argument that represents the key user clicked. I added some edge case handling, so that the `pointer` can't become less than 0 and more than the last index in the `files` array.

> I could tell you to run the app and try it out, but you won't see anything.

Let's handle one last thing. We need to reflect the user's selection in the UI, otherwise `pointer` will do the work, but the user won't be aware of what file they are looking at.

```typescript
{files.map((file, index) => {
	const selected = index === pointer;

	return (
	    <Box key={index} flexDirection='row' paddingLeft={1} justifyContent='flex-start'>
		    <Text color='greenBright'>{selected ? '> ' : '  '}</Text>
			<Text color={selected ? 'greenBright' : 'grey'}>{file}</Text>
	    </Box>
	)
})}
```

In the code above we modified the rendering of `files` array and added a few things:

* `selected` - if `pointer` is equal to `index` of the current `file` return true
    
* top `Text` component - responsible for showing `>` sign pointing at folder/file
    
* bottom `Text` component - modified to get color `greenBright` if `selected` true
    

Now, we are ready! Let's build and test the application:

![Testing application with selection and navigation GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4bm8io7gjsdtp9f8ably.gif)

> To stop the CLI application, when the terminal is in focus hit Ctrl+C

This. Looks. GREAT!

We've done an amazing job! Let's do one last thing and we are good.

### In/Out Folders Handling

We have handled most of the use cases for this application, except for one - how to navigate in and out of the folders. Let's not waste much time on it, since it includes all the things we have already covered above.

Inside your `useInput` hook add the following below `up`/`down` keys handling:

```typescript
if (key.return) {
	if (!files[pointer]?.includes('/')) return;

	let newPath = `${path}/${files[pointer]}`.slice(0, -1);
	execa('ls', ['-p', newPath]).then((result) => {
		setFiles([...result.stdout.split('\n')]);
	});
	setPath(newPath);
	setPointer(0);
}

if (key.delete || key.backspace) {
	let newPath = path.split('/').slice(0, -1).join('/');
	if (newPath[newPath.length - 1] === '/') {
		newPath = newPath.slice(0, -1);
	}

	execa('ls', ['-p', newPath]).then((result) => {
		setFiles([...result.stdout.split('\n')]);
	});
	setPath(newPath);
	setPointer(0);
}
```

Let's break the code above into steps:

* If `key` === `return`
    
    * if the user tries to enter inside "non-directory"(file) - return
        
    * create a new path string using the current `files[pointer]` folder name and remove `/` from the end of the string
        
    * use `execa` to run `ls` command inside `newPath` directory
        
    * when `execa` is done, set new files to the resulting `stdout`
        
    * set a new path using `setPath`
        
    * set `pointer` to 0
        
* if `key` === `delete` or `backspace` (to support Windows)
    
    * create a new path by removing the last part of the `path`
        
    * if the new path ends with `/` - remove it
        
    * use `execa` to run `ls` command inside `newPath` directory
        
    * when `execa` is done, set new files to the resulting `stdout`
        
    * set a new path using `setPath`
        
    * set `pointer` to 0
        

Time to try it out!

![navigate in and out folders](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xn7vknro6345kv7iaaj8.gif)

Awesome! Works well and looks great!

#### Bonus: User Hints

Last, but not least, let's add some user hints, so they know how to use this application. Add the code below after the `Box` rendering `files` array:

```typescript
//app.tsx
//...
<Box flexDirection='column'>
	<Text color='grey'>
		Press <Text color='greenBright'>Enter</Text> to enter a directory, <Text color='greenBright'>Delete</Text> to go up a directory
	</Text>
	<Text color='grey'>
		Use <Text color='yellow'>Up</Text> and <Text color='yellow'>Down</Text> to navigate
	</Text>
</Box>
//...
```

For the last time today - build and run the app!

![Final CLI application with hints](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jlv26cmbf2lncpc2wxlh.png)

### Conclusions

In this article, we used Ink - React-based library for building reactive and interactive UI CLI applications, to build our own CLI File Explorer app!

Ink is one of the greatest examples of how powerful React is, even outside of the Web. React ideology and built-in hooks and APIs are powerful enough even for CLIs and way more than that.

Our application is not complete though. The user wants not only to explore the folders and files but also `cd` into folders and quit the CLI app without doing it themselves, but, I will leave it for you 😏 Let me know in the comments if you were able to finish it!

Thanks for reading and I hope you enjoyed it! Hit me up on [Twitter](https://twitter.com/SergiiKirianov) if you have any questions and give a star to [Ink](https://github.com/vadimdemedes/ink)!