チーズバーガーのイラスト

React 互換の軽量ランタイム TanStack Redact とは

TanStack Redact は、React のコンポーネントや Hooks の書き方を引き継ぐ軽量ランタイムです。同期描画を採用しており、React と同じ API があっても動作が異なる部分があります。この記事では基本的な導入方法と設計思想を説明し、同じ商品一覧を React と Redact で動かし、startTransition の動作の違いを確かめます。

TanStack Redact は、React の API に対応する軽量なランタイムです。既存の JSX や Hooks を使ったコードを維持しながら、実行時に使われる React の実装を置き換えます。

React の軽量な代替としては Preact もあります。Preact 自体は React の再実装を目的としておらず、preact/compat という互換レイヤーを通じて React のコードやライブラリを利用できるようにしています。

Redact の作者 Tanner Linsley 氏は Projecting React で、最初は preact/compat の導入を試したと振り返っています。同記事によると、当時の自身のアプリケーションでは use() の挙動や React 19 の Server Actions 関連 API、Portal、Error Boundary、hydration の細部で互換性の問題が重なり、追加の互換処理が増えていったとのことです。

そこで Redact では、React の公開 API を出発点に、TanStack Start で必要とする範囲へ絞った実装を作る方針を選びました。Preact に互換レイヤーを重ねる方法に対し、必要な React の API と動作から実装を組み立てる、という違いがあります。

ただし、API の名前が同じでも、React のすべての動作が再現されるわけではありません。Redact は同期描画を採用し、描画を優先順位に応じて中断・再開する仕組みを省いています。この違いは useDeferredValuestartTransition の動作に影響します。

この記事では TanStack Redact が何をするライブラリなのか、React の設計思想とどこが異なるのかを説明します。

TanStack Redact の導入

通常の React アプリケーションでは、react から useState などを、react-dom/client から createRoot などを読み込みます。Redact はこれらの import 先を、ビルドツールを通じて自身の実装へ差し替えます。

たとえば、アプリケーションのコードに書く import は以下のように通常の React と同じです。

import { startTransition, useState } from "react";
import { createRoot } from "react-dom/client";

Redact の Vite プラグインを使うと、react@tanstack/redactreact-dom/client@tanstack/redact/dom-client の実装へ解決されます。JSX を実行するための react/jsx-runtime なども同様に置き換わります。

Vite へ組み込む

Vite + React のプロジェクトで Redact を使用してみましょう。まず @tanstack/redact パッケージをインストールします。

npm install --save-exact @tanstack/[email protected]

続いて、Vite のプラグインに redact() を追加します。また、Vite 内蔵の JSX 変換を使う場合は esbuild: { jsx: 'automatic' } を明示する必要があります。React の Vite プラグインを使う場合は、automatic JSX runtime がデフォルトで有効になるため、明示する必要はありません。

vite.config.js
import { defineConfig } from 'vite';
import { redact } from '@tanstack/redact/vite';
 
export default defineConfig({
  plugins: [redact()],
  esbuild: { jsx: 'automatic' },
});

Redact のプラグインはクライアントと SSR のビルドを扱います。一方、React Server Components(RSC)の環境では import を差し替えず、本家 React がそのまま使われることに注意してください。

React の設計思想と Redact の違い

Redact の設計を理解するために、まず React が何を重視しているかを整理しましょう。React は、コンポーネントの描画をいつ実行するかを制御するために、いくつかの制約を設けています。

React の重要な考え方の 1 つは、コンポーネントを純粋に保つことです。同じ props・state・context に対して同じ結果を返し、レンダリング中には外部の状態を変更しないようにします。副作用はイベントハンドラーや Effect など、レンダリングとは別の場所で実行します。

この制約によって React は、コンポーネントをいつ評価するかを制御しやすくなります。React の公式ドキュメントでも、純粋性と描画の優先順位付けの関係が説明されています。

重い画面更新より入力を優先する

描画の実行タイミングを制御する理由の 1 つは、ユーザーの操作に対して応答性を維持するためです。たとえば、検索欄への入力に合わせて巨大なリストを更新する画面を考えましょう。一覧の描画が終わるまで入力欄の更新も待たせると、文字を入力する操作が重く感じられます。

React は、描画の優先順位を制御して、入力欄の更新を優先する仕組みである concurrent rendering を導入しました。concurrent rendering では、描画の途中で中断して、より優先度の高い更新を先に処理できます。React 18 の発表では、従来の中断できない描画との違いが説明されています。

