Movatterモバイル変換


[0]ホーム

URL:


Coji MizoguchiCoji Mizoguchi
💿️

Remix 3 発表まとめ - React を捨て、Web標準で新しい世界へ

に公開
2025/10/13
1件

はじめに

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分に及ぶセッションの内容を詳しく解説します。

!

注意事項

  • この記事は、セッション動画の音声を AI で文字起こしし、その内容をもとに AI を活用して執筆しています
  • 記事内のコード例は、セッションでの説明をもとに再構成したものですが、実際の動作確認はまだ行えていません
  • Remix 3 は現在プロトタイプ段階のため、実際の API や仕様は変更される可能性があります
  • コードの正確性については、追って確認・修正する予定です
  • より正確な情報については、上記の動画を直接ご確認ください
!

この記事は、セッションの前半部分(Part 1)のみを扱います。バックエンド関連の内容は Part 2 で語られました。

なぜ Remix 3 を作るのか

💡動画で確認する (3:17:30~)

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: 暗号化機能が標準に

💡動画で確認する (3:22:45~)

AI 時代のフレームワーク

Ryan は、AI 時代のフレームワークに必要な要素について語ります:

  • 安定した URL: LLM がアクションを実行するため、URL は常に同じである必要がある
  • シンプルなコード: AI が生成・理解しやすいコード
  • バンドラーへの依存を減らす: ランタイムセマンティクスがバンドラーに依存しない

React のuse server では、RPC 関数の URL がビルドごとに変わってしまうため、AI がそれを利用することが困難です。

💡動画で確認する (3:19:34~)

Remix 3 の核心アイデア

セットアップスコープ (Setup Scope)

💡動画で確認する (3:50:03~)

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/>)

重要なポイント:

  1. セットアップコードは1回だけ実行される
  2. 状態は JavaScript のクロージャに保存される(Remix の特別な機能ではない)
  3. 再レンダリングはthis.update() を明示的に呼ぶ

「ボタンはどうやって BPM が変わったことを知るの?知らない。それが Remix 3 の素晴らしいところ。これはただの JavaScript スコープ。君がupdate() を呼んだ時だけ、レンダー関数を再実行する」- Ryan Florence

Remix Events: イベントを第一級市民に

💡動画で確認する (3:34:50~)

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"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

Remix Events の概念図
図: Components are to elements as custom interactions are to events

Context API: 再レンダリングを引き起こさない

💡動画で確認する (4:07:36~)

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>)}

重要なポイント:

  1. context.set() は再レンダリングを引き起こさない
  2. context.get(Component) でプロバイダーを直接参照("Go to Definition" が効く!)
  3. 型安全: プロバイダーコンポーネントの型から自動推論

Context API の実装
図: Go to Definition でプロバイダーに直接ジャンプできる

Signal: 非同期処理の管理

💡動画で確認する (4:42:39~)

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()})}>

ユーザーが連続してセレクトボックスを変更すると:

  1. 古いハンドラーの signal が abort される
  2. fetch() が自動的にキャンセルされる
  3. signal.aborted チェックで古い処理をスキップ

Signal でレースコンディションを解決
図: ネットワークタブで古いリクエストがキャンセルされている様子

これにより、レースコンディションを手動で、しかしシンプルに解決できます。

実際のデモから学ぶ

デモ1: カウンターからテンポタッパーへ

💡動画で確認する (3:29:03~)

Ryan は最もシンプルな例から始めます。

ステップ1: プレーンJSでカウンター → テンポタッパー

まずは、プレーンな 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で実装したカウンター

退屈だから、もっと面白いものへ

💡動画で確認する (3:32:13~)

「退屈だな。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 を算出しています:

  1. 直近4秒間のタップを配列に保存
  2. タップ間の間隔(ミリ秒)を計算
  3. 各間隔から BPM を計算(60000 / interval)
  4. すべての BPM を平均して表示

BPM計算ロジック
図: タップ間隔を計算して平均BPMを算出

ステップ2: Remix Events でイベントを抽象化

💡動画で確認する (3:34:50~)

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"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

Remix Events の概念図
図: Components are to elements as custom interactions are to events

重要なポイント:

  1. 状態とイベントをカプセル化:taps 配列やresetTimertempo インタラクション内部に隠蔽
  2. 型安全:createInteraction<HTMLElement, number> で型を定義
  3. 再利用可能: どこでもtempo インタラクションを使える
  4. 合成可能:pressDown は内部でpointerdownkeydown を統合

