EY-Office ブログ

Remix 3のFrameを使いジャンケンアプリを動かしました、苦労したけど面白い!

前々回ブログで少し触れたRemix 3ですが以前からとても気になっていました。Remix 3 発表まとめ - React を捨て、Web標準で新しい世界へというRemix Jam 2025の素晴らしい記事を読んで、早くRemix3を実際に使ってみたいと思っていました。

ただしRemix3は現在は開発中で、まだGetting Started的なRemix3を使ったアプリの作り方入門的なページはありません。

remix3-frame Bing Image Creatorが生成した画像を使っています

開発中のRemix 3のアプリを作る方法

試しにRemix3で検索してみると、Remix 3を実際に動かしてみた 的な記事がいくつか出てきました! 中でもazukiazusaさんのRemix v3 を実際に動かして試してみたは、しっかりと手順と解説が書かれていて大変参考になりました。
しかし、これを真似て(いつもの)ジャンケンアプリを作ろうとしたのですが上手くいきません。Remix3は現在開発中なので、このブログが書かれた時点とは現在では変わっているようです。

実は以前RemixのGitHubをcloneしていて、中にあるサンプルコード(demos/bookstore)の事を思いだし、真剣にコードを読んでみました。このコードはRemix 3を使った書籍販売サイトでのサンプルコードでかなり、管理サイトを含むしっかりしたサンプルコードです。
これを参考に開発環境を作れば良さそうです、Remix 3はアルファ・ベータ版はもちろん、nightlyリリースもないので最新の環境を使うには、Remix関連の最新の全パッケージを含んでいるRemixリポジトリーを使うのが良さそうです。

そこで、demos/jyankenディレクトリーを作り、ここにdemos/bookstoreから設定ファイル等をコピーして始めることにしました。

Remix 3の動作環境

Remix 3のコンセプトはRemix 3 発表まとめ - React を捨て、Web標準で新しい世界へにまとめられていますが、今回判ったRemix 3の動作環境(原理)を簡単に書きます(将来かわるかも知れませんが)。

  • Remix 3はNext.jsと同じくSSR(サーバーサイドレンダリング)を主軸にクライアントサイドのJavaScriptも実行できます
  • React同様にJSXが使われています、しかし仮想DOMやステート管理はありません
  • 全てのリクエストはサーバーで受け付け、ルーティングを通じサーバーサイドのコンポーネントがレンダリングされ、結果のHTMLがブラウザーに送られます
  • サーバーサイドのコンポーネントからクライアントサイドのコンポーネントを呼び出せます
  • クライアントサイドのコンポーネントは当然、動的に画面(DOM)を変更できます
  • サーバーサイドのコンポーネントでは一部を動的に書き変えができるFrameがあります。これは<iframe>のようにコンテンツ内に独立した書き変え可能なエリアをRemix 3フレームワークが実現するものです
  • クライアントからサーバーサイドの関数を呼び出す事はできません、明示的に通信を行っています

Remix 3のジャンケンアプリ

いつものジャンケンアプリです。😄 画像のオレンジ色の点線内がFrame(後で説明します)になっています。

今回のアプリのディレクトリー構成です

  • ① クライアントサイドのコンポーネントは、ここに置かれています
  • ② サーバーサイドのコンポーネント、ここに置かれています
  • app/直下の.tsxはルーティングに対応した特殊なコンポーネントです
  • ④ モデルはここに置かれます
  • ⑤ 一般的なユーティリティーというよりシステムに関連するコード
  • app/assets下のコンポーネントはコンパイル(TypeScript→JavaScript変換、JSXの変換・・・)され、ここに置かれます
