Introduction #

This article is an advanced guide to using Ink v3.2.0 (with TypeScript and React) to create CLI apps. IDEA Ultimate / Webstorm project files are provided.

To get started w/ Ink v3 please checkout this introductory article.

โš ๏ธ This isnโ€™t a reference for React, Node.js, TypeScript, or CSS #

Learn more about these topics on developerlife.com:

Resource Notes
Node.js handbook Learn Node.js w/ TypeScript
React Hooks handbook Learn React, Redux, and Testing w/ TypeScript
CSS handbook Learn CSS, grid, flexbox, to implement responsive design

What is Ink #

Ink is a React renderer written in TypeScript using the react-reconciler package. So instead of rendering React components to the DOM Ink actually renders them to a terminal based UI.

With it, you can make CLI apps that look like this:

Ink supplies its own host UI components (eg: <Text>) which has nothing to do w/ react-dom host components that come out of the box (eg: <img>) w/ React.

Ink supplied host UI components are rendered to a terminal based UI by using:

  1. console streams to manipulate the output to the console, and input from the console.
  2. Yoga to implement CSS flexbox layout and positioning. Yoga is written in C++ that allows flexbox layout itself to be implemented in various platforms (like Lithio on Android, Node.js, .Net, etc).
  3. You can take a look at the dependencies that Ink has in its package.json. It relies on packages like: chalk, cli-boxes, cli-cursor, yoga-layout-prebuilt, react-reconciler, react-devtools-core.

Use the template to get a bare-bones CLI app #

The first tutorial was all about the mechanics of setting up an Ink CLI app, and understanding executable modules in Node.js, configuring Jest, etc. Now we can get into Ink itself and the CLI apps we can build w/ it.

The first thing we must do is clone the ts-ink-template repo. This will get our project bootstrapped w/ all the configuration settings in package.json, tsconfig.json, and jest.config.js sorted for us! It also sets up Eslint for TypeScript. And provide npm scripts, along w/ run configurations for IDEA, and even settings for VSCode.

๐Ÿ’ก In IDEA, you can simply run the Run all tests (watch) run configuration to continuously run Jest and execute all your tests when any file changes. This is a great way to see if the code that youโ€™re writing is breaking anything that youโ€™ve already written.

๐Ÿ’ก If you install the Jest extension in VSCode, you can do the same thing as well (settings.json is provided in this template for VSCode, so this extension is automatically configured for you).

Here are the 2 main TSX files we need to look at to get started.

  1. source/cli.tsx - This uses commander to parse the command line arguments that may be passed to the CLI app. So if we want to change the arguments that the app takes, we have to change the commander options. The values that we pass via the command line are actually passed as props to the App component described next.

  2. source/ui.tsx - We use React and Ink host components in this file to define the App component which takes props that correspond to the values which are passed into the command line above.

Also, we can customize the name of our app. Currently itโ€™s called "name": "ts-ink-template-app" in package.json and we can change it to ink-cli-app2. If we want to be able to run this app from anywhere on your computer, we have to run npm link. In this tutorial, we are just going to use the npm start script that is provided w/ the template.

To see the app in action we can simply run the following.

$ npm start -- -h

And it will display the help screen.

> ink-cli-app2@0.0.0 start
> dist/cli.js "-h"

Usage: cli [options]

Options:
  -n, --name <name>  name to display
  -h, --help         display help for command

If we donโ€™t pass any arguments in and run the following.

$ npm run start

We get this output.

> ts-ink-template-app@0.0.1 start
> dist/cli.js

Hello, Stranger

And if we pass it a name argument, like so.

$ npm run start -- -n Grogu

We get this output.

> ts-ink-template-app@0.0.1 start
> dist/cli.js "-n" "Grogu"

Hello, Grogu

There are a lot of great scripts that we can use, such as:

  • npm run test-watch - This will run Jest and watch your source code for changes and re-run all the tests.
  • An IDEA run configuration is also provided so you run and watch all the Jest tests in IDEA!
  • npm run dev - This will wat your source code for changes and run tsc to compile all the TS to JS and also run the app so you can see the changes in a terminal. Hot reloading ๐ŸŒถ for CLI apps ๐ŸŽ‰.

Build a CLI app using Ink and flexbox w/ Redux #

