目次
ターミナルで動くツールにちょっとした画面を付けたくなったとき、いつも console.log と process.stdout.write を手で組み立てていました。プログレスバーを出すたびに ANSI エスケープシーケンスを調べ直す、あの面倒なやつです。React Ink を触ってからは、その部分をまるごと React のコンポーネントで書けるようになりました。
React Ink とは
Ink は、React のコンポーネントをブラウザの DOM ではなくターミナルにレンダリングするためのライブラリです。普段 Web で書いている JSX・useState・useEffect がそのまま動き、出力先だけがターミナルに変わる、と捉えると分かりやすいです。
実際、CLI として有名な Gatsby の CLI やいくつかのコーディングエージェントの画面も Ink で作られています。<div> を組み立てる代わりにターミナルの行を組み立てる、というだけで、頭の中の mental model は Web React と変わりません。
Web React の知識がそのまま使える
嬉しいのは、新しいフレームワークの作法を覚え直さなくていい点です。state を持って再レンダリングで画面を更新する、副作用は useEffect に書く、コンポーネントを分割して props を渡す——このやり方を CLI にそのまま持ち込めます。
ターミナルUIはこれまで、出力位置のカーソル制御や差分更新を自前で書く世界でした。Ink はその更新処理を React の再レンダリングに肩代わりさせます。state が変われば画面が描き変わる、という Web で当たり前のことが、ターミナルでも当たり前になります。
インストールと最小コード
React 本体と一緒に入れます。
npm install ink react
Hello World は次の3行が核です。
import React from 'react';
import {render, Text} from 'ink';
const App = () => <Text>Hello World</Text>;
render(<App />);
render() が Web でいう ReactDOM.createRoot().render() にあたります。渡したコンポーネントをターミナルに描画し、内部で state が変わるたびに再描画してくれます。実行すると Hello World がターミナルに出ます。
ここで最初に戸惑うのが、文字列を直接 JSX に置けないことです。<App>Hello</App> のように地のテキストを書くとエラーになり、必ず <Text> で包む必要があります。Web の感覚で <>Hello</> と書いてしまいがちなので、ここは Ink の最初の関門です。
Ink 固有のコンポーネント
Web React 経験者が一番つまずくのはここです。Ink では <div> <span> <p> といった HTML タグが一切使えません。 使えるのは Ink が用意した専用コンポーネントだけで、レイアウトの土台になるのが <Box> と <Text> の2つです。
<Text> は <span> の置き換え
テキストとその装飾はすべて <Text> が担います。色や太字は CSS ではなく props で指定します。
import React from 'react';
import {render, Text} from 'ink';
const App = () => (
<Text color="green" bold>
ビルドに成功しました
</Text>
);
render(<App />);
color のほかに backgroundColor bold italic underline dimColor などが props として用意されています。style={{ color: 'green' }} ではなく color="green" と書く、という対応で覚えると移行が早いです。
<Box> は display: flex の <div>
<Box> はレイアウト用のコンテナで、ブラウザでいう display: flex を当てた <div> だと思って差し支えありません。Ink はレイアウトエンジンに Yoga(React Native と同じ Flexbox 実装)を使っていて、Flexbox のプロパティを props で受け取ります。
import React from 'react';
import {render, Box, Text} from 'ink';
const App = () => (
<Box flexDirection="column" padding={1} borderStyle="round">
<Text>1行目</Text>
<Text>2行目</Text>
</Box>
);
render(<App />);
ここが Web React との最大の差分です。レイアウトを CSS ではなく props で書く。flexDirection padding margin gap width height といった見慣れた名前が、文字列やキャメルケースの props として並びます。<Box> はデフォルトで子要素を横並び(flexDirection="row")にするので、縦に積みたいときは上のように flexDirection="column" を明示します。
CSS ファイルも className も登場しません。スタイルは全部コンポーネントの props に集約される、と割り切ると Ink の書き味に馴染めます。
キー入力を受け取る useInput
ここまでは表示だけでした。インタラクティブにするには useInput フックを使います。これは Web React にはない、Ink 固有のフックです。
コールバックは (input, key) の2引数を受け取ります。input は押された文字そのもの、key は矢印キーや Enter などの特殊キーが押されたかどうかを真偽値で持つオブジェクトです。下は矢印キーで数を増減するカウンターです。
import React, {useState} from 'react';
import {render, Box, Text, useApp, useInput} from 'ink';
const Counter = () => {
const [count, setCount] = useState(0);
const {exit} = useApp();
useInput((input, key) => {
if (key.upArrow) {
setCount(prev => prev + 1);
}
if (key.downArrow) {
setCount(prev => prev - 1);
}
if (input === 'q') {
exit();
}
});
return (
<Box flexDirection="column">
<Text>カウント: <Text color="green">{count}</Text></Text>
<Text dimColor>↑↓ で増減、q で終了</Text>
</Box>
);
};
render(<Counter />);
key.upArrow が true なら上矢印が押された、という素直な作りです。key には downArrow leftArrow rightArrow return(Enter)escape などが入っています。状態は普通に useState で持ち、setCount で更新すれば Ink が画面を描き直します。Web で書くカウンターと、入力の取り方以外はほとんど同じコードになっているはずです。
終了処理だけは Web にない概念です。useApp から取り出した exit() を呼ぶとアプリが unmount されてプロセスが終わります。なお Ctrl+C での終了は exitOnCtrlC がデフォルトで有効なので、自前で書かなくても効きます。
文字入力フォームのように1文字ずつ拾ってカーソル管理までするのは地味に面倒なので、その用途には公式の ink-text-input を入れた方が早いです。useInput は「キーで操作するメニューやカウンター」くらいの粒度で考えておくと噛み合います。
Web React からの引っ越しで押さえるのは結局3点でした。HTML タグは使えず <Box> <Text> に置き換わること、レイアウトは CSS ではなく <Box> の props(Flexbox)で書くこと、入力は useInput で受けて useApp の exit() で抜けること。逆に言えば、それ以外の state や hooks の感覚はそのまま通用します。次にターミナルツールを書くとき、process.stdout.write を並べる前にまず Ink を入れてみるくらいの気軽さで使えると思います。