.
├── app
│   ├── assets                    ← ①
│   │   ├── entry.tsx
│   │   └── te-button.tsx
│   ├── components                ← ②
│   │   ├── jyanken-box.tsx
│   │   ├── jyanken-main.tsx
│   │   └── score-list.tsx
│   ├── fragments.tsx             ← ③
│   ├── jyanken.tsx
│   ├── models                    ← ④
│   │   └── jyanken.ts
│   ├── router.ts
│   └── utils                     ← ⑤
│       ├── frame.tsx
│       └── render.ts
├── package.json
├── public                        ← ⑥
│   └── assets
│       └── ・・・
├── routes.ts
├── server.ts
└── tsconfig.json

ルーティング

ルーティングの設定ファイルは./routes.ts./app/router.ts に分かれています

./routes.ts

パス・メッソドにルート名を設定しています

import { route } from '@remix-run/fetch-router'

export const routes = route({
  assets: '/assets/*path',             // ← /assets/以下へのアクセスをassets

  jyanken: route('/', {
    index: { method: 'GET', pattern: '/' },  // ← / へのGETアクセスをindex
    pon: { method: 'POST', pattern: '/' },   // ← / へのPOSTアクセスをpon
  }),

  fragments: {    // ← /fragments/jyanken-mainのアクセスをfragments.jyankenMain
    jyankenMain: '/fragments/jyanken-main',
  },
})
./app/router.ts

ルート名と処理するコンポーネントの設定です。ミドルウエアの設定もここで行っています。

import { createRouter } from '@remix-run/fetch-router'
import { logger } from '@remix-run/fetch-router/logger-middleware'
import { formData } from '@remix-run/fetch-router/form-data-middleware'
import { staticFiles } from '@remix-run/fetch-router/static-middleware'
import { routes } from '../routes.ts'
import jyanken from './jyanken.tsx'
import fragmentsHandlers from './fragments.tsx'

let middleware = []

middleware.push(               // ←  publicディレクトリー内のファイルの配信ミドルウエア
  staticFiles('./public', {
    cacheControl: 'no-store, must-revalidate',
    etag: false,
    lastModified: false,
    acceptRanges: false,
  }),
)
middleware.push(logger())
middleware.push(formData())

export const router = createRouter({ middleware })

router.map(routes.jyanken, jyanken);              // ← ルート名と処理の対応付け
router.map(routes.fragments, fragmentsHandlers)   // ← ルート名と処理の対応付け

サーバー

サーバーはNode.jsのHTTPモジュールを使っています。起動メッセージ以外はbookstore /server.tsと同じです。

./server.ts
import * as http from 'node:http'
import { createRequestListener } from '@remix-run/node-fetch-server'
import { router } from './app/router.ts'

const server = http.createServer(
  createRequestListener(async (request) => {
    try {
      return await router.fetch(request)
    } catch (error) {
      console.error(error)
      return new Response('Internal Server Error', { status: 500 })
    }
  }),
)

const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 44100

server.listen(port, () => {
  console.log(`Jyanken app is running on http://localhost:${port}`)
})

その他設定ファイル

以下の設定はbookstoreと同じです。

  • tsconfig.json
  • package.json

モデル

app/models/jyanken.ts

MVCのモデルです、今回はジャンケンのロジックとジャンケン結果を格納する配列とアクセスメソッドがあります。

export const Te = {
  Guu: 0,
  Choki: 1,
  Paa: 2
} as const;
export type Te = (typeof Te)[keyof typeof Te]

export const Judgment = {
  Draw: 0,
  Win: 1,
  Lose: 2
} as const
export type Judgment = (typeof Judgment)[keyof typeof Judgment]

export type Score = {
  human: Te,
  computer: Te,
  judgment: Judgment,
  matchDate: Date
}

export type Status = {
  draw: number,
  win: number,
  lose: number
}

export const randomHand = (): Te => {
  return Math.floor(Math.random() * 3) as Te
}

export const judge = (humanHand: Te, computerHand: Te): Judgment => {
  return (computerHand - humanHand + 3) % 3 as Judgment
}

//  --------------------------------

let scores: Score[] = []

