2025年10月10日、カナダのトロントで開催されたイベント "Remix Jam 2025" で Ryan Florence と Michael Jackson がRemix 3 を発表しました。このセッションは、React Router の生みの親たちが、なぜ React から離れ、独自のフレームワークを作ることにしたのか、その理由と新しいビジョンを語った歴史的な発表です。
https://www.youtube.com/live/xt_iEOn2a6Y?t=11764s
本記事では、1時間47分に及ぶセッションの内容を詳しく解説します。
!注意事項
この記事は、セッションの前半部分(Part 1)のみを扱います。バックエンド関連の内容は Part 2 で語られました。
Michael Jackson と Ryan Florence は、React に対して深い敬意を持っています。React は彼らのキャリアを変え、Web 開発の考え方を一変させました。React Router を10年以上メンテナンスし、Shopify のような大企業がそれに依存しています。
しかし、ここ1〜2年、彼らは React の方向性に違和感を感じるようになりました。
「僕らはもう、React がどこに向かっているのか分からなくなってきた」- Michael Jackson
Ryan は、フロントエンドエコシステムの複雑さについて率直に語ります:
「正直言って、全体像を把握できなくなってきた。フロントエンド開発者として、自分でも何が起きているのか分からない時がある」- Ryan Florence
彼らは、この状況を「山を登る」比喩で表現しています:
「僕らはこの山を登ってきて、頂上でキャンプしようとしている。でも、登ってきたおかげで視野が広がり、別の山が見えてきた。もっとシンプルな山が。だから、この山を下りて、あっちの山に登り直すことにした」- Ryan Florence
Node.js は16歳、React は12〜13歳です。その間に Web プラットフォームは大きく進化しました:
Ryan は、AI 時代のフレームワークに必要な要素について語ります:
React のuse server では、RPC 関数の URL がビルドごとに変わってしまうため、AI がそれを利用することが困難です。
Remix 3 の最も革新的な概念がSetup Scope(セットアップスコープ) です。
import{ events}from"@remix-run/events"import{ tempo}from"./01-intro/tempo"import{ createRoot,typeRemix}from"@remix-run/dom"functionApp(this: Remix.Handle){// このスコープは1回だけ実行される(セットアップスコープ)let bpm=60// レンダー関数を返すreturn()=>(<button on={tempo((event)=>{ bpm= event.detailthis.update()})}>BPM:{bpm}</button>)}createRoot(document.body).render(<App/>)重要なポイント:
this.update() を明示的に呼ぶ「ボタンはどうやって BPM が変わったことを知るの?知らない。それが Remix 3 の素晴らしいところ。これはただの JavaScript スコープ。君が
update()を呼んだ時だけ、レンダー関数を再実行する」- Ryan Florence
Remix 3 では、イベントをコンポーネントと同じレベルの抽象化として扱います。
click イベントの複雑さRyan は、click イベントの複雑さを説明します:
これらすべてがclick イベントです。
Remix Events を使うと、独自のインタラクションを作成できます:
import{ createInteraction, events}from"@remix-run/events"import{ pressDown}from"@remix-run/events/press"exportconst tempo=createInteraction<HTMLElement,number>("rmx:tempo",({ target, dispatch})=>{let taps:number[]=[]let resetTimer:number=0functionhandleTap(){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)}returnevents(target,[pressDown(handleTap)])})使い方:
<button on={tempo((event)=>{ bpm= event.detailthis.update()})}>BPM:{bpm}</button>「コンポーネントが要素に対する抽象化であるように、カスタムインタラクションはイベントに対する抽象化だ」- Ryan Florence

