はじめに
ReactのコンポーネントにはuseStateやuseEffect、Suspenseといった強力なライフサイクル管理の仕組みがあります。でも、これらは本当にDOMを描画するためだけの機能なのでしょうか。REST APIのポーリング処理や、ユーザーセッションの管理、非同期処理の待ち合わせなど、「画面を持たないロジック」にもマウント・アンマウント・副作用の後始末は必要になります。
react-nilは、その疑問に対する一つの答えです。画面を一切描画しない「nullレンダラー」でありながら、Reactの本物のライフサイクルと状態管理をそのまま使えます。DOMもCanvasも登場しない代わりに、コンポーネントツリーはただのJSONとして手に入ります。
とはいえ、言葉で説明するより実際に動かした方が早いと思います。状態が変化するたびにJSONツリーがどう更新されるかを確認できるサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
React-Nilとは
react-nilは、pmndrs(Poimandres)が公開しているReactのカスタムレンダラーです。react-reconcilerをベースに実装されており、React Three FiberやReact PDFと同じ仕組みで動きますが、react-nilが描画する先は「何もない」(nil)という点が特徴です。
render()に渡したコンポーネントは、DOMにもCanvasにも描画されません。その代わりに{ head: NilNode | null }という形のHostContainerが返り、コンポーネントツリーがプレーンなJSONオブジェクトとして手に入ります。それでいて内部的には本物のReactが動いているので、useStateによるローカル状態、useEffectによるマウント・アンマウント時の副作用、Contextによるデータ伝搬、Suspenseによる非同期処理の待ち合わせが、すべてそのまま機能します。
主な特徴
- 本物のライフサイクル - マウント・アンマウント・再レンダリングがReact本来の挙動通りに走るため、
useEffectのクリーンアップも保証される - DOM不要のロジックコンポーネント - REST APIエンドポイントの管理やユーザーセッションの状態機械を、副作用を
useEffectに閉じ込めたコンポーネントとして表現できる - JSONツリーとしての出力 - 独自の要素名(
<light>や<task>など)を定義すれば、render()の戻り値からリアクティブなJSONツリーを取り出せる act()との相性 - Reactが提供するact()でエフェクトやSuspenseの完了を待ってからスナップショットを取得できるため、テストコードの記述にも向いている
インストール
react-nilはReact 19系をpeerDependencyとして要求します。
npm install react-nil react
yarn add react-nil react
pnpm add react-nil react
React-Nilのサンプルを動かす
render()に渡したコンポーネントがどのようなJSONツリーを返すかを、実際に確かめられるサンプルです。信号機を模した<light>という独自要素を1.2秒ごとにuseStateで切り替え、render()の戻り値であるcontainer.headを<pre>に書き出しています。react-nilには「変化を購読する」API自体が無いため、ここではcontainer.headを短い間隔でポーリングして最新のツリーを表示しています。
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
light: { color: string }
}
}
}
function App() {
const [color, setColor] = useState<'green' | 'yellow' | 'red'>('green')
useEffect(() => {
const id = setInterval(() => {
setColor((c) => (c === 'green' ? 'yellow' : c === 'yellow' ? 'red' : 'green'))
}, 1200)
return () => clearInterval(id) // アンマウント時に必ず後始末される
}, [])
return <light color={color} />
}
const container = render(<App />)
下のサンプルは実際に編集・実行できます。「停止 / 再開」ボタンでコンポーネントのマウント状態を切り替えると、useEffectのクリーンアップ(clearInterval)が呼ばれてタイマーが止まる様子もログで確認できます。
<light color={color} />はDOM要素ではなく、react-nilが定義を任されている「独自の要素名」です。実際の画面には何も描画されませんが、container.headを見ると{ type: 'light', props: { color: 'green' }, children: [] }のようなJSONが1.2秒ごとに更新されているのが分かります。ボタンを押してrender(null)を渡すと、useEffectのクリーンアップ関数が呼ばれてタイマーが止まる点にも注目してください。
基本的な使い方
最もシンプルな使い方は、画面を持たないロジックだけのコンポーネントをマウントすることです。render()はDOMではなくJSONツリーを返すだけなので、コンポーネント自体は普通のReactコンポーネントと同じ書き方で構いません。
import { useState, useEffect } from 'react'
import { render } from 'react-nil'
function Foo() {
const [active, setActive] = useState(false)
useEffect(() => {
const id = setInterval(() => setActive((a) => !a), 1000)
return () => clearInterval(id)
}, [])
// false, true, false, true, ...
console.log(active)
}
render(<Foo />)
Fooは何もreturnしていませんが、これは正しいReactコンポーネントです。useStateとuseEffectが本物のライフサイクルに沿って動作し、activeの値が1秒ごとに切り替わり続けます。画面に何も出ない代わりに、REST APIのポーリングやWebSocketの接続維持など、副作用だけが目的の処理をReactのマウント・アンマウントの仕組みに乗せて管理できます。
実践的なユースケース
Suspenseで非同期処理の完了を待つ
React-nilはビューを持たないぶん、「非同期データが揃うまで待つ」というSuspenseの役割がより純粋な形で見えてきます。ここでは、Promiseが解決するまでコンポーネントをサスペンドさせる簡易的なリソースを作り、<Suspense fallback>で読み込み中の状態を表現しています。データ取得が完了すると、結果が<data>要素としてcontainer.headに反映されます。
function createResource<T>(promise: Promise<T>) {
let status: 'pending' | 'ready' | 'error' = 'pending'
let result: T
const suspender = promise.then(
(r) => { status = 'ready'; result = r },
(e) => { status = 'error'; result = e },
)
return {
read(): T {
if (status === 'pending') throw suspender // Suspenseがこれをキャッチする
if (status === 'error') throw result
return result
},
}
}
resource.read()がPromiseをthrowすると、その上位にある<Suspense fallback={<loading />}>がキャッチしてfallback側のツリー({ type: 'loading' })をcontainer.headに反映します。Promiseが解決すると自動的に再レンダリングされ、{ type: 'data', props: { value: '...' } }に切り替わります。ボタンを押すたびに新しいリクエストとしてdelayMsを変えて試すと、fallback表示の時間が変わる様子を確認できます。
act()でロジックの結果をテストのように検証する
react-nilは元々テスト用途を想定して作られており、Reactが提供するact()を使うと「エフェクトとSuspenseが全て片付くのを待ってから結果を読む」という、テストコードそのままの書き方ができます。ここではact(async () => render(<Task />))でコンポーネントの初期化を待ち、container.headの中身をアサーションの代わりに画面へ出力しています。
const container = await act(async () => render(<Task title="記事を書く" />))
// { type: 'task', props: { title: '記事を書く', done: false }, children: [] }
console.log(container.head)
ポイントは、render()単体ではuseEffect内のPromise.resolve().then(...)がまだ終わっていない状態のツリーしか返らない点です。act(async () => ...)でラップすることで、保留中のエフェクトが解決してマイクロタスクが片付くまで待ってからcontainer.headを読み出せます。done: falseのときに検証すると失敗するように書き換えると、act()を外した場合との違いがはっきり分かります。
まとめ
react-nilは、Reactの「コンポーネントという単位で状態とライフサイクルを管理する」考え方を、画面の有無から切り離して見せてくれるライブラリです。DOMを介さないぶん仕組みがシンプルに見え、useEffectのクリーンアップがいつ・なぜ呼ばれるのか、Suspenseが実際に何を待っているのかを、View層のノイズなしに観察できます。
REST APIのポーリングやセッション管理をコンポーネント化したいとき、あるいは自作のカスタムレンダラーの挙動を学びたいときは、一度react-nilでロジックだけを取り出して動かしてみると、Reactの内部モデルへの理解が深まるはずです。