ステップ3: Remix 3 のコンポーネント化

💡動画で確認する (3:50:03~)

「みんな、コンポーネントを見せろって言ってる。よし、コンポーネントにしよう」- 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

セットアップスコープの特徴:

  1. 1回だけ実行される: コンポーネントの初期化時のみ
  2. 状態は JavaScript のクロージャに保存: 特別な機能ではなく、普通の JavaScript
  3. this.update() で明示的に再レンダリング: 自動的な依存性追跡はなし

tempo カスタムインタラクションが、先ほどの複雑なタップ計算ロジックをすべてカプセル化しています。コンポーネントは結果を受け取って表示するだけです。

コンポーネント化されたテンポタッパー
図: Remix 3 コンポーネントとして実装されたテンポタッパー

デモ2: ドラムマシン

💡動画で確認する (3:56:02~)

完全なドラムマシンアプリを構築し、Context APIEventTargetqueueTask など Remix 3 の核心機能を実演します。

構築する機能:

  • Play/Stop ボタン
  • テンポ調整(BPM)
  • リアルタイムビジュアライザー(kick、snare、hi-hat の音量表示)
  • キーボードショートカット(Space: 再生/停止、Arrow Up/Down: テンポ変更)

ステップ1: Drummer クラスを AI に生成させる

「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(){// ...}// ...}

重要なポイント:

  1. EventTarget を継承 → 標準的な DOM イベントモデルを利用
  2. CustomEvent で変更を通知 → どんなコンポーネントでもリッスンできる
  3. 特別な Remix 用の型は不要 → 普通の JavaScript クラス

「重要なのは、これが特別な型である必要がないってこと。Cursor に頼めば吐き出してくれる。動けば使う。動かなければもう一回試す」- Ryan Florence

ステップ2: Context API でアプリ全体に Drummer を共有

💡動画で確認する (4:06:54~)

前半で学んだ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 ContextRemix Context
Provider コンポーネントが必要this.context.set() だけ
Context 変更 = 再レンダリングContext 変更しても再レンダリングなし
Provider を探すのが大変Go to Definition で即座に見つかる

ステップ3: 型安全なイベントを作る(createEventType)

💡動画で確認する (4:04:25~)

カスタムイベントを型安全にするため、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

ステップ4: queueTask で DOM 更新後の処理

💡動画で確認する (4:24:38~)

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 が更新された後!

ステップ5: キーボードイベントの統合

💡動画で確認する (4:31:08~)

前半で学んだ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 キーでドラムマシンを操作

このデモで学んだこと:

  1. EventTarget の活用: 標準的な DOM イベントモデルで状態を管理
  2. Context API: 再レンダリングなしでアプリ全体に値を共有
  3. 型安全なイベント:createEventType でカスタムイベントを型安全に
  4. queueTask: DOM 更新後の処理を安全に実行
  5. セマンティックなキーイベント:spacearrowUp などで意図を明確に
  6. AI フレンドリー: Drummer クラスは AI が生成できる普通の JavaScript

デモ3: フォームと非同期処理(Signal によるレースコンディション解決)

!
  • ここ以降に出てくるソースコードは、文字起こしで話されている内容から、AI がコードを推測したものなので、正しくない可能性が高いです。
  • 今後、動画を見直してソースコードを確認して修正してきます。

💡動画で確認する (4:37:24~)

前半で学んだ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 に異なる遅延を設定しています:

  • Alabama: 300ms
  • Alaska: 500ms
  • Kansas: 5000ms(意図的に遅い)

ユーザーが素早く選択を変更すると:

  1. Kentucky を選択 → fetch 開始(500ms)
  2. Illinois を選択 → fetch 開始(1000ms)
  3. Arizona を選択 → fetch 開始(800ms)

結果: どの fetch が最後に完了するかによって、表示される都市リストが変わってしまう!

「Louisville(Kentucky)、Illinois、Phoenix(Arizona)って表示された。問題だよね?」- Ryan Florence

レースコンディション問題
図: 連続して選択を変更すると、最後に完了した fetch の結果が表示される

解決策: Signal を使う

💡動画で確認する (4:42:50~)

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 でレースコンディション解決
図: Signal を使うと、最新のリクエストだけが完了する

Signal の2つの使い方

1. fetch API に渡す(推奨)

const response=awaitfetch(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 が提供されます:

  1. this.signal: コンポーネントがマウント/アンマウントされた時に abort
  2. イベントコールバックのsignal: 関数が再度呼ばれた時、または、コンポーネントがアンマウントされた時に abort
  3. レンダー中のsignal: 再レンダリングされた時に abort(通常は使わない)

「関数を渡したら、signal をあげる。これがルール。あなたがその中で何をするか分からないからね」- Ryan Florence

このデモで学んだこと:

  1. レースコンディションの理解: 複数の非同期処理が同時進行する問題
  2. Signal の基本:AbortSignal を使って古い処理をキャンセル
  3. fetch API との統合:{ signal } を渡すだけで自動キャンセル
  4. 手動チェック: 長時間処理の後はsignal.aborted をチェック
  5. 明示的な制御: 必要な時だけ abort する設計
  6. Web 標準:AbortController は Web 標準 API

デモ4: ListBox - Web標準の統合デモ

💡動画で確認する (4:48:56~)

これまで学んだ複数の概念(Remix Events、Web標準API)を統合した実例です!

Ryan は、Remix 3 と並行してコンポーネントライブラリ を開発しており、その中核となるListBox コンポーネントを通じて、Web 標準との統合方法を示します。

「UIフレームワークとして relevantであるためには、簡単に組み合わせられるコンポーネントが必要だ。フルスタック体験を目指している」- Ryan Florence [01:23:07]

ステップ1: 基礎 - ネストされたドロップダウンメニュー

💡動画で確認する (4:49:11~)

「コンポーネントモデルが動くようになった瞬間、最も難しいネストされたドロップダウンメニューを作り始めた」- Ryan Florence [01:24:11]

まず、最も複雑なコンポーネントから開始します:

実装されている機能:

  • ホバーインテント: マウスが境界を横切っても意図を理解して消えない
  • 3階層のネスト: サブメニューのサブメニューまで対応
  • キーボードナビゲーション: 完全なアクセシビリティ対応
  • Remix Events: カスタムイベントで駆動

レイアウトとテーマシステム:

コンポーネントライブラリには、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>)}
  • CSS カスタムプロパティベース: サーバーレンダリングと相性が良い
  • 型安全なサイズ指定:"xxl","medium" などが型チェックされる

