Advanced guide to Ink v3.2.0 (w/ React, Node.js and TypeScript)
- Introduction
- What is Ink
- Use the template to get a bare-bones CLI app
- Build a CLI app using Ink and flexbox w/ Redux
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 check out 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:
console
streams to manipulate the output to the console, and input from the console.- 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).
- 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.
-
source/cli.tsx
- This usescommander
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 thecommander
options. The values that we pass via the command line are actually passed as props to theApp
component described next. -
source/ui.tsx
- We use React and Ink host components in this file to define theApp
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 runtsc
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.
- Thereโs a
Timer
(imported fromr3bl-ts-utils
) that actually manages asetInterval()
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. - 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>
)
๐ Watch Rust ๐ฆ live coding videos on our YouTube Channel.
๐ฆ Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- ๐ฑ
giti
: run interactive git commands with confidence in your terminal- ๐ฆ
edi
: edit Markdown with style in your terminalgiti in action
edi in action