Remix 3 発表まとめ - React を捨て、Web標準で新しい世界へ
はじめに
2025年10月10日、カナダのトロントで開催されたイベント "Remix Jam 2025" で Ryan Florence と Michael Jackson が Remix 3 を発表しました。このセッションは、React Router の生みの親たちが、なぜ React から離れ、独自のフレームワークを作ることにしたのか、その理由と新しいビジョンを語った歴史的な発表です。
本記事では、1時間47分に及ぶセッションの内容を詳しく解説します。
なぜ Remix 3 を作るのか
React への感謝と決別
Michael Jackson と Ryan Florence は、React に対して深い敬意を持っています。React は彼らのキャリアを変え、Web 開発の考え方を一変させました。React Router を10年以上メンテナンスし、Shopify のような大企業がそれに依存しています。
しかし、ここ1〜2年、彼らは React の方向性に違和感を感じるようになりました。
「僕らはもう、React がどこに向かっているのか分からなくなってきた」- Michael Jackson
現代のフロントエンド開発の複雑さ
Ryan は、フロントエンドエコシステムの複雑さについて率直に語ります:
「正直言って、全体像を把握できなくなってきた。フロントエンド開発者として、自分でも何が起きているのか分からない時がある」- Ryan Florence
彼らは、この状況を「山を登る」比喩で表現しています:
「僕らはこの山を登ってきて、頂上でキャンプしようとしている。でも、登ってきたおかげで視野が広がり、別の山が見えてきた。もっとシンプルな山が。だから、この山を下りて、あっちの山に登り直すことにした」- Ryan Florence
Web プラットフォームの進化
Node.js は16歳、React は12〜13歳です。その間に Web プラットフォームは大きく進化しました:
- ES Modules: ブラウザでモジュールをロードできる
- TypeScript: 型による開発体験の向上
- Service Workers: バックエンド機能をブラウザで
- Web Streams: Node.js にも標準ストリームが
- Fetch API: Node.js でも使える
- Web Crypto: 暗号化機能が標準に
AI 時代のフレームワーク
Ryan は、AI 時代のフレームワークに必要な要素について語ります:
- 安定した URL: LLM がアクションを実行するため、URL は常に同じである必要がある
- シンプルなコード: AI が生成・理解しやすいコード
- バンドラーへの依存を減らす: ランタイムセマンティクスがバンドラーに依存しない
React の use server では、RPC 関数の URL がビルドごとに変わってしまうため、AI がそれを利用することが困難です。
Remix 3 の核心アイデア
セットアップスコープ (Setup Scope)
Remix 3 の最も革新的な概念が Setup Scope(セットアップスコープ) です。
import { events } from "@remix-run/events"
import { tempo } from "./01-intro/tempo"
import { createRoot, type Remix } from "@remix-run/dom"
function App(this: Remix.Handle) {
// このスコープは1回だけ実行される(セットアップスコープ)
let bpm = 60
// レンダー関数を返す
return () => (
<button
on={tempo((event) => {
bpm = event.detail
this.update()
})}
>
BPM: {bpm}
</button>
)
}
createRoot(document.body).render(<App />)
重要なポイント:
- セットアップコードは1回だけ実行される
- 状態は JavaScript のクロージャに保存される(Remix の特別な機能ではない)
- 再レンダリングは
this.update()を明示的に呼ぶ
「ボタンはどうやって BPM が変わったことを知るの? 知らない。それが Remix 3 の素晴らしいところ。これはただの JavaScript スコープ。君が
update()を呼んだ時だけ、レンダー関数を再実行する」- Ryan Florence
Remix Events: イベントを第一級市民に
Remix 3 では、イベントをコンポーネントと同じレベルの抽象化として扱います。
click イベントの複雑さ
Ryan は、click イベントの複雑さを説明します:
- マウスダウン + マウスアップ(同じ要素上)
- キーボードの Space ダウン + Space アップ(Escape なし)
- キーボードの Enter ダウン(即座にクリック + リピート)
- タッチスタート + タッチアップ(スワイプなし)
これらすべてが click イベントです。
カスタムインタラクションの作成
Remix Events を使うと、独自のインタラクションを作成できます:
import { createInteraction, events } from "@remix-run/events"
import { pressDown } from "@remix-run/events/press"
export const tempo = createInteraction<HTMLElement, number>(
"rmx:tempo",
({ target, dispatch }) => {
let taps: number[] = []
let resetTimer: number = 0
function handleTap() {
clearTimeout(resetTimer)
taps.push(Date.now())
taps = taps.filter((tap) => Date.now() - tap < 4000)
if (taps.length >= 4) {
let intervals = [];
for (let i = 1; i < taps.length; i++) {
intervals.push(taps[i] - taps[i - 1])
}
let bpm = intervals.map(
(interval) => 60000 / interval
)
let avgTempo = Math.round(
bpm.reduce((sum, value) => sum + value, 0) /
bpm.length
)
dispatch({ detail: avgTempo })
}
resetTimer = window.setTimeout(() => {
taps = []
}, 4000)
}
return events(target, [pressDown(handleTap)])
}
)
使い方:
<button on={tempo((event) => {
bpm = event.detail
this.update()
})}>
BPM: {bpm}
</button>
「コンポーネントが要素に対する抽象化であるように、カスタムインタラクションはイベントに対する抽象化だ」- Ryan Florence