export const getScores = () => scores

export const createScore = (humanHand: Te) => {
  const computerHand = randomHand()
  const judgment = judge(humanHand, computerHand)
  const score: Score = {
    human: humanHand,
    computer: computerHand,
    judgment,
    matchDate: new Date()
  }
  scores = [score, ...scores]
}

ルーティング処理ハンドラー

app/jyanken.tsx

このファイルはルーティングに対応したリクエストを処理するためのモジュールです。

  • ① 処理にミドルウエアを組み込めます、ここでは使っていませんがbookstoreでは認証等に使っています。
  • ② index(GET /)に対する処理でページ全体をサーバー側で生成し戻すします
  • ③ ブラウザー上でクライアントサイドのJavaScriptファイルを読み込むentry.jsを起動しています
  • ④ Tailwind CSSは暫定的に全スタイルをCDNから読み込んでいます
  • ⑤ ジャンケン・ボタンと結果リストの入ったコンポーネントをFrameを使ってここに描画しています
  • ⑥ pon(POST /)に対する処理で受け取った手をモデルのジャンケン処理createScore()に渡しジャンケンを行い、ステータス204を戻しています
import type { RouteHandlers } from '@remix-run/fetch-router'
import { routes } from '../routes.ts'
import { render } from './utils/render.ts'
import {  createScore, type Te } from './models/jyanken.ts'
import { Frame } from '@remix-run/dom'

export default  {
  middleware: [],     // ← ①
  handlers: {
    index() {         // ← ②
      return render(
        <html lang="en">
            <head>
              <meta charSet="UTF-8" />
              <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                                                {/* ↓ ③ */}
              <script type="module" async src={routes.assets.href({ path: "entry.js" })} />
                                                {/* ↓ ④ */}
              <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
              <title>Jyanken</title>
            </head>
            <body>
              <div class="mx-8 w-1/2">
                <h1 class="my-6 text-center text-xl font-bold">
                  ジャンケンポン
                </h1>                           {/* ↓ ⑤ */}
                <Frame src={routes.fragments.jyankenMain.href()} />
              </div>
            </body>
          </html>
      )
    },
    async pon({ formData }) {  // ← ⑥
      createScore(Number(formData.get('human')?.toString() ?? '0') as Te)
      return new Response(null, { status: 204 })
    }
  }
} satisfies RouteHandlers<typeof routes.jyanken>
app/fragments.tsx

Frameの内部を描画する処理のモジュールです。
ここではjyankenMain(GET /fragments/jyanken-main)に対してはJyankenMainコンポーネントのレンダリング結果を戻しています。

import type { RouteHandlers } from "@remix-run/fetch-router"
import { render } from "./utils/render"
import type { routes } from "../routes"
import JyankenMain from "./components/jyanken-main"

export default {
  middleware: [],
  handlers: {
    jyankenMain() {
      return render(<JyankenMain />)
    },
  },
} satisfies RouteHandlers<typeof routes.fragments>

サーバーサイドのコンポーネント

サーバーサイドでレンダリングされるコンポーネントは、React Server Componentsのサーバーコンポーネントと同様なReactコンポーネントです。

app/components/jyanken-main.tsx

過去に作ったReactアプリには無かったコンポーネントです。しかしFrameでレンダリングされるコンポーネントにはクライアント側で再レンダリングを起こすコンポーネントと再描画されるコンポーネントが1つのコンポーネントになっている必要があるようです(これは困るような気もしますが、将来改良されるのかな?)。

import { getScores } from "../models/jyanken"
import JyankenBox from "./jyanken-box"
import ScoreList from "./score-list"

export default function JyankenMain ()  {
  const scores = getScores();

  return (
    <>
      <JyankenBox />
      <ScoreList scores={scores}/>
    </>
  )
}
app/components/score-list.tsx

React版からコピーした、普通のReactコンポーネントです。

