Remix 3は、Reactを使わない独自のコンポーネントモデルを持つWebフレームワークです。2026年10月1日に3.0.0が正式版になり、10月23日に3.1.0、11月4日に3.2.0が出ています。この記事では、コンポーネントの書き方(handleとmix)、状態の持ち方、remix newで作る雛形、3.1・3.2で変わった挙動、ヘッドレスUIの@remix-run/uiを、3.2.0でビルドして動かしたコードで説明します。
2025年10月のRemix Jamで公開されたデモのAPI(this.update()やpressDown)は、正式版では書き方が変わっています。英語圏の解説記事にも当時の書き方のままのものが多いため、読み替えの表も載せます。
まとめ:Remix 3のコンポーネントで押さえる要点
- 最新は
remix3.2.0(2026年11月4日)で、コンポーネント本体は@remix-run/component1.2.0です。ReactにもPreactにも依存しません。 - コンポーネントは「
handleを受け取り、描画関数を返す関数」です。状態はふつうの変数で持ち、変えたらhandle.update()を呼びます。代入だけでは再描画されません。 - イベントは
mix={on('click', ...)}で付けます。振る舞いの使い回しはcreateMixin()で作ります。 - 仮想DOMの差分更新はあります。Reactとの違いは、描画のきっかけを開発者が
update()で出す点です。 - 3.2では
css()の数値の扱いが変わり、flex: 2が2pxでなく伸長比になりました。3.1以前の出力に合わせたCSSは見直しが必要です。 - UI部品の
@remix-run/uiは0.13.1の不安定版で、remixパッケージには含まれません。0.13.0ではtoggleの書き方が変わりました。
Remix 3の現在地:3.0正式版から3.2までのリリースとパッケージ構成
Remix 3は2025年5月28日の公式ブログ「Wake up, Remix!」で「Reactに依存しない」方針が示され、2025年10月のRemix Jamで動くデモが公開されました。ベータ、リリース候補を経て、公式CHANGELOGの3.0.0の項に「First stable release of Remix 3.」と書かれた正式版が出ています。表の日付はnpmの公開日時(UTC)です。
| 公開日 | 版 | コンポーネントに関わる主な変更 |
|---|---|---|
| 2025-10-02 | @remix-run/dom(Jam用) | 実験版。this.update()・pressDown |
| 2026-10-01 | remix 3.0.0 | 正式版。component 1.0.0 |
| 2026-10-23 | remix 3.1.0 | ssr:falseのmixin、frame.reload、prefetch |
| 2026-11-04 | remix 3.2.0 | csp()、css()の数値の扱い、props検証 |
npmのremix 3.2.0は依存パッケージが49個あり、すべて@remix-run/で始まる自社パッケージです。reactは含まれていません。動作環境はenginesでnode >=24.3.0と指定されています。remixパッケージはルーター、ミドルウェア、セッション、DBアクセス(remix/data-table)、アセット配信まで含む全部入りで、コンポーネントはそのうちの画面を描く層(remix/component)にあたります。
名前が同じでも、Remix v2の後継はReact Routerです。npmのreact-routerは2026年10月27日に8.5.1(v7系は7.18.5)が出ており、Remix 3とは別の系統として更新が続いています。Remix v2から続く系統の経緯はPESPAとReact Router v8・Remix 3での実装にまとめています。
remix newで始める:雛形の構成と起動
新しいアプリはnpx remix new my-remix-appで作ります(同梱ガイドのCLIの章)。3.2.0で生成したところ、18個のファイルができ、実行時の依存はremixだけでした。
npx [email protected] new my-remix-app
cd my-remix-app
npm install
npm run dev # http://localhost:44100
| ファイル | 役割 |
|---|---|
| server.ts | Nodeのhttpサーバー。既定ポート44100 |
| app/routes.ts・router.ts | URLの定義とミドルウェア |
| app/actions/controller.tsx | URLごとの処理と描画 |
| app/actions/public/prompt-button.tsx | ブラウザで動く唯一の部品 |
| app/actions/public/entry.ts | ブラウザ側の起動(run()) |
| AGENTS.md・.agents/skills/remix | AIコーディング支援向けの説明 |
package.jsonのスクリプトはdev・hmr・start・test・typecheckの5つで、TypeScriptはnode --import remix/node-tsxでビルドせずに実行します。本番相当の起動でトップページは200を返し、ページ内でハイドレーションされるのはコピー用ボタン(PromptButton)だけでした。同梱のテスト(remix test)は1件成功し、tsc --noEmitもエラーなしです。
Reactとの違い:setupとrenderの2段構造と明示的な更新
setupとrenderの実行回数
コンポーネント関数そのものは最初に1回だけ呼ばれ(setup)、戻り値の描画関数が更新のたびに呼ばれます(render)。@remix-run/componentのREADMEは「It runs once per component instance」「The render function runs on the first render and again on every update」と説明しています。状態はsetupで宣言したローカル変数に置けば、クロージャとしてコンポーネントの寿命のあいだ残ります。useStateも、Hooksの呼び出し順のルールもありません。propsはhandle.propsから読み、更新のたびに最新の値に差し替わります。
ローカル状態の変更とhandle.update()による再描画
変数を書き換えても、Remixはそれを検知しません。自分の状態を変えたコンポーネントは、handle.update()を呼んで描画を予約します。もう1つの再描画の契機は親の更新で、親がupdate()すると子の描画関数も新しいpropsで呼ばれます。jsdomで試すと、子は自分のupdate()を呼ばなくても親のクリックに合わせて表示が変わりました。
handle.update()は、更新が反映された後にAbortSignalで解決するPromiseを返します。awaitすれば、DOMに反映された後にフォーカス移動などの処理を続けられます。
仮想DOMによる差分更新とDOMの再利用
「Remix 3には仮想DOMが無い」という説明を英語圏の解説でも見かけますが、ソースにはsrc/runtime/vdom.tsとreconcile.tsがあり、仮想ノードを比較して必要な箇所だけDOMを更新しています。READMEもroot.render()を「Renders a tree, reusing existing DOM and component instances.」と説明しています。Reactとの違いは差分更新の有無ではなく、状態setterで更新を予約するか、変数を変更した後に開発者がupdate()で出すかです。
| 観点 | React | Remix 3(component 1.2) |
|---|---|---|
| 状態の置き場 | useState などのHooks | setup内のふつうの変数 |
| 再描画のきっかけ | setState で自動 | handle.update() を明示的に呼ぶ |
| イベント | onClick(合成イベント) | mix={on(‘click’, …)}(DOMイベント) |
| 振る舞いの再利用 | カスタムフック | createMixin() で作るmixin |
| 後片付け | useEffect の戻り値 | handle.signal の abort |
Reactのコンポーネントや、Reactに依存するUIライブラリはそのままでは使えません。Reactからの距離を小さく保ちたい場合は、互換レイヤーを持つPreactのほうが移行の負担は軽くなります。
基本の書き方:Counterとmix={on()}
最小の例はクリックで数を増やすボタンです。次のコードをremix 3.2.0とnode v26.5.0でビルドし、jsdomで2回クリックして「Count: 2」になること、サーバー描画で<button type="button">Count: 2</button>を出力すること(初期値2を渡した場合)を確認しました。
import { on, type Handle } from 'remix/component'
export function Counter(handle: Handle<{ initialCount?: number }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
type="button"
mix={on('click', () => {
count++
handle.update()
})}
>
Count: {count}
</button>
)
}
JSXの変換先はremix/componentです。remix newの雛形には設定済みですが、手で組む場合はtsconfig.jsonに次の2行を入れます。TypeScript 7.0.2の--strictで型エラーが出ないことも確認しました。
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component"
}
}
Remix Jam 2025のデモから変わった書き方
2025年10月時点の実験版と正式版では、同じカウンターでも書き方が違います。Jam時点の記事やサンプルを参考にする場合は、次の表に沿って読み替えてください。
| 項目 | Jam時点(2025-10) | 正式版(3.0以降) |
|---|---|---|
| ハンドルの受け取り | this: Remix.Handle | 第1引数 handle: Handle |
| 更新 | this.update() | handle.update() |
| イベント | on={pressDown(…)} | mix={on(‘click’, …)} |
| 独自の操作の定義 | createInteraction | createMixin |
| パッケージ | @remix-run/dom・events | remix/component |
Jam時点の@remix-run/domはREADMEの冒頭に「DO NOT USE UNDER CONSTRUCTION」と書かれた実験版です。createInteractionは3.2.0のremixと@remix-run/*のどのパッケージにも含まれておらず、独自の振る舞いは後述のcreateMixin()で書きます。また、remix/uiだった入口は、正式版の数時間前に出た3.0.0-rc.5でremix/componentへ改名されました。
ToDoリストの実装:配列の状態管理とフォームイベント
状態が配列になっても考え方は同じです。配列をsetupの変数に置き、フォームのsubmitで要素を足してからhandle.update()を呼びます。入力値はフォーム要素からFormDataで読むので、入力のたびに状態を更新する必要はありません。
import { on, type Handle } from 'remix/component'
export function TodoApp(handle: Handle) {
let todos: { id: number; text: string; done: boolean }[] = []
let nextId = 1
return () => (
<section>
<form
mix={on('submit', (event) => {
event.preventDefault()
let form = event.currentTarget
let text = String(new FormData(form).get('text') ?? '').trim()
if (!text) return
todos.push({ id: nextId++, text, done: false })
form.reset()
handle.update()
})}
>
<input name="text" aria-label="新しいタスク" />
<button type="submit">追加</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
mix={on('change', (event) => {
todo.done = event.currentTarget.checked
handle.update()
})}
/>
{todo.text}
</label>
</li>
))}
</ul>
<p>残り {todos.filter((t) => !t.done).length} 件</p>
</section>
)
}
jsdom上で「牛乳を買う」「請求書を送る」の2件を追加すると「残り 2 件」、1件目のチェックボックスを押すと「残り 1 件」になり、送信後の入力欄が空に戻ることを3.2.0でも確認しました。todo.doneを直接書き換えているのは、Remixが値の不変性を要求しないためです。
非同期処理と中断:AbortSignalの3つの入口
Remix 3は後片付けと競合の回避を、ブラウザ標準のAbortSignalにそろえています。入口は3つあり、用途で使い分けます。
| 入口 | abortされるとき | 主な用途 |
|---|---|---|
| handle.signal | コンポーネントが外れたとき | タイマーやwindowのリスナー解除 |
| on() の第2引数 signal | 同じハンドラが再入したとき・外れたとき | 検索入力のfetch取り消し |
| await handle.update() の戻り値 | 次の描画が始まったとき・外れたとき | 読み込み表示を出した後のfetch |
検索欄のように入力が続く場面では、イベントハンドラが受け取るsignalをfetchへ渡すだけで、前の要求が取り消されます。READMEは「Remix doesn’t handle a rejected async event handler」とし、中断されたfetchは例外になると明記しているので、tryで受けて、中断による失敗は黙って抜けるようにします。
import { on, type Handle } from 'remix/component'
function SearchInput(handle: Handle) {
let results: string[] = []
return () => (
<div>
<input
type="search"
aria-label="検索語"
mix={on('input', async (event, signal) => {
try {
let q = encodeURIComponent(event.currentTarget.value)
let res = await fetch(`/search?q=${q}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
let data = await res.json()
if (signal.aborted) return
results = data.results
handle.update()
} catch (error) {
if (signal.aborted) return
console.error(error)
}
})}
/>
<ul>{results.map((r) => <li>{r}</li>)}</ul>
</div>
)
}
windowのresizeや外部のストアのように要素の外にあるイベントは、on()では付けられません。setupからhandle.queueTask()でブラウザ専用処理を予約し、その中でwindow.addEventListenerを呼び、オプションに{ signal: handle.signal }を渡しておけば、コンポーネントが外れたときにリスナーも外れます。
createMixinによる振る舞いの再利用
mixに渡すon()やcss()は「mixin」と呼ばれる部品で、createMixin()を使えば自分でも作れます。Reactのカスタムフックにあたる使い回しの単位ですが、コンポーネントではなく要素に付けます。次の例は、要素がDOMに入った時点で入力欄にフォーカスを移すmixinです。
import { createMixin, type Handle } from 'remix/component'
export const focusOnInsert = createMixin<HTMLElement>(
(handle) => {
handle.addEventListener('insert', ({ node }) => node.focus())
},
{ ssr: false },
)
export function SearchBox(handle: Handle) {
return () => <input type="search" name="q" aria-label="検索語" mix={focusOnInsert()} />
}
第2引数の{ ssr: false }は3.1.0で入った指定で、サーバー描画ではそのmixinのsetupを飛ばします。試すと、サーバーの出力は<input type="search" name="q" aria-label="検索語" />のままで、ブラウザ側ではフォーカスが入力欄に移りました。比較用にssrを指定しないmixinを並べると、そちらのsetupはサーバーで1回呼ばれていました。HTMLに何も足さない振る舞い(フォーカス、外側クリックの検知など)にだけ使い、属性やスタイルを付けるmixinには付けません。組み込みのon()とref()も3.1からこの指定で動いています。
3.1・3.2で変わったコンポーネントの挙動
3.0.0の時点で書いたコードは、3.2.0でスタイルやARIA属性などの出力が変わります。componentのCHANGELOGで「Behavior change」とされた変更と、知っておくと設計が変わる追加を並べます。
css()の数値と単位の扱い
3.2.0(component 1.2.0)から、css()とstyleは単位なしの数値を受け付けるプロパティでpxを付けなくなりました。css({ flex: 2, marginTop: 8, tabSize: 4, '--gapSize': '8px' })をサーバー描画すると、出力はflex: 2・margin-top: 8px・tab-size: 4・--gapSize: 8pxでした。以前はflex: 2pxになり伸長比として効いていなかったので、数値のflexを使っていた画面はレイアウトが変わります。キャメルケースで書いたカスタムプロパティはケバブケースに変換されなくなったため、var(--gap-size)で読んでいる場合は定義側も'--gap-size'に直します。生成されるクラス名(rmxc-*)も変わるので、テストやセレクタで決め打ちしている箇所は書き換えが必要です。
3.1・3.2のSSR性能・Frame操作・CSPの変更
| 版 | 変更 | 影響 |
|---|---|---|
| 3.1.0 | サーバー描画の高速化 | 48商品:513→244µs(Node 24.18・M5 Pro) |
| 3.1.0 | frame.reload({ src, method, body }) | 履歴を変えずにFrameへ送信 |
| 3.1.0 | data-rmx-prefetch=”intent|viewport” | リンク先のFrameを先読み |
| 3.2.0 | csp()ミドルウェア | nonce付きのCSPを自動で付与 |
| 3.2.0 | aria-*の真偽値 | サーバーでも”true”・”false”を出力 |
| 3.2.0 | createRoot系のみ使用 | Frame・run()未使用でruntime約40%減 |
真偽値の変更は、<a aria-current={true} aria-hidden={false}>のサーバー出力がaria-current="true" aria-hidden="false"になることで確かめました。以前はaria-currentだけが値なしで出力され、支援技術には「false」として読まれていました。
@remix-run/ui:Remix 3のヘッドレスUIプリミティブ
Remix 3向けのUI部品は、別パッケージの@remix-run/uiにあります。2026年11月時点の最新は0.13.1で、READMEには「currently unstable and versioned independently. It is not available through the remix package.」と書かれています。完成済みの見た目を持たないヘッドレスな部品で、キーボード操作やARIA属性の付与に加え、位置合わせやアニメーションなども提供します。
| 読み込み先 | 役割 |
|---|---|
| @remix-run/ui/menu | メニュー |
| @remix-run/ui/popover | ポップオーバー |
| @remix-run/ui/select・listbox・combobox | 選択リスト・入力補完 |
| @remix-run/ui/tabs・accordion・toggle | タブ・開閉・切り替え |
| @remix-run/ui/anchor | 要素の位置合わせ |
| @remix-run/ui/animation | 出入りのアニメーション・spring |
振る舞いの大半はmixに渡すmixinとして提供され、部品どうしの状態を共有する範囲だけをpopover.Contextのようなコンポーネントで囲みます。ポップオーバーの例では、ボタンにpopover.anchor()、パネルにpopover.surface({ open, onHide })を付け、開閉状態は自分の変数とhandle.update()で管理します。次のコードは0.13.1でも型検査を通りました。実行前にnpm install @remix-run/[email protected]で別パッケージを追加してください。
import { css, on, type Handle } from 'remix/component'
import * as popover from '@remix-run/ui/popover'
function ViewOptions(handle: Handle) {
let open = false
return () => (
<popover.Context>
<button
type="button"
mix={[
popover.anchor({ placement: 'bottom-end' }),
popover.focusOnHide(),
on('click', () => {
open = true
handle.update()
}),
]}
>
表示オプション
</button>
<div
mix={[
css({ background: 'white', padding: '8px' }),
popover.surface({
open,
onHide() {
open = false
handle.update()
},
}),
]}
>
パネルの内容
</div>
</popover.Context>
)
}
0.13.0では、toggleの改名に加え、削除済みの装飾付きコンポーネントの型やAccordionの未使用プロパティなども削除されました。切り替えのmixinはtoggle.control({ defaultChecked: true })からtoggle({ defaultChecked: true })に変わり、読み込みもimport { toggle } from '@remix-run/ui/toggle'になっています。同じ版で、動きを減らす設定を読むprefersReducedMotion()が@remix-run/ui/animationに加わりました。サーバー描画ではfalseを返すので、描画関数の中から呼べます。
css()で付けたスタイルはrmxというカスケードレイヤーに入ります。同じオリジンの通常宣言では、どのレイヤーにも属さないCSSのほうが優先されるため、既存のリセットCSSを素のまま置くと部品のスタイルが上書きされます。componentのREADMEの手順どおり、@layer base, rmx;で順序を宣言したうえで、リセットCSS自体を@layer base { ... }の中へ移してください。順序の宣言だけでは既存のCSSはレイヤーに入りません。
SSRと部分ハイドレーション:clientEntryとFrame
clientEntryによる部分ハイドレーション
Remix 3はサーバーでHTMLを描画し、ブラウザではclientEntryで指定した部品だけを動かします。READMEの説明は「hydrate selected components of a server-rendered page, leaving the rest as plain HTML」で、アイランドアーキテクチャと同じく静的な部分にはJavaScriptを送りません。
import { clientEntry, on, type Handle } from 'remix/component'
export const Counter = clientEntry(
import.meta.url,
function Counter(handle: Handle<{ initialCount?: number }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button type="button" mix={on('click', () => { count++; handle.update() })}>
{count}
</button>
)
},
)
第1引数はmoduleUrl#ExportNameの形の識別子で、remix newの雛形とREADMEはimport.meta.urlを渡しています。#ExportNameを省くと関数名が使われるため、関数名とエクスポート名をそろえます。ブラウザ側ではrun({ loadModule })がページ内の目印を探してモジュールを読み込みます。
propsはページに書き出されてブラウザへ渡るので、文字列・数値・プレーンなオブジェクト・JSX要素など直列化できる値に限られます。関数をpropsに持たせると、TypeScriptの段階でnever型のエラー(TS2345)になりました。JSX要素に付けられるmixinもcss()だけです。イベントハンドラを親から渡す設計はここで行き詰まるので、対話の単位ごとにclientEntryを切ります。3.2.0からは、JSONから読んだだけの「要素の形をしたオブジェクト」は描画されません。{"$rmx":true,"type":"div",...}を子に渡すと、サーバーの出力は空の<div></div>で、ブラウザ側では例外になりました。DBやAPIの値でマークアップを差し込まれる経路が塞がれています。
Frameによる部分描画とソフトナビゲーション
<Frame src="/sidebar">はサーバーで描画したHTMLをページの一部に流し込む部品です。fallbackを渡すと初回HTMLは読み込み表示で先に返り、本体は後から流れてきます。渡さないと、その中身がそろうまで待ちます。3.1.0からはframe.reload({ src, method, body })で、ブラウザの履歴を変えずにFrameへフォームの内容を送り、返ってきたHTMLで差し替えられます。リンクにdata-rmx-prefetch="intent"を付ければ、ホバーやフォーカスの時点で遷移先を先読みします。
またrun()を呼ぶと、同一オリジンのリンクとフォームはNavigation API経由でHTMLを取り直し、ページ全体を読み込み直さずに差し替えます。READMEによると、この挙動にはwindow.navigationとNavigateEvent.sourceElementの両方が必要で、どちらかが無いブラウザでは通常のページ遷移になります。特定のリンクだけ通常遷移にしたいときはdata-rmx-document属性を付けます。
サーバーとクライアントの描画の分担そのものはレンダリングとCSR・SSR・SSGの違いで整理しています。HTMLを部分的に差し替えるという点ではhtmxやHotwireのTurbo Framesと発想が近く、違いはクライアント側の部品をJSXで書ける点にあります。
採用判断:Remix 3を選ぶ場面と避ける場面
Remix 3は3.2まで来ましたが、新規の業務アプリで既定の選択肢にするのはまだ早いと判断します。理由は、UI部品が不安定版のままで、Reactの部品資産を使えないためです。3.2.0でもcss()の出力が変わったように、マイナー版で挙動が動く段階でもあります。一方、次の条件がそろうなら試す価値があります。
- 画面の大半がサーバー描画の静的HTMLで、対話する部品が限られている
- ルーティング、セッション、DBアクセス、UIまでの提供元を1つにそろえたい
- Node.js 24.3以上を本番で使える
反対に、次のどれかに当てはまる場合は避けるべきです。
- MUIやshadcn/uiなどReact向けUIライブラリを前提にした画面が多い(Remix 3のコンポーネントとして動かない)
- Remix v2やReact Routerの既存アプリを移したい(Remix 3は別系統で、コンポーネントは書き直しになる)
- デザインシステムの部品を長期に固定したい(@remix-run/uiは0.x系で、0.13.0でもtoggleの書き方が変わった)
既存のRemix v2アプリを持っているなら、先にReact Router v7・v8への移行を検討してください。クライアントだけで完結させたい場合の制約はRemix SPAモードの解説に、Remix 3のサーバー側でgRPCを扱う例はgRPC×Connect-ESをRemixで使う実装にまとめています。
update()の呼び忘れとsetup中の呼び出しによる失敗
状態を変えたのに画面が変わらないときは、まずhandle.update()の呼び忘れを疑います。Counterからhandle.update()だけを消した版をjsdomで2回クリックすると、変数は2になっていても表示は「Count: 0」のままでした。エラーも警告も出ないため、気づきにくい不具合です。
逆に、setupの中でhandle.update()を呼ぶと、「Ignored handle.update() while SetupUpdate is running its setup function.」という警告が出て無視されます(SetupUpdateは試験用に付けたコンポーネント名)。初回の描画はsetupの後に必ず行われるため、setup中の更新呼び出しは不要です。READMEは、描画関数の中で呼ぶと例外になること、サーバー描画中のupdate()も例外になることを明記しています。DOMができてから実行したい処理はhandle.queueTask()に渡します。
よくある質問
Remix 3はReactを使っていますか?
使っていません。remix 3.2.0の依存49個はすべて@remix-run/配下で、reactは含まれていません。2025年5月の方針発表では「Preactのフォークから始める」と書かれていましたが、正式版はPreactにも依存していません。
Remix 3の最新バージョンはいくつですか?
2026年11月10日時点のnpmのlatestは3.2.0(11月4日公開)です。3.0.0の正式版は10月1日、その直前のリリース候補が3.0.0-rc.5でした。UI部品の@remix-run/uiは0.13.1の不安定版で、正式版の範囲に含まれていません。
Remix v2のアプリはRemix 3へ移行できますか?
コンポーネントの書き方が別物なので、移行というより書き直しになります。Remix v2の後継として公式に案内されているのはReact Routerで、2026年10月27日時点で8.5.1とv7系の7.18.5が並行して更新されています。
Remix 3で使えるUIコンポーネントライブラリはありますか?
公式の@remix-run/uiがあり、menu・popover・select・tabs・combobox・accordionなど9種類の振る舞いと、アニメーション用の部品を提供しています。見た目を持たないヘッドレス部品なので、スタイルはcss()や既存のCSSで自分で付けます。React向けのUIライブラリは使えません。
Remix 3にuseStateやuseEffectはありますか?
ありません。状態はsetupで宣言した変数に置き、変更後にhandle.update()を呼びます。useEffectの後片付けにあたる処理はhandle.signalのabortで、描画後の処理はhandle.queueTask()で書きます。カスタムフックのような使い回しはcreateMixin()で作ります。