図: Components are to elements as custom interactions are to events
Context API: 再レンダリングを引き起こさない
Remix 3 の Context API は、React とは根本的に異なります。
function App(this: Remix.Handle<Drummer>) {
const drummer = new Drummer(120)
// コンテキストをセット(再レンダリングは発生しない)
this.context.set(drummer)
return () => (
<Layout>
<DrumControls />
</Layout>
)
}
function DrumControls(this: Remix.Handle) {
// コンテキストを型安全に取得
let drummer = this.context.get(App)
// drummer の変更を購読
events(drummer, [Drummer.change(() => this.update())])
return () => (
<ControlGroup>
<Button on={dom.pointerdown(() => drummer.play())}>
PLAY
</Button>
<Button on={dom.pointerdown(() => drummer.stop())}>
STOP
</Button>
</ControlGroup>
)
}
重要なポイント:
context.set()は再レンダリングを引き起こさない-
context.get(Component)でプロバイダーを直接参照("Go to Definition" が効く!) - 型安全: プロバイダーコンポーネントの型から自動推論

図: Go to Definition でプロバイダーに直接ジャンプできる
Signal: 非同期処理の管理
Remix 3 には重要な原則があります:
「関数を渡したら、signal を返す」
イベントハンドラーには自動的に signal が渡されます(AbortController の signal):
<select
id="state"
on={dom.change(async (event, signal) => {
fetchState = "loading"
this.update()
const response = await fetch(
`/api/cities?state=${event.target.value}`,
{ signal } // signal を fetch に渡す
)
cities = await response.json()
if (signal.aborted) return // 古いリクエストは自動的に中断される
fetchState = "loaded"
this.update()
})}
>
ユーザーが連続してセレクトボックスを変更すると:
- 古いハンドラーの signal が abort される
-
fetch()が自動的にキャンセルされる -
signal.abortedチェックで古い処理をスキップ

図: ネットワークタブで古いリクエストがキャンセルされている様子
これにより、レースコンディションを手動で、しかしシンプルに解決できます。
実際のデモから学ぶ
デモ1: カウンターからテンポタッパーへ
Ryan は最もシンプルな例から始めます。
ステップ1: プレーンJSでカウンター → テンポタッパー
まずは、プレーンな JavaScript でシンプルなカウンターを作ります:
// プレーンな DOM API から始める
let button = document.createElement("button")
let count = 0
button.addEventListener("click", () => {
count++
update()
})
function update() {
button.textContent = `Count: ${count}`
}
update()
document.body.appendChild(button)
「山を下りているんだ。プラットフォームには何がある?」- Ryan Florence

図: プレーンJavaScriptで実装したカウンター
退屈だから、もっと面白いものへ
「退屈だな。Remix Jam なのに、何でくだらないカウンターの話をしてるんだ?もっとエキサイティングなものを作ろう」- Ryan Florence
ここで Ryan は、クリックの**速さ(BPM)**を測定するテンポタッパーに変更します:
let button = document.createElement("button")
let tempo = 60
let taps = []
let resetTimer = 0
function handleTap() {
clearTimeout(resetTimer)
taps.push(Date.now())
taps = taps.filter((tap) => Date.now() - tap < 4000)
if (taps.length >= 4) {
let intervals = []
for (let i = 1; i < taps.length; i++) {
intervals.push(taps[i] - taps[i - 1])
}
let bpm = intervals.map((interval) => 60000 / interval)
tempo = Math.round(
bpm.reduce((sum, value) => sum + value, 0) / bpm.length
)
update()
}
resetTimer = window.setTimeout(() => {
taps = []
}, 4000)
}
button.addEventListener("pointerdown", handleTap)
button.addEventListener("keydown", (event) => {
if (event.repeat) return
if (event.key === "Enter" || event.key === " ") {
handleTap()
}
})
function update() {
button.textContent = `${tempo} BPM`
}
update()
document.body.appendChild(button)
このコードは、タップの間隔を計算して平均 BPM を算出しています:
- 直近4秒間のタップを配列に保存
- タップ間の間隔(ミリ秒)を計算
- 各間隔から BPM を計算(60000 / interval)
- すべての BPM を平均して表示

図: タップ間隔を計算して平均BPMを算出
ステップ2: Remix Events でイベントを抽象化
Ryan は click イベントの複雑さを説明します:
「みんな、
clickイベントって本当に知ってる?clickは実は複雑なんだ」- Ryan Florence
click イベントの内部動作:
- マウスダウン + マウスアップ(同じ要素上)
- キーボードの Space ダウン + Space アップ(Escape なし)
- キーボードの Enter ダウン(即座にクリック + リピート)
- タッチスタート + タッチアップ(スワイブなし)
これらすべてが click として発火します。
そこで、Remix Events を使ってカスタムインタラクションを作成します:
import { createInteraction, events } from "@remix-run/events"
import { pressDown } from "@remix-run/events/press"
export const tempo = createInteraction<HTMLElement, number>(
"rmx:tempo",
({ target, dispatch }) => {
let taps = []
let resetTimer = 0
function handleTap() {
clearTimeout(resetTimer)
taps.push(Date.now())
taps = taps.filter((tap) => Date.now() - tap < 4000)
if (taps.length >= 4) {
let intervals = []
for (let i = 1; i < taps.length; i++) {
intervals.push(taps[i] - taps[i - 1])
}
let bpm = intervals.map((interval) => 60000 / interval)
let avgTempo = Math.round(
bpm.reduce((sum, value) => sum + value, 0) / bpm.length
)
dispatch({ detail: avgTempo })
}
resetTimer = window.setTimeout(() => {
taps = []
}, 4000)
}
return events(target, [pressDown(handleTap)])
}
)
「コンポーネントが要素に対する抽象化であるように、カスタムインタラクションはイベントに対する抽象化だ」- Ryan Florence