import type { Score } from '../models/jyanken.ts'
const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"]

type ScoreListProps = {
  scores: Score[]
}
export default function ScoreList ({scores}: ScoreListProps)  {
  const header=["時間", "人間", "コンピュータ", "結果"]
  return (
    <table class="w-full text-sm text-left text-gray-500">
      <thead class="bg-sky-100 border">
        <tr>
          {header.map((title, ix) => (
            <th key={ix} scope="col" class="px-6 py-3">
              {title}
            </th>
          ))}
        </tr>
      </thead>
      <tbody class="bg-white border">
        {scores.map((score, ix) => (
          <ScoreListItem key={ix} score={score} />
        ))}
      </tbody>
    </table>
  )
}

type ScoreListItemProps = {
  score: Score
}
function ScoreListItem({score}: ScoreListItemProps) {
  const teString = ["グー", "チョキ", "パー"]
  const judgmentString = ["引き分け", "勝ち", "負け"]
  const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8)
  const tdClass = `px-2 md:px-6 py-4 ${JudgmentColor[score.judgment]}`

  return (
    <tr class="bg-white border-b">
      <td class={tdClass}>{dateHHMMSS(score.matchDate)}</td>
      <td class={tdClass}>{teString[score.human]}</td>
      <td class={tdClass}>{teString[score.computer]}</td>
      <td class={tdClass}>{judgmentString[score.judgment]}</td>
    </tr>
  )
};
app/components/jyanken-box.tsx

TeButtonはクライアントサイドで動くジャンケンの手ボタンのコンポーネントです。
最初<div>タグは無かったのですが、その時はグーのボタンしか正しく動作しませんでした。いろいろ試した結果<div>で括るとこで全てのボタンが動作するようになりました。

import { Te } from "../models/jyanken.ts"
import { TeButton } from "../assets/te-button.tsx"

export default function JyankenBox () {
  return (
    <div className="w-[270px] mx-auto flex justify-center mb-10">
      <div class="flex-1"><TeButton value={Te.Guu} /></div>
      <div class="flex-1"><TeButton value={Te.Choki} /></div>
      <div class="flex-1"><TeButton value={Te.Paa} /></div>
    </div>
  );
}

クライアントサイドのコンポーネント

ジャンケン・ボタンのイベントを扱うコンポーネントは当然クライアントサイドで動作する必要があります。

app/assets/te-button.tsx
  • ① クライアントサイドで動くコンポーネントはハイドレーションされるのでhydrated関数で定義しています
    • 初期表示の際にはこのコンポーネントもサーバーサイドでレンダリングされ、画面表示後にクライアントサイドのJavaScriptのイベント定義等が行われます
  • ② コンポーネントのファイル名、コンポーネント名を明示的に定義する必要があります
    • 面倒な気もしますが、フレームワークが見えない魔術を使わない方針のようです
  • ③ このfunctionでこのコンポーネントを呼出すコンポーネントがthisになるようにしています(動的なthisバインディング)
    • 詳しくはAIに聞いてください
  • ④ Reactと違いステート管理などは不要で、通常の変数で状態を持てます
    • postingは送信中を表す変数で、ボタンの透明度をコントロールしています
  • ⑤ Propsを受け取る無名関数でコンポーネントのを定義しています
import { type Remix, hydrated } from '@remix-run/dom'
import { dom } from '@remix-run/events'
import { routes } from '../../routes.ts'

const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
const TeString = ["グー", "チョキ", "パー"];