図: Components are to elements as custom interactions are to events
Remix 3 の Context API は、React とは根本的に異なります。
functionApp(this: Remix.Handle<Drummer>){const drummer=newDrummer(120)// コンテキストをセット(再レンダリングは発生しない)this.context.set(drummer)return()=>(<Layout><DrumControls/></Layout>)}functionDrumControls(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 でプロバイダーに直接ジャンプできる
Remix 3 には重要な原則があります:
「関数を渡したら、signal を返す」
イベントハンドラーには自動的にsignal が渡されます(AbortController の signal):
<select id="state" on={dom.change(async(event, signal)=>{ fetchState="loading"this.update()const response=awaitfetch(`/api/cities?state=${event.target.value}`,{ signal}// signal を fetch に渡す) cities=await response.json()if(signal.aborted)return// 古いリクエストは自動的に中断される fetchState="loaded"this.update()})}>ユーザーが連続してセレクトボックスを変更すると:
fetch() が自動的にキャンセルされるsignal.aborted チェックで古い処理をスキップ
図: ネットワークタブで古いリクエストがキャンセルされている様子
これにより、レースコンディションを手動で、しかしシンプルに解決できます。
Ryan は最もシンプルな例から始めます。
まずは、プレーンな JavaScript でシンプルなカウンターを作ります:
// プレーンな DOM API から始めるlet button= document.createElement("button")let count=0button.addEventListener("click",()=>{ count++update()})functionupdate(){ 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=60let taps=[]let resetTimer=0functionhandleTap(){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)returnif(event.key==="Enter"|| event.key===" "){handleTap()}})functionupdate(){ button.textContent=`${tempo} BPM`}update()document.body.appendChild(button)このコードは、タップの間隔を計算して平均 BPM を算出しています:

図: タップ間隔を計算して平均BPMを算出
Ryan はclick イベントの複雑さを説明します:
「みんな、
clickイベントって本当に知ってる?clickは実は複雑なんだ」- Ryan Florence
click イベントの内部動作:
これらすべてがclick として発火します。
そこで、Remix Events を使ってカスタムインタラクションを作成します:
import{ createInteraction, events}from"@remix-run/events"import{ pressDown}from"@remix-run/events/press"exportconst tempo=createInteraction<HTMLElement,number>("rmx:tempo",({ target, dispatch})=>{let taps=[]let resetTimer=0functionhandleTap(){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)}returnevents(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 を統合「みんな、コンポーネントを見せろって言ってる。よし、コンポーネントにしよう」- Ryan Florence
ここで、プレーンな JavaScript から Remix 3 のコンポーネントに変換します:
import{ events}from"@remix-run/events"import{ tempo}from"./01-intro/tempo"import{ createRoot,typeRemix}from"@remix-run/dom"functionApp(this: Remix.Handle){// セットアップスコープ(1回だけ実行される)let bpm=60// レンダー関数を返すreturn()=>(<button on={tempo((event)=>{ bpm= event.detailthis.update()})}>BPM:{bpm}</button>)}createRoot(document.body).render(<App/>)ここで Ryan が強調する重要なポイント:
「ボタンはどうやって BPM が変わったことを知るの?知らない。それが Remix 3 の素晴らしいところ。これはただの JavaScript スコープ。君が
update()を呼んだ時だけ、レンダー関数を再実行する」- Ryan Florence
セットアップスコープの特徴:
this.update() で明示的に再レンダリング: 自動的な依存性追跡はなしtempo カスタムインタラクションが、先ほどの複雑なタップ計算ロジックをすべてカプセル化しています。コンポーネントは結果を受け取って表示するだけです。

図: Remix 3 コンポーネントとして実装されたテンポタッパー
完全なドラムマシンアプリを構築し、Context API、EventTarget、queueTask など Remix 3 の核心機能を実演します。
構築する機能:
「Cursor に『キック、スネア、ハイハットを持ったドラマーを作って』って頼んだら、こいつが吐き出してくれた」- Ryan Florence
// AI が生成した Drummer クラス(EventTarget を継承)classDrummerextendsEventTarget{private audioCtx: AudioContext|null=nullprivate masterGain: GainNode|null=nullprivate noiseBuffer: AudioBuffer|null=nullprivate _isPlaying=falseprivate tempoBpm=90private current16th=0private nextNoteTime=0private intervalId:number|null=null// Scheduler settingsprivatereadonly lookaheadMs=25// how frequently toprivatereadonly scheduleAheadS=0.1// how far ahead toconstructor(tempoBpm:number=90){super()this.tempoBpm= tempoBpm}getisPlaying(){returnthis._isPlaying}getbpm(){returnthis.tempoBpm}asynctoggle(){if(this.isPlaying){awaitthis.stop()}else{awaitthis.play()}}setTempo(bmp:number){this.tempoBpm= Math.max(30, Math.min(300, Math.floor(bpm||this.tempoBpm)))this.dispatchEvent(newCustomEvent("change"))}asyncplay(bpm?:number){this.ensureContext()if(!this.audioCtx)returnif(bpm){this.setTempo(bpm)}awaitthis.audioCtx.resume()if(this._isPlaying)returnthis._isPlaying=truethis.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(newCustomEvent("change"))}asyncstop(){if(!this.audioCtx)returnif(this.intervalId!=null){ window.clearInterval(this.intervalId)this.intervalId=null}this._isPlaying=falsethis.current16th=0this.nextNoteTime=this.audioCtx.currentTimethis.dispatchEvent(newCustomEvent("change"))}privateensureContext(){// ...}// ...}重要なポイント:
EventTarget を継承 → 標準的な DOM イベントモデルを利用CustomEvent で変更を通知 → どんなコンポーネントでもリッスンできる「重要なのは、これが特別な型である必要がないってこと。Cursor に頼めば吐き出してくれる。動けば使う。動かなければもう一回試す」- Ryan Florence
前半で学んだContext API の実践例です!
functionApp(this: Remix.Handle<{ drummer: Drummer}>){// セットアップスコープで Drummer を作成const drummer=newDrummer()// Context に設定(再レンダリングは不要)this.context.set(drummer)// レンダー関数を返すreturn()=>(<div><DrumControls/><Equalizer/></div>)}Context API の利点:
functionDrumControls(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}>SETTEMPO</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 で即座に見つかる |
カスタムイベントを型安全にするため、createEventType を使います:
import{ createEventType}from"@remix-run/events"// 型安全な "change" イベントを作成let[change, createChange]=createEventType("drum:change")classDrummerextendsEventTarget{static change= change// 静的メソッドとして公開(推奨パターン)// ... 他のメソッド / プロパティprivate tempoBpm=90setTempo(bpm:number){this.tempoBpm= Math.max(30, Math.min(300, Math.floor(bpm||this.tempoBpm)))// 型安全な方法で dispatchthis.dispatchEvent(createChange())}}// 使用例import{ events}from"@remix-run/events"functionTempoDisplay(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
Play ボタンを押すと、Stop ボタンに自動的にフォーカスを移動したい:
functionDrumControls(this: Remix.Handle){let drummer=this.context.get(App)events(drummer,[Drummer.change(()=>this.update())])let stop: HTMLButtonElementslet play: HTMLButtonElementsreturn()=>(<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 が更新された後!前半で学んだRemix Events の実践例です!
import{ connect,typeRemix}from"@remix-run/dom"import{ pressDown}from"@remix-run/events/press"import{ space, arrowUp, arrowDown, arrowLeft, arrowRight}from"@remix-run/events/key"functionApp(this: Remix.Handle<{ drummer: Drummer}>){const drummer=newDrummer()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 キーでドラムマシンを操作
このデモで学んだこと:
createEventType でカスタムイベントを型安全にspace、arrowUp などで意図を明確に前半で学んだSignal: 非同期処理の管理 の実践例です!
州を選択すると、その州の都市リストを fetch する典型的な UI を構築します。これは、非同期処理のレースコンディションという古典的な問題を扱います。
functionCitySelector(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=awaitfetch(`/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 に異なる遅延を設定しています:
ユーザーが素早く選択を変更すると:
結果: どの fetch が最後に完了するかによって、表示される都市リストが変わってしまう!
「Louisville(Kentucky)、Illinois、Phoenix(Arizona)って表示された。問題だよね?」- Ryan Florence

図: 連続して選択を変更すると、最後に完了した fetch の結果が表示される
Remix 3 の原則:
「Remix 3 の原則として、あなたが関数を渡したら、僕らはあなたに signal を渡す。あなたは非同期関数の中で好きなことができるべきだから、レースコンディションから自分を守る方法を提供する必要がある」- Ryan Florence
Signal を使った修正版:
functionCitySelector(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=awaitfetch(`/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 を使うと、最新のリクエストだけが完了する
1. fetch API に渡す(推奨)
const response=awaitfetch(url,{ signal})fetch API は、signal が abort されると自動的にAbortError を throw します。
2. 手動でチェック
if(signal.aborted)returnJSON のパースなど、時間がかかる処理の後にチェックします。
「abort controller を fetch に渡すと、throw する。だから、それ以降のコードは実行されない」- Ryan Florence
「2番目のチェックは実は不要だった。fetch が throw するから。でも、JSON のパースが巨大だったら、そこでもレースコンディションになりうる。だから、非同期処理の後は signal をチェックする癖をつけるといい」- Ryan Florence [01:19:35]
「手動でやる必要がある。
this.update()を呼ぶのと同じように、手動でsignalを使う。でも、いつも abort させたいわけじゃない。投票システムみたいに、全部通したいこともある。重要な時だけ signal を使えばいい」- Ryan Florence [01:20:30]
重要な設計思想:
this.update() を呼ぶsignal を使うRemix 3 では、3種類の signal が提供されます:
this.signal: コンポーネントがマウント/アンマウントされた時に abortsignal: 関数が再度呼ばれた時、または、コンポーネントがアンマウントされた時に abortsignal: 再レンダリングされた時に abort(通常は使わない)「関数を渡したら、signal をあげる。これがルール。あなたがその中で何をするか分からないからね」- Ryan Florence
このデモで学んだこと:
AbortSignal を使って古い処理をキャンセル{ signal } を渡すだけで自動キャンセルsignal.aborted をチェックAbortController は Web 標準 APIこれまで学んだ複数の概念(Remix Events、Web標準API)を統合した実例です!
Ryan は、Remix 3 と並行してコンポーネントライブラリ を開発しており、その中核となるListBox コンポーネントを通じて、Web 標準との統合方法を示します。
「UIフレームワークとして relevantであるためには、簡単に組み合わせられるコンポーネントが必要だ。フルスタック体験を目指している」- Ryan Florence [01:23:07]
「コンポーネントモデルが動くようになった瞬間、最も難しいネストされたドロップダウンメニューを作り始めた」- Ryan Florence [01:24:11]
まず、最も複雑なコンポーネントから開始します:
実装されている機能:
レイアウトとテーマシステム:
コンポーネントライブラリには、Stack(縦)とRow(横)のレイアウトシステムも含まれています:
import{ Stack, Row}from"@remix/ui"functionComponentShowcase(this: Remix.Handle){return()=>(<Stack size="xxl"><Stack size="medium"><MenuExample/></Stack><Row><Button>Primary</Button><Button>Secondary</Button></Row></Stack>)}"xxl","medium" などが型チェックされる「Tim(デザイナー)のデザインが素晴らしすぎて、それに見合うものを作らなきゃという気持ちになる」- Ryan Florence

図: Remix UI コンポーネントライブラリのプレビュー
ここからが本題です。ネイティブの<select> 要素を超えるListBox を構築します。
Popover API との統合:
Web 標準のPopover API を使って、ドロップダウンリストを実装します:
functionListBox(this: Remix.Handle, props:{ options:string[]}){let selectedValue= props.defaultValue||nulllet isOpen=falsereturn()=>(<><button type="button" popovertarget="listbox-popover" on={[// Popover の開閉を検知DOM.toggle(()=>{ isOpen=!isOpenthis.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// カスタムイベントを dispatchthis.dispatchEvent(newCustomEvent("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> を持ち、フォームの一部として動作します:
functionListBox(this: Remix.Handle, props:{ name:string, options:string[]}){let selectedValue= props.defaultValue||nullreturn()=>(<>{/* 隠しinput: フォーム送信時に値を送る */}<input type="hidden" name={props.name} value={selectedValue}/><button type="button" popovertarget="listbox-popover">{selectedValue||"Select..."}</button><div id="listbox-popover" popover>{/* ... オプションリスト ... */}</div></>)}// 使用例functionFruitForm(this: Remix.Handle){let formData=nullreturn()=>(<form on={DOM.submit((event, signal)=>{ event.preventDefault() formData=newFormData(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
フォームのリセットへの対応:
functionListBox(this: Remix.Handle, props:{ options:string[], defaultValue?:string}){let selectedValue= props.defaultValue||nullreturn()=>(<div on={[// 親フォームのresetイベントをリッスンDOM.reset(()=>{ selectedValue= props.defaultValue||nullthis.update()})]}>{/* ... ListBox UI ... */}</div>)}「リセットボタンを押すと、watch this(これ見て)... デフォルト状態に戻る。なぜなら、所属するフォームの reset イベントをリッスンしているからだ」- Ryan Florence
Remix のカスタムイベントは、DOM標準のイベントと同様にバブリング します。
親要素でのイベント処理:
functionFormWithListBox(this: Remix.Handle){let selectedFruit=nullreturn()=>(<form on={[// ★ フォーム要素で ListBox の変更を検知 ListBox.change((event)=>{ selectedFruit= event.detail.valueconsole.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つのハンドラで処理
functionMultiSelectForm(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.valuethis.update()})]}><ListBox name="fruit" options={["Apple","Banana"]}/><ListBox name="vegetable" options={["Carrot","Broccoli"]}/><pre>{JSON.stringify(selections,null,2)}</pre></form>)}セッションのクライマックス。Ryan は、Remix コンポーネントをWeb Components として公開できることを実演します。
「僕らのイベントシステム全体は、ただのカスタムイベントなんだ。通常のDOMを通してバブリングする。だから、Web Componentsを含む世界の他のすべてと、すぐに互換性がある」- Ryan Florence
カスタム要素としての使用:
<!-- 普通のHTMLファイル --><!DOCTYPEhtml><html><head><scripttype="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><scripttype="module">// カスタム要素の定義classRmxDisclosureextendsHTMLElement{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
この設計の意義:
このデモで学んだこと:
「抽象化は、本当に必要だと感じるまで導入しない。イベントには型安全性と合成のために必要だった。でも、他の部分は?」- Ryan Florence
Remix 3 のコンポーネントは、特別な状態管理ライブラリを使いません:
let bpm=60// ただの変数更新も明示的:
this.update()// これだけEventTarget とCustomEventAbortController とsignalPointerEvent でマウス・タッチ・ペンを統一「Remix 1 と 2 では TypeScript はサイドクエストみたいなものだった。でも今は、TypeScript が開発体験の中心だ」- Michael Jackson
すべての API が型安全に設計されています:
Ryan は、AI が Drummer クラスを生成したことを何度も強調します。Remix 3 のコードは:
そのため、LLM が理解・生成しやすいのです。
重要なポイント:
「React Router はどこにも行かない。それだけは明確にしておきたい」- Ryan Florence
@remix/events、@remix/ui など)Remix 3 は、フロントエンド開発の複雑さに対するアンチテーゼです。
主要な特徴:
this.update() で制御Ryan と Michael のメッセージ:
「3ヶ月間、日の光を見ていない。でも、これはワクワクする。僕らは正しい山を見つけたと思う」- Ryan Florence
Remix 3 は、Web 開発の未来を再定義しようとしています。シンプルさ、Web 標準、型安全性、そして AI との親和性。これらすべてを兼ね備えた新しいフレームワークの登場を、期待して待ちましょう。
この記事が役に立ったら、ぜひ実際のセッション動画もご覧ください。Ryan のライブコーディングと軽妙なトークは、文字では伝えきれない魅力があります!
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。