As I said in the previous article 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) I still love it ๐คทโโ๏ธ
But did you know that you can build CLI apps with React?!
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 ๐
Looks cool, right? Building a similar UI in the terminal without any library would be quite hard, though, thanks to 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.
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.
Let's open the project in the IDE and investigate the project structure.
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:
//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 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.
app.tsx
simply exports App
component, that receives name
prop.
// 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. 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.
//cli.tsx
import React from 'react';
import {render} from 'ink';
import App from './app.js';
render(<App />);
//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:
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 - abstraction library on top of Node.js child_process
methods.
Install it:
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:
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.
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:
execa
runsls
command to list all folders/files in the current directory and-p
flag means thatls
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 toresult
object. Since the resultedstdout
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 tofiles
variable.
- Once
execa
runspwd
command to get the current path- Once
execa
completes the command, we get access toresult
object and set ourpath
variable toresult.stdout
usingsetPath
function.
- Once
Now, this is how our app.tsx
file looks:
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:
<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).
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 ๐
<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:
"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.
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 from the Ink library.
//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.
{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
- ifpointer
is equal toindex
of the currentfile
return truetop
Text
component - responsible for showing>
sign pointing at folder/filebottom
Text
component - modified to get colorgreenBright
ifselected
true
Now, we are ready! Let's build and test the application:
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:
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 stringuse
execa
to runls
command insidenewPath
directorywhen
execa
is done, set new files to the resultingstdout
set a new path using
setPath
set
pointer
to 0
if
key
===delete
orbackspace
(to support Windows)create a new path by removing the last part of the
path
if the new path ends with
/
- remove ituse
execa
to runls
command insidenewPath
directorywhen
execa
is done, set new files to the resultingstdout
set a new path using
setPath
set
pointer
to 0
Time to try it out!
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:
//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!
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 if you have any questions and give a star to Ink!