図: Components are to elements as custom interactions are to events
重要なポイント:
-
状態とイベントをカプセル化:
taps配列やresetTimerはtempoインタラクション内部に隠蔽 -
型安全:
createInteraction<HTMLElement, number>で型を定義 -
再利用可能: どこでも
tempoインタラクションを使える -
合成可能:
pressDownは内部でpointerdownとkeydownを統合
ステップ3: Remix 3 のコンポーネント化
「みんな、コンポーネントを見せろって言ってる。よし、コンポーネントにしよう」- Ryan Florence
ここで、プレーンな JavaScript から Remix 3 のコンポーネントに変換します:
import { events } from "@remix-run/events"
import { tempo } from "./01-intro/tempo"
import { createRoot, type Remix } from "@remix-run/dom"
function App(this: Remix.Handle) {
// セットアップスコープ(1回だけ実行される)
let bpm = 60
// レンダー関数を返す
return () => (
<button
on={tempo((event) => {
bpm = event.detail
this.update()
})}
>
BPM: {bpm}
</button>
)
}
createRoot(document.body).render(<App />)
ここで Ryan が強調する重要なポイント:
「ボタンはどうやって BPM が変わったことを知るの? 知らない。それが Remix 3 の素晴らしいところ。これはただの JavaScript スコープ。君が
update()を呼んだ時だけ、レンダー関数を再実行する」- Ryan Florence
セットアップスコープの特徴:
- 1回だけ実行される: コンポーネントの初期化時のみ
- 状態は JavaScript のクロージャに保存: 特別な機能ではなく、普通の JavaScript
-
this.update()で明示的に再レンダリング: 自動的な依存性追跡はなし
tempo カスタムインタラクションが、先ほどの複雑なタップ計算ロジックをすべてカプセル化しています。コンポーネントは結果を受け取って表示するだけです。