開発者は以下の API を使って、更新の扱いを React に伝えます。

  • startTransition:コールバック関数内で指定した状態更新を、ほかの更新を妨げない Transition として扱う
  • useDeferredValue:値の反映を遅らせ、その値を使う UI の更新を後から試みる

実際の使用例も見てみましょう。入力欄の値 text と、一覧の絞り込みに使う値 query を分けます。入力欄は通常の状態更新で反映し、一覧の更新だけを startTransition で囲むことにより、入力欄の更新を優先できます。

一覧を表示する ProductList コンポーネントは、挙動を観察しやすいように、5,000 件の商品の各行で意図的に重い計算をしてから表示するかを判定します。

src/concurrent.jsx
import { startTransition, useState } from 'react';
import { createRoot } from 'react-dom/client';
 
import { ProductList } from './ProductList.jsx';
 
function App() {
  const [text, setText] = useState('');
  const [query, setQuery] = useState('');
 
  return (
    <main>
      <label>
        商品を検索
        <input
          value={text}
          onChange={(event) => {
            const nextText = event.target.value;
            setText(nextText); // 入力欄の更新は Transition にしない
            startTransition(() => {
              setQuery(nextText); // 一覧の更新を Transition にする
            });
          }}
        />
      </label>
      <p>{text !== query ? '一覧を更新しています' : ''}</p>
      {/* ProductList コンポーネントは意図的に計算負荷を入れている */}
      <ProductList query={query} />
    </main>
  );
}

setQuery(nextText) による一覧の更新は Transition として扱われるため、その描画中に次の入力が届くと、React は一覧の描画を中断して入力欄の更新を優先できます。その後、最新の検索語で一覧の描画をやり直します。以下のデモでは、入力が妨げられないことが確認できます。

Redact が選んだ同期描画

Redact は、React の API と日常的な動作を提供しながら、concurrent scheduling を持たないことを目標にしています。ここでいう scheduling は、描画する仕事の優先順位や実行タイミングを管理することです。初期版 0.0.1 のサイズ分析には、優先順位に基づく中断可能な描画などを省くことで、実装を小さくする方針が記されています。

その後、2026 年 9 月 11 日にマージされた PR #24 では、React の API や DOM・SSR の互換性を拡張しています。この変更でも、同期描画と Action・楽観的更新に関する Hooks の動作省略を維持する方針が明記されています。

つまり、React のコンポーネントモデルを引き継ぎつつ、実行時の責務を絞るという設計です。React と同じく純粋なコンポーネントを前提にしつつ、ランタイムが引き受ける処理を減らしています。

中断・再開を支える実装を省くことによる影響

先ほどの検索例で、一覧の描画を中断して入力を優先するには、React 側にもそのための仕組みが必要です。更新の優先順位を管理し、どこまで描画したかを保持し、処理を譲るタイミングを判断しなければなりません。新しい入力が届いた場合には、途中の描画をやり直す処理も必要になります。

React の描画処理の実装には、作業中のコンポーネントを管理する workInProgress や、更新の優先順位などを分類する lane、処理を譲るか判断する shouldYield が登場します。これらを管理・更新する JavaScript も、ランタイムの一部として配信されます。

Redact では、次の処理を省略・簡略化することにより、バンドルサイズの削減を図っています。

処理 中断可能な描画で必要になること Redact の方針
更新の選択 入力などの更新と Transition の更新を区別して、優先する仕事を選ぶ React の lane に基づく優先順位管理を実装しない
描画の進行 作業を分割し、処理を譲るか判断して、途中から再開する 描画の途中で処理を譲る仕組みを実装しない
値の遅延 古い値を使う表示と、新しい値で試みる描画を管理する useDeferredValue は渡された値をそのまま返す

useDeferredValue の違いは、Redact のHooks の実装を見ると一目瞭然です。

useDeferredValue<T>(v: T): T {
  return v
}

このメソッドは、受け取った値をそのまま返します。以前の値を保持して、別の優先順位で描画する React の処理はありません。

同期描画以外にも実装を小さくしている

同期描画以外にも、Redact はイベント処理を簡略化しています。DOM イベント処理では、要素へ addEventListener でハンドラーを登録し、ネイティブイベントに nativeEvent などの互換性を補っており、React の合成イベントの仕組み全体を再現することは避けています。