「Tim(デザイナー)のデザインが素晴らしすぎて、それに見合うものを作らなきゃという気持ちになる」- Ryan Florence

コンポーネントライブラリ
図: Remix UI コンポーネントライブラリのプレビュー

ステップ2: ListBox の構築 - Popover API とフォーム統合

💡動画で確認する (4:43:07~)

ここからが本題です。ネイティブの<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 のポイント:

  1. popover 属性 → 自動的にトップレイヤーに配置
  2. popovertarget → ボタンとポップオーバーを接続
  3. toggle イベント → 開閉を検知できる

「Popover API は素晴らしい。トップレイヤーに行く。イベントもある。popoverTargetToggle を使えば、ボタンが所有するポップオーバーがいつ開くかリッスンできる。カスタムイベントを使えば、通常は接続されていないものを接続できるんだ」- Ryan Florence

リアルなフォーム要素として動作:

💡動画で確認する (4:44:18~)

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

ステップ3: イベントのバブリング

💡動画で確認する (4:46:03~)

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>)}

ステップ4: Web Components との互換性

💡動画で確認する (4:50:55~)

セッションのクライマックス。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-disclosuredisclosure-button があって、これらはただの Web Components だ。addEventListenerdisclosure:toggle をリッスンできる」- Ryan Florence

Web Components デモ
図: HTMLファイル内でカスタム要素として使用される Remix コンポーネント

マイクロフロントエンドへの応用:

「Remix で完全なアプリを作れるだけじゃない。Web Components の中に隠すこともできる。そうすれば、世界の他の部分と簡単に互換性を持たせられる。レガシーシステムや、AI チャットアプリに埋め込むとか、そういう新しいユースケースにも対応できる」- Ryan Florence