図: Remix 3 コンポーネントとして実装されたテンポタッパー
デモ2: ドラムマシン
完全なドラムマシンアプリを構築し、Context API、EventTarget、queueTask など Remix 3 の核心機能を実演します。
構築する機能:
- Play/Stop ボタン
- テンポ調整(BPM)
- リアルタイムビジュアライザー(kick、snare、hi-hat の音量表示)
- キーボードショートカット(Space: 再生/停止、Arrow Up/Down: テンポ変更)
ステップ1: Drummer クラスを AI に生成させる
「Cursor に『キック、スネア、ハイハットを持ったドラマーを作って』って頼んだら、こいつが吐き出してくれた」- Ryan Florence
// AI が生成した Drummer クラス(EventTarget を継承)
class Drummer extends EventTarget {
private audioCtx: AudioContext | null = null
private masterGain: GainNode | null = null
private noiseBuffer: AudioBuffer | null = null
private _isPlaying = false
private tempoBpm = 90
private current16th = 0
private nextNoteTime = 0
private intervalId: number | null = null
// Scheduler settings
private readonly lookaheadMs = 25 // how frequently to
private readonly scheduleAheadS = 0.1 // how far ahead to
constructor(tempoBpm: number = 90) {
super()
this.tempoBpm = tempoBpm
}
get isPlaying() {
return this._isPlaying
}
get bpm() {
return this.tempoBpm
}
async toggle() {
if (this.isPlaying) {
await this.stop()
} else {
await this.play()
}
}
setTempo(bmp: number) {
this.tempoBpm = Math.max(
30,
Math.min(300, Math.floor(bpm || this.tempoBpm))
)
this.dispatchEvent(new CustomEvent("change"))
}
async play(bpm?: number) {
this.ensureContext()
if (!this.audioCtx) return
if (bpm) {
this.setTempo(bpm)
}
await this.audioCtx.resume()
if (this._isPlaying) return
this._isPlaying = true
this.nextNoteTime = this.audioCtx.currentTime
// don't reset current16th so setTempo can adjust mid-
if (this.intervalId != null)
window.clearInterval(this.intervalId)
this.intervalId = window.setInterval(
this.scheduler,
this.lookaheadMs
)
this.dispatchEvent(new CustomEvent("change"))
}
async stop() {
if (!this.audioCtx) return
if (this.intervalId != null) {
window.clearInterval(this.intervalId)
this.intervalId = null
}
this._isPlaying = false
this.current16th = 0
this.nextNoteTime = this.audioCtx.currentTime
this.dispatchEvent(new CustomEvent("change"))
}
private ensureContext() {
// ...
}
// ...
}
重要なポイント:
-
EventTargetを継承 → 標準的な DOM イベントモデルを利用 -
CustomEventで変更を通知 → どんなコンポーネントでもリッスンできる - 特別な Remix 用の型は不要 → 普通の JavaScript クラス
「重要なのは、これが特別な型である必要がないってこと。Cursor に頼めば吐き出してくれる。動けば使う。動かなければもう一回試す」- Ryan Florence
ステップ2: Context API でアプリ全体に Drummer を共有
前半で学んだ Context API の実践例です!
function App(this: Remix.Handle<{ drummer: Drummer }>) {
// セットアップスコープで Drummer を作成
const drummer = new Drummer()
// Context に設定(再レンダリングは不要)
this.context.set(drummer)
// レンダー関数を返す
return () => (
<div>
<DrumControls />
<Equalizer />
</div>
)
}
Context API の利点:
function DrumControls(this: Remix.Handle) {
// App コンポーネントから Context を取得
let drummer = this.context.get(App)
drummer.addEventListener("change", () => this.update())
return () => (
<ControlGroup>
<Button
on={temp((event) => {
drummer.play(drummer.bpm)
})}
disabled={drummer.playing}
>
SET TEMPO
</Button>
<TempoDisplay bpm={drummer.bpm} />
<Button
on={dom.pointerdown(() => {
drummer.play()
})}
>
PLAY
</Button>
<Button
on={dom.pinterdown(() => {
drummer.stop()
})}
>
STOP
</Button>
</ControlGroup>
)
}
「Context の取得で
this.context.get(App)を使うと、どのコンポーネントがそれを提供しているか一目瞭然。Go to Definition で飛べる。型も完全に安全」- Ryan Florence
React の Context との違い:
| React Context | Remix Context |
|---|---|
| Provider コンポーネントが必要 |
this.context.set() だけ |
| Context 変更 = 再レンダリング | Context 変更しても再レンダリングなし |
| Provider を探すのが大変 | Go to Definition で即座に見つかる |
ステップ3: 型安全なイベントを作る(createEventType)
カスタムイベントを型安全にするため、createEventType を使います:
import { createEventType } from "@remix-run/events"
// 型安全な "change" イベントを作成
let [change, createChange] = createEventType("drum:change")
class Drummer extends EventTarget {
static change = change // 静的メソッドとして公開(推奨パターン)
// ... 他のメソッド / プロパティ
private tempoBpm = 90
setTempo(bpm: number) {
this.tempoBpm = Math.max(
30,
Math.min(300, Math.floor(bpm || this.tempoBpm))
)
// 型安全な方法で dispatch
this.dispatchEvent(createChange())
}
}
// 使用例
import { events } from "@remix-run/events"
function TempoDisplay(this: Remix.Handle) {
const drummer = this.context.get(App)
// 型安全なイベントリスナー
events(drummer, [Drummer.change(() => this.update())])
return () => (
<div
>
BPM: {drummer.bpm}
</div>
)
}
「カスタムイベントを文字列で管理するのは型安全じゃない。
createEventTypeを使えば、イベント名も detail の型も完全に安全になる」- Ryan Florence
ステップ4: queueTask で DOM 更新後の処理
Play ボタンを押すと、Stop ボタンに自動的にフォーカスを移動したい:
function DrumControls(this: Remix.Handle) {
let drummer = this.context.get(App)
events(drummer, [Drummer.change(() => this.update())])
let stop: HTMLButtonElements
let play: HTMLButtonElements
return () => (
<ControlGrouop>
<Button
disabled={drummer.playing}
on={[
connect((event) => (play = event.currentTarget)),
pressDown(() => {
drummer.play()
// ❌ ここで focus() してもまだ DOM が更新されていない
// stop.focus() // エラー: disabled 状態のボタンにフォーカスできない
this.queueTask(() => {
// ✅ queueTask: DOM 更新が完了してから実行
stop.focus()
})
})
]
>
PLAY
</Button>
<Button
disabled={!drummer.playing}
on={[
connect((event) => (stop = event.currentTarget))
pressDown(() => {
drummer.stop()
// ❌ ここで focus() してもまだ DOM が更新されていない
// play.focus() // エラー: disabled 状態のボタンにフォーカスできない
this.queueTask(() => {
// ✅ queueTask: DOM 更新が完了してから実行
play.focus()
})
})
]}
>
STOP
</Button>
</ControlGrouop>
)
}
queueTask の仕組み:
「Remix は microtask でレンダリングをバッチ処理する。
queueTaskは DOM 更新が完了した後に実行されるキューだ。リスナーじゃない。次のレンダリングで一度だけ実行される」- Ryan Florence
1. drummer.play() → 状態変更
2. this.update() → レンダリングをキューに追加
3. [microtask] レンダリング実行 → DOM 更新
4. [queueTask] stop.focus() 実行 ← DOM が更新された後!
ステップ5: キーボードイベントの統合
前半で学んだ Remix Events の実践例です!
import { connect, type Remix } from "@remix-run/dom"
import { pressDown } from "@remix-run/events/press"
import {
space,
arrowUp,
arrowDown,
arrowLeft,
arrowRight
} from "@remix-run/events/key"
function App(this: Remix.Handle<{ drummer: Drummer }>) {
const drummer = new Drummer()
this.context.set(drummer)
events(window, [
// Space: 再生/停止
space(() => {
drummer.toggle()
}),
// Arrow Up: テンポアップ
arrowUp(() => {
drummer.setTempo(drummer.bpm + 1)
}),
// Arrow Down: テンポダウン
arrowDown(() => {
drummer.setTempo(drummer.bpm - 1)
},
// Arrow Left: テンポアップ
arrowLeft(() => {
drummer.setTempo(drummer.bpm - 1)
}),
// Arrow Right: テンポダウン
arrowRight(() => {
drummer.setTempo(drummer.bpm + 1)
})
])
return () => (
<Layout>
<DrumControls />
<Equalizer />
</Layout>
)
}
「windowにキーボードイベントを追加しても、Remixの他の部分と何も違わない感じだ。この
onプロップは、見ての通り、カスタムインタラクションも、どこでも同じように使える。要素にも使えるし、windowだけじゃない」- Ryan Florence
セマンティックなキーイベント:
-
space→ スペースキー -
arrowUp/arrowDown/arrowLeft/arrowRight→ 上下左右矢印キー - 内部的には
keydownをラップしているだけだが、意図が明確

図: Space、Arrow キーでドラムマシンを操作
このデモで学んだこと:
- ✅ EventTarget の活用: 標準的な DOM イベントモデルで状態を管理
- ✅ Context API: 再レンダリングなしでアプリ全体に値を共有
- ✅ 型安全なイベント:
createEventTypeでカスタムイベントを型安全に - ✅ queueTask: DOM 更新後の処理を安全に実行
- ✅ セマンティックなキーイベント:
space、arrowUpなどで意図を明確に - ✅ AI フレンドリー: Drummer クラスは AI が生成できる普通の JavaScript
デモ3: フォームと非同期処理(Signal によるレースコンディション解決)
前半で学んだ Signal: 非同期処理の管理 の実践例です!
州を選択すると、その州の都市リストを fetch する典型的な UI を構築します。これは、非同期処理のレースコンディションという古典的な問題を扱います。
問題: レースコンディション
function CitySelector(this: Remix.Handle) {
let state = "idle" // "idle" | "loading" | "loaded"
let cities = []
return () => (
<form>
<select
on={DOM.change(async (event) => {
// ローディング開始
state = "loading"
this.update()
// データ取得
const response = await fetch(
`/api/cities?state=${event.target.value}`
)
cities = await response.json()
// ローディング完了
state = "loaded"
this.update()
})}
>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="IL">Illinois</option>
<option value="KY">Kentucky</option>
<option value="KS">Kansas</option>
</select>
<select disabled={state === "loading"}>
{cities.map(city => (
<option key={city}>{city}</option>
))}
</select>
</form>
)
}
「イベントから考え始める。それが僕のやり方だ。ユーザーが最初のセレクトボックスを変更した。それで機能が始まる。ローディング状態にする → データ取得 → ロード完了。これが一番自然じゃない?」- Ryan Florence
問題の再現:
Ryan は、デモ用に各州の fetch に異なる遅延を設定しています:
- Alabama: 300ms
- Alaska: 500ms
- Kansas: 5000ms(意図的に遅い)
ユーザーが素早く選択を変更すると:
- Kentucky を選択 → fetch 開始(500ms)
- Illinois を選択 → fetch 開始(1000ms)
- Arizona を選択 → fetch 開始(800ms)
結果: どの fetch が最後に完了するかによって、表示される都市リストが変わってしまう!
「Louisville(Kentucky)、Illinois、Phoenix(Arizona)って表示された。問題だよね?」- Ryan Florence

図: 連続して選択を変更すると、最後に完了した fetch の結果が表示される
解決策: Signal を使う
Remix 3 の原則:
「Remix 3 の原則として、あなたが関数を渡したら、僕らはあなたに signal を渡す。あなたは非同期関数の中で好きなことができるべきだから、レースコンディションから自分を守る方法を提供する必要がある」- Ryan Florence
Signal を使った修正版:
function CitySelector(this: Remix.Handle) {
let state = "idle"
let cities = []
return () => (
<form>
<select
on={DOM.change(async (event, signal) => {
// ^^^^^^ Remix が渡す AbortSignal
state = "loading"
this.update()
// ✅ fetch に signal を渡す
const response = await fetch(
`/api/cities?state=${event.target.value}`,
{ signal } // <- これが重要!
)
// ✅ JSON パース中に abort されるかもチェック
if (signal.aborted) return
cities = await response.json()
state = "loaded"
this.update()
})}
>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="IL">Illinois</option>
<option value="KY">Kentucky</option>
<option value="KS">Kansas</option>
</select>
<select disabled={state === "loading"}>
{cities.map(city => (
<option key={city}>{city}</option>
))}
</select>
</form>
)
}
Signal の仕組み:
1. Kentucky 選択 → fetch 開始(関数A実行中)
2. Illinois 選択 → 関数Aの signal を abort
→ fetch 開始(関数B実行中)
3. Arizona 選択 → 関数Bの signal を abort
→ fetch 開始(関数C実行中)
「この関数は1つだけだが、ユーザーがセレクトボックスをクリックするたびに、複数の呼び出しが同時に進行してる。非同期だからね。1回選択したら関数を呼んで待ってる。もう一回クリックしたら、また関数を呼んで待ってる。関数が再度呼ばれた時、Remix は前の signal を abort する」- Ryan Florence

図: Signal を使うと、最新のリクエストだけが完了する
Signal の2つの使い方
1. fetch API に渡す(推奨)
const response = await fetch(url, { signal })
fetch API は、signal が abort されると自動的に AbortError を throw します。
2. 手動でチェック
if (signal.aborted) return
JSON のパースなど、時間がかかる処理の後にチェックします。
「abort controller を fetch に渡すと、throw する。だから、それ以降のコードは実行されない」- Ryan Florence
「2番目のチェックは実は不要だった。fetch が throw するから。でも、JSON のパースが巨大だったら、そこでもレースコンディションになりうる。だから、非同期処理の後は signal をチェックする癖をつけるといい」- Ryan Florence [01:19:35]
Remix 3 のシンプルな原則
「手動でやる必要がある。
this.update()を呼ぶのと同じように、手動でsignalを使う。でも、いつも abort させたいわけじゃない。投票システムみたいに、全部通したいこともある。重要な時だけ signal を使えばいい」- Ryan Florence [01:20:30]
重要な設計思想:
-
自動的な依存関係追跡はしない → 明示的に
this.update()を呼ぶ -
自動的な abort もしない → 明示的に
signalを使う - シンプルで予測可能 → コードを読めば何が起こるか分かる
利用可能な Signal の種類
Remix 3 では、3種類の signal が提供されます:
-
this.signal: コンポーネントがマウント/アンマウントされた時に abort -
イベントコールバックの
signal: 関数が再度呼ばれた時、または、コンポーネントがアンマウントされた時に abort -
レンダー中の
signal: 再レンダリングされた時に abort(通常は使わない)
「関数を渡したら、signal をあげる。これがルール。あなたがその中で何をするか分からないからね」- Ryan Florence
このデモで学んだこと:
- ✅ レースコンディションの理解: 複数の非同期処理が同時進行する問題
- ✅ Signal の基本:
AbortSignalを使って古い処理をキャンセル - ✅ fetch API との統合:
{ signal }を渡すだけで自動キャンセル - ✅ 手動チェック: 長時間処理の後は
signal.abortedをチェック - ✅ 明示的な制御: 必要な時だけ abort する設計
- ✅ Web 標準:
AbortControllerは Web 標準 API
デモ4: ListBox - Web標準の統合デモ
これまで学んだ複数の概念(Remix Events、Web標準API)を統合した実例です!
Ryan は、Remix 3 と並行して コンポーネントライブラリ を開発しており、その中核となる ListBox コンポーネントを通じて、Web 標準との統合方法を示します。
「UIフレームワークとして relevantであるためには、簡単に組み合わせられるコンポーネントが必要だ。フルスタック体験を目指している」- Ryan Florence [01:23:07]
ステップ1: 基礎 - ネストされたドロップダウンメニュー
「コンポーネントモデルが動くようになった瞬間、最も難しいネストされたドロップダウンメニューを作り始めた」- Ryan Florence [01:24:11]
まず、最も複雑なコンポーネントから開始します:
実装されている機能:
- ホバーインテント: マウスが境界を横切っても意図を理解して消えない
- 3階層のネスト: サブメニューのサブメニューまで対応
- キーボードナビゲーション: 完全なアクセシビリティ対応
- Remix Events: カスタムイベントで駆動
レイアウトとテーマシステム:
コンポーネントライブラリには、Stack(縦)と Row(横)のレイアウトシステムも含まれています:
import { Stack, Row } from "@remix/ui"
function ComponentShowcase(this: Remix.Handle) {
return () => (
<Stack size="xxl">
<Stack size="medium">
<MenuExample />
</Stack>
<Row>
<Button>Primary</Button>
<Button>Secondary</Button>
</Row>
</Stack>
)
}
- CSS カスタムプロパティベース: サーバーレンダリングと相性が良い
-
型安全なサイズ指定:
"xxl","medium"などが型チェックされる
「Tim(デザイナー)のデザインが素晴らしすぎて、それに見合うものを作らなきゃという気持ちになる」- Ryan Florence

図: Remix UI コンポーネントライブラリのプレビュー
ステップ2: ListBox の構築 - Popover API とフォーム統合
ここからが本題です。ネイティブの <select> 要素を超える ListBox を構築します。
Popover API との統合:
Web 標準の Popover API を使って、ドロップダウンリストを実装します:
function ListBox(this: Remix.Handle, props: { options: string[] }) {
let selectedValue = props.defaultValue || null
let isOpen = false
return () => (
<>
<button
type="button"
popovertarget="listbox-popover"
on={[
// Popover の開閉を検知
DOM.toggle(() => {
isOpen = !isOpen
this.update()
})
]}
>
{selectedValue || "Select..."}
</button>
<div id="listbox-popover" popover>
{/* このdivはbuttonの中にあるが、top layerに表示される */}
<ul role="listbox">
{props.options.map(option => (
<li
role="option"
on={pressDown(() => {
selectedValue = option
// カスタムイベントを dispatch
this.dispatchEvent(new CustomEvent("listbox:change", {
detail: { value: option },
bubbles: true // ← バブリングを有効化
}))
this.update()
})}
>
{option}
</li>
))}
</ul>
</div>
</>
)
}
Popover API のポイント:
-
popover属性 → 自動的にトップレイヤーに配置 -
popovertarget→ ボタンとポップオーバーを接続 -
toggleイベント → 開閉を検知できる
「Popover API は素晴らしい。トップレイヤーに行く。イベントもある。
popoverTargetToggleを使えば、ボタンが所有するポップオーバーがいつ開くかリッスンできる。カスタムイベントを使えば、通常は接続されていないものを接続できるんだ」- Ryan Florence
リアルなフォーム要素として動作:
ListBox は、内部に実際の <input> を持ち、フォームの一部として動作します:
function ListBox(this: Remix.Handle, props: { name: string, options: string[] }) {
let selectedValue = props.defaultValue || null
return () => (
<>
{/* 隠しinput: フォーム送信時に値を送る */}
<input type="hidden" name={props.name} value={selectedValue} />
<button type="button" popovertarget="listbox-popover">
{selectedValue || "Select..."}
</button>
<div id="listbox-popover" popover>
{/* ... オプションリスト ... */}
</div>
</>
)
}
// 使用例
function FruitForm(this: Remix.Handle) {
let formData = null
return () => (
<form
on={DOM.submit((event, signal) => {
event.preventDefault()
formData = new FormData(event.target)
console.log("Selected:", formData.get("fruit"))
this.update()
})}
>
<ListBox name="fruit" options={["Apple", "Banana", "Orange"]} />
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>
)
}
「これらは本物のフォーム要素なんだ。submit すると、実際の input が入ってる。リセットボタンを押すと、デフォルト状態に戻る。なぜなら、所属するフォームの submit イベントをリッスンしてるからだ。これが通常のフォーム要素がやることだよね」- Ryan Florence
フォームのリセットへの対応:
function ListBox(this: Remix.Handle, props: { options: string[], defaultValue?: string }) {
let selectedValue = props.defaultValue || null
return () => (
<div
on={[
// 親フォームのresetイベントをリッスン
DOM.reset(() => {
selectedValue = props.defaultValue || null
this.update()
})
]}
>
{/* ... ListBox UI ... */}
</div>
)
}
「リセットボタンを押すと、watch this(これ見て)... デフォルト状態に戻る。なぜなら、所属するフォームの reset イベントをリッスンしているからだ」- Ryan Florence
ステップ3: イベントのバブリング
Remix のカスタムイベントは、DOM標準のイベントと同様に バブリング します。
親要素でのイベント処理:
function FormWithListBox(this: Remix.Handle) {
let selectedFruit = null
return () => (
<form
on={[
// ★ フォーム要素で ListBox の変更を検知
ListBox.change((event) => {
selectedFruit = event.detail.value
console.log("ListBox changed:", selectedFruit)
this.update()
})
]}
>
{/* ListBox 自体には on プロップを付けない */}
<ListBox options={["Apple", "Banana", "Orange"]} />
<p>Selected: {selectedFruit}</p>
</form>
)
}
バブリングの仕組み:
<form> ← イベントがバブリングして到達
<ListBox> ← ここで dispatch
<button />
<div popover>
<li onClick> ← ここでクリック
「div の中に画像があったら、div に
onLoadを付けられるよね?div 自体は何もロードしないけど、load イベントはバブリングする。同じことだ。面白いパターンが生まれるはずだよ。ListBox が本当のイベントを dispatch して、親にバブリングする。だから、イベントを上の方で処理することも、下の方で処理することも、好きなところに置ける」- Ryan Florence
実用例: 複数の ListBox を1つのハンドラで処理
function MultiSelectForm(this: Remix.Handle) {
let selections = { fruit: null, vegetable: null }
return () => (
<form
on={[
// ★ すべての ListBox の変更を1つのハンドラで処理
ListBox.change((event) => {
const name = event.target.getAttribute("name")
selections[name] = event.detail.value
this.update()
})
]}
>
<ListBox name="fruit" options={["Apple", "Banana"]} />
<ListBox name="vegetable" options={["Carrot", "Broccoli"]} />
<pre>{JSON.stringify(selections, null, 2)}</pre>
</form>
)
}
ステップ4: Web Components との互換性
セッションのクライマックス。Ryan は、Remix コンポーネントを Web Components として公開できることを実演します。
「僕らのイベントシステム全体は、ただのカスタムイベントなんだ。通常のDOMを通してバブリングする。だから、Web Componentsを含む世界の他のすべてと、すぐに互換性がある」- Ryan Florence
カスタム要素としての使用:
<!-- 普通のHTMLファイル -->
<!DOCTYPE html>
<html>
<head>
<script type="module" src="/remix-components.js"></script>
</head>
<body>
<!-- ★ カスタム要素として使用 -->
<rmx-disclosure>
<disclosure-button>Toggle Content</disclosure-button>
<disclosure-content>
Hidden content here
</disclosure-content>
</rmx-disclosure>
<script type="module">
// カスタム要素の定義
class RmxDisclosure extends HTMLElement {
connectedCallback() {
// 既存のHTMLを取得
const button = this.querySelector('disclosure-button')
const content = this.querySelector('disclosure-content')
// innerHTML を消去して Remix コンポーネントをレンダリング
this.innerHTML = ""
const root = createRoot(this)
root.render(
<Disclosure>
<Disclosure.Button>{button.innerHTML}</Disclosure.Button>
<Disclosure.Content>{content.innerHTML}</Disclosure.Content>
</Disclosure>
)
}
}
customElements.define('rmx-disclosure', RmxDisclosure)
// ★ 通常のDOM APIでイベントをリッスン
document.querySelector('rmx-disclosure')
.addEventListener('disclosure:toggle', (e) => {
console.log('Disclosure toggled!', e.detail)
})
</script>
</body>
</html>
「これは証明のためのコンセプトだ。ハイドレーションとかやるべきだけど、これは単なる HTML ファイル。
rmx-disclosureとdisclosure-buttonがあって、これらはただの Web Components だ。addEventListenerでdisclosure:toggleをリッスンできる」- Ryan Florence

図: HTMLファイル内でカスタム要素として使用される Remix コンポーネント
マイクロフロントエンドへの応用:
「Remix で完全なアプリを作れるだけじゃない。Web Components の中に隠すこともできる。そうすれば、世界の他の部分と簡単に互換性を持たせられる。レガシーシステムや、AI チャットアプリに埋め込むとか、そういう新しいユースケースにも対応できる」- Ryan Florence
この設計の意義:
- 既存システムへの段階的導入: レガシーアプリに Remix コンポーネントを少しずつ追加
- フレームワーク間の相互運用: React、Vue、Angular などと共存
- AI エージェントへの埋め込み: チャットボットや AI インターフェースにコンポーネントを提供
- 標準への準拠: Web 標準に基づいているため、将来性がある
このデモで学んだこと:
- ✅ Popover API: Web標準のAPIとの統合
- ✅ フォーム統合: submit/resetイベントへの自動対応
- ✅ イベントのバブリング: 親要素での一括処理
- ✅ Web Components: 標準技術との完全な互換性
- ✅ 型安全なコンポーネント: TypeScript でのDX向上
- ✅ AI フレンドリー: 標準技術ベースで LLM が理解しやすい
Remix 3 の設計思想
抽象化は最小限に
「抽象化は、本当に必要だと感じるまで導入しない。イベントには型安全性と合成のために必要だった。でも、他の部分は?」- Ryan Florence
Remix 3 のコンポーネントは、特別な状態管理ライブラリを使いません:
let bpm = 60 // ただの変数
更新も明示的:
this.update() // これだけ
Web 標準を最大限活用
-
EventTargetとCustomEvent -
AbortControllerとsignal -
PointerEventでマウス・タッチ・ペンを統一 - DOM API をそのまま利用
TypeScript ファーストの開発体験
「Remix 1 と 2 では TypeScript はサイドクエストみたいなものだった。でも今は、TypeScript が開発体験の中心だ」- Michael Jackson
すべての API が型安全に設計されています:
- イベントの detail 型
- Context の型推論
- コンポーネントの props 型
LLM で生成しやすいコード
Ryan は、AI が Drummer クラスを生成したことを何度も強調します。Remix 3 のコードは:
- シンプルで予測可能
- 特殊な規則が少ない
- Web 標準に基づいている
そのため、LLM が理解・生成しやすいのです。
React Router は継続される
重要なポイント:
- React Router は継続されます
- Shopify など多くの企業が React Router に依存
- Remix チームが React Router V7 を開発中
- Remix 3 は別の選択肢として提供
「React Router はどこにも行かない。それだけは明確にしておきたい」- Ryan Florence
現在のステータス
- プロトタイプ段階
- ブログ投稿の3ヶ月後に開発開始
- 個別パッケージとして公開中(
@remix/events、@remix/uiなど) - 最終的には統合されたフレームワークとして提供予定
- コンポーネントライブラリも開発中(ドロップダウンメニュー、テーマシステムなど)
まとめ
Remix 3 は、フロントエンド開発の複雑さに対するアンチテーゼです。
主要な特徴:
- Setup Scope: JavaScript のクロージャを活用した状態管理
- Remix Events: イベントを第一級市民として扱う
-
明示的な再レンダリング:
this.update()で制御 - 型安全な Context API: 再レンダリングを引き起こさない
- Signal による非同期管理: レースコンディションをシンプルに解決
- Web 標準ベース: バンドラーへの依存を最小化
- TypeScript ファースト: すべての API が型安全
- AI フレンドリー: LLM が理解・生成しやすいコード
Ryan と Michael のメッセージ:
「3ヶ月間、日の光を見ていない。でも、これはワクワクする。僕らは正しい山を見つけたと思う」- Ryan Florence
Remix 3 は、Web 開発の未来を再定義しようとしています。シンプルさ、Web 標準、型安全性、そして AI との親和性。これらすべてを兼ね備えた新しいフレームワークの登場を、期待して待ちましょう。
参考リンク
この記事が役に立ったら、ぜひ実際のセッション動画もご覧ください。Ryan のライブコーディングと軽妙なトークは、文字では伝えきれない魅力があります!
Discussion
これは楽しみですねー