Starting w/ this simple and functional base we can now start adding some interesting Ink host components and React hooks into the mix! In this section we will build a React component that uses hooks (useEffect), Redux (useSelector), timers (setInterval), and even flexbox!

The main app, Flexbox, and Redux store #

Letโ€™s rename ui.tsx to app.tsx and update it w/ the following code.

import React, { FC } from "react"
import { Box, Text } from "ink"
import { TimerDisplayComponent, effectFn, store } from "./timer-component"
import { Provider } from "react-redux"

// App functional component.
const Style = {
  backgroundColor: "#161b22",
  textColor: "#e6e6e6",
  brandColor: "#2f9ece",
}
type PropTypes = {
  name?: string
}
export const App: FC<PropTypes> = ({ name = "Stranger" }) => {
  return (
    <Box
      borderStyle="round"
      borderColor={Style.brandColor}
      flexDirection="column"
      alignItems="center"
    >
      <Text color={Style.textColor} backgroundColor={Style.backgroundColor}>
        {`๐Ÿ‘‹ Hello ๐Ÿ‘‹`}
      </Text>
      <Text bold color={Style.textColor} backgroundColor={Style.brandColor}>
        {name}
      </Text>
      <Provider store={store}>
        <TimerDisplayComponent onComponentMountEffect={effectFn} />
      </Provider>
    </Box>
  )
}

Flexbox #

The Box host component is our Flexbox container. It is used to wrap other nested host components (Text) and composite components (TimerDisplayComponent) w/ flexbox directives that you expect.

Thereโ€™s no CSS styling since this isnโ€™t a web app and thereโ€™s no browser. The declarative styling that we would normally express in the CSS now has to be expressed in JSX (thankfully also declaratively). If youโ€™ve ever used React Native, this should seem familiar.

And hereโ€™s the TimerDisplayComponent composite component which is a functional component that uses React hooks (useEffect, useSelector), and a is passed a function via onComponentMountEffect props to start the timer that will update the Redux store 5 times w/ a delay of 100 ms.

import React, { EffectCallback, FC } from "react"
import { Text } from "ink"
import { useSelector } from "react-redux"
import { State } from "./reducer"

type PropType = {
  onComponentMountEffect: EffectCallback
}

export const TimerDisplayComponent: FC<PropType> = ({ onComponentMountEffect }) => {
  const state = useSelector((state) => state) as State

  React.useEffect(onComponentMountEffect, [] /* componentDidMount */)

  return render()

  function render() {
    return (
      <Text color={"green"}>
        [{state.count} tests passed]
        {showSkullIfTimerIsStopped()}
      </Text>
    )
  }

  function showSkullIfTimerIsStopped() {
    return !state.run ? "๐Ÿ’€" : null
  }
}

Redux store and effect function #

Hereโ€™s the code that creates the Redux store and calls the function that generates the effect function (that is passed to the component).

import { EffectCallback } from "react"
import { configureStore, EnhancedStore } from "@reduxjs/toolkit"
import { Action, reducerFn, ReducerType } from "./reducer"
import { createAndManageTimer } from "./timer-store-connector"

// Create Redux store.
export type TimerStore = EnhancedStore<ReducerType, Action, any>
export const store = configureStore<ReducerType>({
  reducer: reducerFn,
}) as TimerStore

// Create Timer and connect it to the Redux store.
export const effectFn: EffectCallback = createAndManageTimer(store)

Underlying / managed Timer and Redux store connector #

This functional component is very simple and it relies on the Redux storeโ€™s state in order to render itself. It does use 1 effect - this is run onComponentDidMount and kicks of the Timer. Hereโ€™s the code that generates the function which is passed to the effect (in timer-store-connector.tsx).

import { EffectCallback } from "react"
import { _also, createTimer, Timer } from "r3bl-ts-utils"
import { TimerStore } from "./store"

/**
 * Create a Timer and manage it by connecting it to the Redux store (dispatch the right actions to
 * it based on the Timer's lifecycle).
 */