さらに、機能フラグを無効にした場合は、Vite プラグインが対象機能の import 先を簡略化した実装へ差し替えます。ブラウザに本来の実装を配信してから実行を止める方式ではなく、ビルド時にその実装を除外する方式です。nano プリセットは、この仕組みでオプション機能をまとめて無効にし、必要な機能を選ぶ設定です。

import { redact } from "@tanstack/redact/vite";
 
export default defineConfig({
  plugins: [
    redact({
      preset: "nano",
      features: { context: true },
    }),
  ],
});

機能フラグの説明では、たとえば context を無効にすると Provider の値は伝わらず、hydration を無効にすると hydrateRoot は例外を投げると明記されています。

同じ商品一覧を React と Redact で比較する

先ほどの商品一覧を、そのまま Redact でも動かしてみましょう。コードはそのままで、Vite のプラグインを有効にするだけで、React の import が Redact の実装へ置き換わります。

Redact では startTransition を呼んでも、使わなかった場合と同じように入力欄の更新と一覧の更新が同じ優先順位で扱われます。以下のデモでは入力欄への入力が遅延していることが確認できます。

一方ビルドサイズは、Redact(full プリセット)の方が小さくなります。今回の商品一覧を Vite で production build すると、生成された JavaScript 全体の gzip サイズは React が 69,714 bytes、Redact が 20,112 bytes でした。

まとめ

  • TanStack Redact は、React の import 先をビルド時に差し替える軽量ランタイム。コンポーネントや Hooks を使う基本的な書き方を引き継ぎ、React の API に対応するが、すべての動作を再現するわけではない。
  • Redact は同期描画を採用し、concurrent scheduling を省いている。useDeferredValuestartTransition は API として存在するが、React と同じ優先順位制御は行われない。
  • Redact の nano プリセットは、オプション機能をまとめて無効にする設定で、さらにバンドルサイズを小さくできる。

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

Vite で Redact を使うと、既存コードの React の import はどのように扱われますか?

  • すべてのコンポーネントを TanStack Start の API で書き直す

    もう一度考えてみましょう

    TanStack Start は必須ではなく、コンポーネントや Hooks を使う基本的な書き方を引き継ぎます。

  • import の記述を維持し、プラグインが Redact の実装へ解決する

    正解!

    redact() が react や react-dom/client などの import 先を置き換えます。

  • すべての import を開発者が @tanstack/redact へ書き換える

    もう一度考えてみましょう

    Vite プラグインを使う場合、アプリケーション側の React の import はそのままです。

  • React と Redact が同じ DOM に対して順番に描画する

    もう一度考えてみましょう

    対象の import 先を Redact へ差し替える仕組みであり、両者が順番に描画する方式ではありません。

Redact が中断可能な描画を省くことで、実装しなくてよくなる処理はどれですか?

  • JSX で書かれた要素を DOM に反映する処理

    もう一度考えてみましょう

    Redact も商品一覧を DOM に反映します。描画そのものを省くわけではありません。

  • 入力された文字列を状態として保持する処理

    もう一度考えてみましょう

    同じ商品の検索例で useState を使って入力値を保持しています。

  • コンポーネントへ props を渡して呼び出す処理

    もう一度考えてみましょう

    Redact も React のコンポーネントモデルを引き継ぎ、props を渡します。

  • 描画を優先順位に応じて中断し、途中の作業を再開する処理

    正解!

    Redact は concurrent scheduling を提供せず、中断・再開を支える実装を省略・簡略化しています。

Redact の nano プリセットについて、正しい説明はどれですか?

  • React のすべての動作を維持したまま、圧縮率だけを上げる

    もう一度考えてみましょう

    nano はオプション機能をまとめて無効にする設定で、動作も変わります。

  • 使った機能を実行時に検出し、必要な実装を自動で追加する

    もう一度考えてみましょう

    機能は設定で選びます。たとえば無効にした context は Provider の値を伝えません。

  • オプション機能をまとめて無効にし、必要なものを設定で選ぶ

    正解!

    機能を無効にすると振る舞いが省かれるため、必要な機能とサイズを合わせて判断します。

  • 同期描画をやめて、React と同じ描画の優先順位制御を使う

    もう一度考えてみましょう

    nano は concurrent scheduling を追加する設定ではありません。