この設計の意義:

  1. 既存システムへの段階的導入: レガシーアプリに Remix コンポーネントを少しずつ追加
  2. フレームワーク間の相互運用: React、Vue、Angular などと共存
  3. AI エージェントへの埋め込み: チャットボットや AI インターフェースにコンポーネントを提供
  4. 標準への準拠: Web 標準に基づいているため、将来性がある

このデモで学んだこと:

  1. Popover API: Web標準のAPIとの統合
  2. フォーム統合: submit/resetイベントへの自動対応
  3. イベントのバブリング: 親要素での一括処理
  4. Web Components: 標準技術との完全な互換性
  5. 型安全なコンポーネント: TypeScript でのDX向上
  6. AI フレンドリー: 標準技術ベースで LLM が理解しやすい

Remix 3 の設計思想

抽象化は最小限に

「抽象化は、本当に必要だと感じるまで導入しない。イベントには型安全性と合成のために必要だった。でも、他の部分は?」- Ryan Florence

Remix 3 のコンポーネントは、特別な状態管理ライブラリを使いません:

let bpm=60// ただの変数

更新も明示的:

this.update()// これだけ

Web 標準を最大限活用

  • EventTargetCustomEvent
  • AbortControllersignal
  • 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 は継続される

💡動画で確認する (3:19:04~)

重要なポイント:

  • React Router は継続されます
  • Shopify など多くの企業が React Router に依存
  • Remix チームが React Router V7 を開発中
  • Remix 3 は別の選択肢として提供

「React Router はどこにも行かない。それだけは明確にしておきたい」- Ryan Florence

現在のステータス

💡動画で確認する (3:40:32~)

  • プロトタイプ段階
  • ブログ投稿の3ヶ月後に開発開始
  • 個別パッケージとして公開中(@remix/events@remix/ui など)
  • 最終的には統合されたフレームワークとして提供予定
  • コンポーネントライブラリも開発中(ドロップダウンメニュー、テーマシステムなど)

まとめ

Remix 3 は、フロントエンド開発の複雑さに対するアンチテーゼです。

主要な特徴:

  1. Setup Scope: JavaScript のクロージャを活用した状態管理
  2. Remix Events: イベントを第一級市民として扱う
  3. 明示的な再レンダリング:this.update() で制御
  4. 型安全な Context API: 再レンダリングを引き起こさない
  5. Signal による非同期管理: レースコンディションをシンプルに解決
  6. Web 標準ベース: バンドラーへの依存を最小化
  7. TypeScript ファースト: すべての API が型安全
  8. AI フレンドリー: LLM が理解・生成しやすいコード

Ryan と Michael のメッセージ:

「3ヶ月間、日の光を見ていない。でも、これはワクワクする。僕らは正しい山を見つけたと思う」- Ryan Florence

Remix 3 は、Web 開発の未来を再定義しようとしています。シンプルさ、Web 標準、型安全性、そして AI との親和性。これらすべてを兼ね備えた新しいフレームワークの登場を、期待して待ちましょう。


参考リンク


この記事が役に立ったら、ぜひ実際のセッション動画もご覧ください。Ryan のライブコーディングと軽妙なトークは、文字では伝えきれない魅力があります!

GitHubで編集を提案
Coji Mizoguchi

こんにちは!

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。

atmanatman

これは楽しみですねー

1

こんにちは!

目次
  1. はじめに
  2. なぜ Remix 3 を作るのか
    1. React への感謝と決別
    2. 現代のフロントエンド開発の複雑さ
    3. Web プラットフォームの進化
    4. AI 時代のフレームワーク
  3. Remix 3 の核心アイデア
    1. セットアップスコープ (Setup Scope)
    2. Remix Events: イベントを第一級市民に
    3. Context API: 再レンダリングを引き起こさない
    4. Signal: 非同期処理の管理
  4. 実際のデモから学ぶ
    1. デモ1: カウンターからテンポタッパーへ
    2. デモ2: ドラムマシン
    3. デモ3: フォームと非同期処理(Signal によるレースコンディション解決)
    4. デモ4: ListBox - Web標準の統合デモ
  5. Remix 3 の設計思想
    1. 抽象化は最小限に
    2. Web 標準を最大限活用
    3. TypeScript ファーストの開発体験
    4. LLM で生成しやすいコード
  6. React Router は継続される
  7. 現在のステータス
  8. まとめ
  9. 参考リンク

[8]ページ先頭

©2009-2025 Movatter.jp