export const TeButton = hydrated(                          // ← ①
  routes.assets.href({ path: 'te-button.js#TeButton' }),   // ← ②
  function (this: Remix.Handle) {                          // ← ③
    let posting = false;                                   // ← ④

    return ({ value }: { value: number }) => {             // ← ⑤
      const route = routes.jyanken.pon;
      const method = route.method;
      const action = route.href();
      const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
        bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;

      return (
       <form method={method} action={action}
          on={dom.submit(async (event, signal) => {
            event.preventDefault()
            const body = new FormData(event.currentTarget)

            posting = true
            this.update()                                  // ← ⑥

            // await sleep(500);                           // ← ⑦

            await fetch(action, { method, signal, body })  // ← ⑧
            if (signal.aborted) return                     // ← ⑨

            await this.frame.reload()                      // ← ⑩
            if (signal.aborted) return

            posting = false
            this.update()
          })}>
                                                         {/* ↓ ④ */}
          <button type="submit" class={buttonClass} style={{opacity: posting ? 0.5 : 1.0}}>
            {TeString[value]}
          </button>
          <input type="hidden" name="human" value={value.toString()} />
        </form>
      )
    }
  }
)
  • ⑥ 色々な記事で取り上げられているように、Remix 3ではコンポーネントの再描画は明示的にthis.update()を書きます
  • ⑦ ここでsleepするとボタンの透明度が変わるのが見えます
  • ⑧ Remix 3にはServer Functionは無いので、明示的に通信しています
  • ⑨ 通信の中断をサポートしています
  • ⑩ このコンポーネントを含むFrameを再描画します
    • サーバーにGET /fragments/jyanken-mainを送り、戻ってきたHTML片でFrameの部分のみ再描画します

ユーティリティー

システムに関連する部分をカスタマイズ出来るように幾つかの関数がユーティリティーになっています。

app/utils/render.ts

ルーティング処理ハンドラーから呼び出されるrender関数です。ここではFrameに対応するためにresolveFrameを指定しています。

import type { Remix } from '@remix-run/dom'
import { renderToStream } from '@remix-run/dom/server'
import { html } from '@remix-run/fetch-router/response-helpers'

import { resolveFrame } from './frame.tsx'

export function render(element: Remix.RemixElement, init?: ResponseInit) {
  return html(renderToStream(element, {resolveFrame}), init)
}
app/utils/frame.tsx

上のrenderからFrameのレンダリング時に呼びだされます。通常のレンダリングでは画面全体が表示されますがFrameの場合は対応するコンポーネントを戻します。
今回のアプリではFrameは1つしかないので、シンプルなコードになっていますがbookstoreのサンプルでは対応するコンポーネントのPropsを作成したりしています。

import JyankenMain from '../components/jyanken-main.tsx'

export async function resolveFrame(frameSrc: string) {
  return <JyankenMain />
}
app/assets/entry.tsx

このファイルは、クライアントサイドのJavaScriptファイルのローダーとFrameを読み込むコードです、YouTubeのIntroducing Remix 3 Part 2で特殊なカスタマイズが出来るようにこのファイルがアプリ側にあるそうです。今回はbookstoreサンプルと同じです。

import { createFrame } from '@remix-run/dom'

createFrame(document, {
  async loadModule(moduleUrl, name) {
    let mod = await import(moduleUrl)
    if (!mod) {
      throw new Error(`Unknown module: ${moduleUrl}#${name}`)
    }

    let Component = mod[name]
    if (!Component) {
      throw new Error(`Unknown component: ${moduleUrl}#${name}`)
    }

    return Component
  },

  async resolveFrame(frameUrl) {
    let res = await fetch(frameUrl)
    if (res.ok) {
      return res.text()
    }

    throw new Error(`Failed to fetch ${frameUrl}`)
  },
})

まとめられないので、次回予告

実は今回のサンプルコードが動くまでに2日以上かかってしまい。やっとブログが書けた状況です。😅
今回のRemix 3のFrameに付いての説明は判りにくいかと思いますので、次回のブログではRemix 3のFrameの全体の流れをわかりやすく説明したいと思います。

私のRemix 3の感想はNext.js(RSC)と違い色々とコードを書かないといけないフレームワークですが、謎ルールによって動いている部分がなく動作が理解しやすいフレームワークだと思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