DEV Community

Cover image for Building Reactive CLIs with Ink - React CLI library
Sergii Kirianov
Sergii Kirianov

Posted on

Building Reactive CLIs with Ink - React CLI library

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

Hell Yeaah!

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

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

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

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

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

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

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 />);
Enter fullscreen mode Exit fullscreen mode
//app.tsx
import React from 'react';
import {Text} from 'ink';

export default function App() {
    return (
        <Text>Hello, World!</Text>
    );
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

Build the CLI application again and run it:

Yassified output

"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

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));
    }
});

//...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Final CLI application with hints

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!

Top comments (0)