export function createAndManageTimer(store: TimerStore): EffectCallback {
  const maxCount = 4
  const timer: Timer = _also(
    createTimer("Timer in App, count from 0 to 5, at 1s interval", 1000),
    (it) => {
      it.onStart = getOnStartFn()
      it.onTick = getOnTickFn()
      it.onStop = getOnStopFn()
    }
  )
  return effectFn

  /* Function that is passed to useEffect. */
  function effectFn() {
    if (!timer.isRunning) {
      timer.startTicking()
    }
    return () => {
      if (timer.isRunning) timer.stopTicking()
    }
  }

  function getOnStartFn() {
    return () => store.dispatch({ type: "startTimer" })
  }

  function getOnTickFn() {
    return () => {
      // Update count in UI.
      store.dispatch({
        type: "setCount",
        payload: timer.counter.value,
      })

      // Stop timer when maxCount is reached.
      if (timer.counter.value >= maxCount && timer.isRunning) timer.stopTicking()
    }
  }

  function getOnStopFn() {
    return () => store.dispatch({ type: "stopTimer" })
  }
}

This function does quite a bit of heavy lifting. It connects the lifecycle of the underlying Timer which it creates to the Redux storeโ€™s state (by dispatching actions) that it is passed as an argument. Here are some other notes on this code.

  1. Thereโ€™s a Timer (imported from r3bl-ts-utils) that actually manages a setInterval() underlying timer. This timer must be started and stopped, and once started, it calls the tick function at the given interval until the timer is stopped. The tick function also terminates the timer after its been called a few times.
  2. The lifecycles of the state and timer objects are separate, one doesnโ€™t know about the other. For this reason, some glue has to be added in order to tie the lifecycle of the Timer to the Redux storeโ€™s state.

Reducer, state, actions #

Finally, hereโ€™s the reducer function, actions, and state, which constitute all the Redux code.

import { Reducer } from "react"
import _ from "lodash"

export type State = {
  count: number
  run: boolean
}

interface ActionStartTimer {
  type: "startTimer"
}
interface ActionStopTimer {
  type: "stopTimer"
}
interface ActionSetCount {
  type: "setCount"
  payload: number
}
export type Action = ActionStartTimer | ActionStopTimer | ActionSetCount

export type ReducerType = Reducer<State | undefined, Action>

export function reducerFn(current: State | undefined, action: Action): State {
  // Initial state.
  if (!current) {
    return {
      count: 0,
      run: false,
    }
  }

  const currentCopy: State = _.clone(current)
  switch (action.type) {
    case "setCount":
      currentCopy.count = action.payload
      break
    case "startTimer":
      currentCopy.run = true
      break
    case "stopTimer":
      currentCopy.run = false
      break
  }
  return currentCopy
}

When you run the following command in the terminal.

$ npm run start -- -n Grogu

It will produce the following output ๐ŸŽ‰.

> ink-cli-app2@0.0.0 start
> dist/cli.js "-n" "Grogu"

โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚                   ๐Ÿ‘‹ Hello ๐Ÿ‘‹                   โ”‚
โ”‚                      Grogu                      โ”‚
โ”‚               [4 tests passed]๐Ÿ’€                โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

Tests #

Hereโ€™s a UI test that exercises all the classes that we have created so far.

import { Provider } from "react-redux"
import {
  Action,
  TimerDisplayComponent,
  reducerFn,
  ReducerType,
  TimerStore,
} from "../timer-component"
import React from "react"
import { render } from "ink-testing-library"
import { configureStore, EnhancedStore } from "@reduxjs/toolkit"

let store: TimerStore

beforeEach(() => {
  // Create Redux store.
  store = configureStore<ReducerType>({
    reducer: reducerFn,
  }) as EnhancedStore<ReducerType, Action, any>
})

describe("ComponentToDisplayTimer", () => {
  test("renders correctly when timer is not started", () => {
    const { lastFrame } = render(React.createElement(TestFC, null))
    expect(lastFrame()).toContain("[0 tests passed]๐Ÿ’€")
  })
})

describe("ComponentToDisplayTimer", () => {
  test("renders correctly when timer is started (which calls the tickFn)", () => {
    // Simulate a timer that is started, and then a tickFn is executed.
    store.dispatch({
      type: "startTimer",
    })
    store.dispatch({
      type: "setCount",
      payload: 10,
    })
    const { lastFrame } = render(React.createElement(TestFC, null))
    expect(lastFrame()).toContain("[10 tests passed]")
  })
})

const TestFC = () => (
  <Provider store={store}>
    <TimerDisplayComponent onComponentMountEffect={() => {}} />
  </Provider>
)

Related Posts