
Remix v3 を実際に動かして試してみた
2025 年 10 月に発表された Remix v3 は React から独立し、Web 標準技術を活用した新しいフレームワークへと進化しました。この記事では、Remix v3 のセットアップ手順と新機能を実際に動かして試してみた内容を紹介します。
2025 年 10 月 10 日に行われた Remix Jam 2025 にて、Remix v3 が発表されました。Remix v3 は React から離れ、独自のフレームワークとして再設計されるという大きな変更が行われています。Remix v3 で新しいアーキテクチャが導入された理由について、以下の 3 つが挙げられています。
- 従来のフロントエンドエコシステムの複雑性の解消
- Web 標準への回帰とシンプルなモデルの追求
- AI エージェント時代への対応
この記事では、Remix v3 の新機能と変更点を実際に動かして試してみた内容を紹介します。
Remix v3 のセットアップ
Remix v3 は 2025 年 10 月現在開発中のパッケージです。以下の手順は将来変更される可能性があります。
それでは Remix v3 をセットアップしてみましょう。コードの内容は以下のレポジトリを参考にしています。
Remix v3 はいくつかの @remix-run
スコープのパッケージとして提供されています。まずは以下の 5 つのパッケージをインストールします。
@remix-run/dom
: Remix のための JSX ランタイム@remix-run/events
: Remix のためのイベントシステム@remix-run/node-fetch-server
: Node.js, Deno, Bun といったランタイムの違いを吸収するためのfetch
API 実装@remix-run/fetch-router
:fetch
API を使用したルーティングシステム@remix-run/lazy-file
: ファイルを遅延読み込みするためのユーティリティ
原因は不明ですが、playwright
パッケージをインストールしていないと npm error sh: playwright: command not found
エラーが発生するので、あらかじめ playwright
もインストールしておきます。
npm install playwright
その後、以下のコマンドで Remix v3 のパッケージとビルド関連ツールをインストールします。
npm install @remix-run/dom @remix-run/events @remix-run/node-fetch-server @remix-run/fetch-router @remix-run/lazy-file
npm install -D @types/node esbuild tsx
次に、package.json
の scripts
セクションに dev
スクリプトを追加します。また ESModule を使用するために type
フィールドも追加します。
{
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx watch server.ts",
"dev:browser": "esbuild app/assets/*.tsx --outbase=app/assets --outdir=public/assets --bundle --minify --splitting --format=esm --entry-names='[dir]/[name]' --chunk-names='chunks/[name]-[hash]' --sourcemap --watch",
}
}
tsconfig.json
ファイルを作成し、以下の内容を追加します。ここでのポイントは jsxImportSource
オプションで @remix-run/dom
を指定している点です。これにより JSX の変換に Remix の JSX ランタイムが使用されるようになります。
{
"compilerOptions": {
"strict": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ES2022",
"moduleResolution": "Bundler",
"target": "ESNext",
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "@remix-run/dom"
}
}
ルーティングを定義する routes.ts
ファイルを作成します。ルーティングの定義は @remix-run/fetch-router
パッケージからインポートした route
関数を使用して行います。
import { route, resources } from '@remix-run/fetch-router'
export const routes = route({
assets: '/assets/*path',
"home": "/",
"about": "/about",
// resources は CRUD ルート(/, /:id, /:id/create, /:id/edit など)を簡単に定義できるユーティリティ
"posts": resources("/posts", {
param: "postId"
})
})
app/router.ts
ファイルを作成し、routes.ts
で定義したルーティングを使用して Router
インスタンスを作成します。
import { createRouter } from '@remix-run/fetch-router'
import { routes } from '../routes.ts'
import { assets } from "./public.ts"
export const router = createRouter()
router.get(routes.assets, assets);
// TODO: 後から Remix のコンポーネントに差し替える
router.get(routes.home, async () => {
return new Response('<h1>Home Page</h1>', {
headers: { 'Content-Type': 'text/html' },
})
})
assets
ハンドラーは app/public.ts
ファイルに実装します。ここでは @remix-run/lazy-file
パッケージからインポートした openFile
関数を使用して、public
ディレクトリ内の静的ファイルを配信するハンドラーを作成しています。
import * as path from "node:path";
import type { InferRouteHandler } from "@remix-run/fetch-router";
import { openFile } from "@remix-run/lazy-file/fs";
import { routes } from "../routes.ts";
const publicDir = path.join(import.meta.dirname, "..", "public");
const publicAssetsDir = path.join(publicDir, "assets");
export const assets: InferRouteHandler<typeof routes.assets> = async ({
params,
}) => {
return serveFile(path.join(publicAssetsDir, params.path));
};
function serveFile(filename: string): Response {
try {
const file = openFile(filename);
return new Response(file, {
headers: {
"Cache-Control": "no-store, must-revalidate",
"Content-Type": file.type,
},
});
} catch (error) {
if (isNoEntityError(error)) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
function isNoEntityError(
error: unknown
): error is NodeJS.ErrnoException & { code: "ENOENT" } {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
最後に、サーバーエントリーポイントとなる server.ts
ファイルを作成します。ここでは @remix-run/node-fetch-server
パッケージからインポートした createRequestListener
関数を使用してサーバーを作成し、routes.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(`Server is running on http://localhost:${port}`)
})
以上で Remix v3 のセットアップは完了です。以下のコマンドで開発サーバーを起動します。
npm run dev
ブラウザで http://localhost:44100
にアクセスすると、<h1>Home Page</h1>
と表示されることが確認できます。
Remix コンポーネントを作成する
それでは Remix のコンポーネントを作成し、新しい機能を試してみましょう。app/utils/render.ts
ファイルを作成し、サーバーで JSX コンポーネントをレンダリングするためのユーティリティ関数を実装します。
import type { Remix } from "@remix-run/dom";
import { renderToStream } from "@remix-run/dom/server";
import { html } from "@remix-run/fetch-router";
export function render(element: Remix.RemixElement, init?: ResponseInit) {
return html(renderToStream(element), init);
}
layout.tsx
ファイルを作成し、共通のレイアウトコンポーネントを実装します。
import type { Remix } from "@remix-run/dom";
import { routes } from "../routes.ts";
export function Document({
title = "my remix app",
children,
}: {
title?: string;
children?: Remix.RemixNode;
}) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>{children}</body>
</html>
);
}
export function Layout({ children }: { children?: Remix.RemixNode }) {
return (
<Document>
<header>
<nav>
<a href={routes.about.href()}>About</a>
<a href={routes.posts.index.href()}>Posts</a>
<a href={routes.posts.new.href()}>New Post</a>
</nav>
</header>
<main>
<div>{children}</div>
</main>
</Document>
);
}
app/Home.tsx
ファイルを作成し、ホームページのコンポーネントを実装します。
import type { InferRouteHandler, RouteHandlers } from '@remix-run/fetch-router'
import { routes } from '../routes.ts'
import { render } from './utils/render.ts'
import { Layout } from './layout.tsx'
export const Home: InferRouteHandler<typeof routes.home> = () => {
return render(
<Layout>
<h1>Welcome to Remix v3!</h1>
<p>This is the home page.</p>
</Layout>
);
};
app/router.ts
ファイルを編集し、ホームページのルートハンドラーとして Home
コンポーネントを使用するように変更します。
import { createRouter } from '@remix-run/fetch-router'
import { routes } from '../routes.ts'
import { Home } from './Home.tsx'
export const router = createRouter()
router.get(routes.home, Home)
再度開発サーバーを起動し、ブラウザで http://localhost:44100
にアクセスすると、Welcome to Remix v3!
と表示されることが確認できます。
状態管理とイベントハンドリング
Remix v3 の新しいイベントシステムを使用して、状態管理とイベントハンドリングを実装してみましょう。状態管理とイベントハンドリングを伴うコンポーネントはサーバーサイドではなくクライアントサイドで動作するため、app/assets/
ディレクトリ内に配置し、JavaScript ファイルを /assets/
パスで配信するようにします。
まずは app/assets/entry.ts
ファイルを作成し、assets
ディレクトリの JavaScript ファイルを読み込めるようにします。
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}`);
},
});
layout.tsx
ファイルの <Document>
を編集し、script
タグで entry.ts
ファイルを読み込むようにします。
import type { Remix } from "@remix-run/dom";
import { routes } from "../routes.ts";
export function Document({
title = "my remix app",
children,
}: {
title?: string;
children?: Remix.RemixNode;
}) {
return (
<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" })}
/>
<title>{title}</title>
</head>
<body>{children}</body>
</html>
);
}
app/assets
ファイルのファイルが assets
パスで配信されるようにするため、別プロセスで esbuild
を実行してバンドルします。
npm run dev:browser
app/assets/Counter.tsx
ファイルを作成し、カウンターコンポーネントを実装しましょう。/assets/
パスとコンポーネントの紐づけは @remix-run/dom
パッケージの hydrated
関数を使用して行います。
import { type Remix, hydrated } from "@remix-run/dom";
import { press } from "@remix-run/events/press";
import { routes } from "../../routes";
export const Counter = hydrated(
routes.assets.href({ path: "Counter.js#Counter" }),
function (this: Remix.Handle) {
let counter = 0;
return () => (
<button
on={press(() => {
counter++;
this.render();
})}
>
{`Clicked ${counter} times`}
</button>
);
}
);
Remix v3 ではコンポーネントの状態を管理するために JavaScript のクロージャー を使用します。上記の Counter
コンポーネントでは、counter
変数をクロージャー内で定義し、コンポーネント内で参照しています。関数内の this
は Remix.Handle
型として型付けされており、this.render()
メソッドを呼び出すことでコンポーネントが再レンダリングされ、更新した状態が反映されます。
ボタンをクリックしたときのイベントハンドリングでは onclick
属性の代わりに on
属性を使用し、@remix-run/events
パッケージの press
イベントハンドラーを使用しています。on
属性を使用するのはイベントハンドリングの抽象化と型安全性を理由に挙げられています。
DOM API の onclick
や oninput
といった属性は、イベント名を文字列として扱うため、型安全性を確保するためにはグローバルな HTML 要素の型を拡張する必要があります。React の場合は JSX.IntrinsicElements
インターフェースを拡張することで onClick
や onInput
属性の型を定義できますが、Remix v3 では on
属性を使用するというアプローチを採用しています。これはイベントをコンポーネントと同じレベルの抽象化として扱うためです。
ここでは @remix-run/events
パッケージの press
イベントハンドラーを使用し、クリックイベントの複雑さを抽象化しています(クリック操作の複雑さは Building a Button Part 1: Press Events – React Spectrum Blog でも説明されています)。press
イベントハンドラーの他に dom.submit
や dom.input
といったより具体的なイベントハンドラーも提供されています。
あらかじめ用意されたイベントハンドラーだけでなく、独自のイベントハンドラーを実装することも可能です。例えば Remix Jam 2025 のデモでは、tempo
イベントハンドラーを実装し、ボタンが押され続けている間に行われる処理をうまく抽象化しています。tempo
イベントハンドラー内では独自のロジックと、UI コンポーネントとは独立した状態を管理しています。
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)]);
},
);
tempo
イベントハンドラーは以下のように使用できます。
<button
on={tempo((event) => {
bpm = event.detail;
this.update();
})}
>
BPM: {bpm}
</button>
元のコードに戻りましょう。作成した Counter
コンポーネントを app/Home.tsx
ファイルで使用するように編集します。
import type { InferRouteHandler, RouteHandlers } from "@remix-run/fetch-router";
import { routes } from "../routes.ts";
import { render } from "./utils/render.ts";
import { Layout } from "./layout.tsx";
import { Counter } from "./assets/Counter.tsx";
export const Home: InferRouteHandler<typeof routes.home> = () => {
return render(
<Layout>
<h1>Welcome to Remix v3!</h1>
<Counter />
</Layout>
);
};
ブラウザで http://localhost:44100
にアクセスし、Clicked 0 times
ボタンをクリックすると、クリック数が増加することが確認できます。
記事の CRUD 機能を実装する
より実践的な例として、Post の CRUD 機能を実装してみましょう。まずはメモリ上で Post を管理するための app/models/post.ts
ファイルを作成します。
export interface Post {
id: string;
title: string;
content: string;
}
let posts: Post[] = [
{
id: "1",
title: "First Post",
content: "This is the content of the first post.",
},
{
id: "2",
title: "Second Post",
content: "This is the content of the second post.",
}
];
export function getAllPosts(): Post[] {
return posts;
}
export function getPostById(id: string): Post | undefined {
return posts.find((post) => post.id === id);
}
export function createPost(title: string, content: string): Post {
const newPost: Post = {
id: String(posts.length + 1),
title,
content,
};
posts.push(newPost);
return newPost;
}
export function updatePost(id: string, title: string, content: string): Post | undefined {
const post = getPostById(id);
if (post) {
post.title = title;
post.content = content;
return post;
}
return undefined;
}
export function deletePost(id: string): boolean {
const index = posts.findIndex((post) => post.id === id);
if (index !== -1) {
posts.splice(index, 1);
return true;
}
return false;
}
app/posts.tsx
ファイルを作成し、Post の一覧表示、詳細表示、新規作成、編集、削除の各コンポーネントを実装します。RouteHandlers<typeof routes.posts>
型を満たすように各ルートハンドラーを実装します。
import type { RouteHandlers } from "@remix-run/fetch-router";
import type { routes } from "../routes";
export const handlers: RouteHandlers<typeof routes.posts> = {
// GET /posts
index: () => {
return new Response("Posts index");
},
// GET /posts/:postId
show: ({ params }) => {
return new Response(`Post ID: ${params.postId}`);
},
// GET /posts/new
new: () => {
return new Response("New post form");
},
// POST /posts
create: () => {
return new Response("Create a new post");
},
// GET /posts/:postId/edit
edit: ({ params }) => {
return new Response(`Edit form for post ID: ${params.postId}`);
},
// PUT /posts/:postId
update: ({ params }) => {
return new Response(`Update post ID: ${params.postId}`);
},
// DELETE /posts/:postId
destroy: ({ params }) => {
return new Response(`Delete post ID: ${params.postId}`);
},
};
app/router.ts
ファイルを編集し router.map(...)
メソッドを使用して、posts
ルートに対して handlers
を適用します。
import { createRouter } from "@remix-run/fetch-router";
import { routes } from "../routes.ts";
import { assets } from "./public.ts";
import { Home } from "./Home.tsx";
import { handlers } from "./posts.tsx";
export const router = createRouter();
router.get(routes.assets, assets);
router.get(routes.home, Home);
router.map(routes.posts, handlers);
http://localhost:44100/posts にアクセスすると Posts index
と表示されることが確認できます。
記事の一覧表示と削除ボタン
記事の一覧表示と削除ボタンを実装してみましょう。app/posts.tsx
ファイルを編集し、index
ルートハンドラーを以下のように実装します。
import type { RouteHandlers } from "@remix-run/fetch-router";
import { routes } from "../routes";
import { getAllPosts } from "./models/post";
import { render } from "./utils/render";
import { Layout } from "./layout";
export const handlers: RouteHandlers<typeof routes.posts> = {
// GET /posts
index: () => {
const posts = getAllPosts();
return render(
<Layout>
<h1
css={{
fontSize: "2.5rem",
fontWeight: "700",
color: "#2563eb",
marginBottom: "2rem",
borderBottom: "3px solid #e5e7eb",
paddingBottom: "0.5rem",
}}
>
Posts
</h1>
<ul
css={{
listStyle: "none",
padding: 0,
margin: 0,
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{posts.map((post) => (
<li
key={post.id}
css={{
padding: "1rem",
backgroundColor: "#f9fafb",
borderRadius: "0.5rem",
border: "1px solid #e5e7eb",
transition: "all 0.2s ease",
}}
>
<a
href={routes.posts.show.href({ postId: post.id })}
css={{
color: "#1f2937",
textDecoration: "none",
fontSize: "1.125rem",
fontWeight: "500",
padding: "0.5rem",
"&:hover": {
color: "#2563eb",
},
}}
>
{post.title}
</a>
</li>
))}
</ul>
</Layout>
);
},
// 他のルートハンドラーは省略
}
getAllPosts
関数を使用して記事の一覧を取得し、<ul>
要素内で記事のタイトルをリンクとして表示しています。スタイルは Remix v3 の組み込み属性である css
属性を使用し、CSS-in-JS のような書き方で定義しています。記事の詳細画面へのリンクは routes.posts.show.href({ postId: post.id })
を使用して生成しているため、型安全にルーティングが行えます。
記事の一覧が表示されていることを確認しましょう。
一覧の各記事に削除ボタンを追加してみましょう。削除ボタンをクリックしたときに確認ダイアログを表示し、OK ボタンが押されたとき fetch
API を使用して記事を削除するリクエストを送信します。JavaScript が動作する必要があるので、app/assets/
ディレクトリ内に DeletePostButton.tsx
ファイルを作成します。
import { hydrated, type Remix } from "@remix-run/dom";
import { routes } from "../../routes";
import { dom } from "@remix-run/events";
export const DeletePostButton = hydrated(
routes.assets.href({ path: "DeletePostButton.js#DeletePostButton" }),
function (this: Remix.Handle) {
const route = routes.posts.destroy;
let deleting = false;
return ({ postId }: { postId: string }) => (
// JavaScript が動かない環境でも動作するように、通常の form 要素を使用
<form
action={route.href({ postId })}
method={route.method}
// Remix のイベントハンドラでは abortController の signal を受け取る
// これを使って非同期処理のキャンセルが可能
on={dom.submit(async (event, signal) => {
event.preventDefault();
deleting = true;
if (confirm("Are you sure you want to delete this post?")) {
await fetch(route.href({ postId }), {
method: route.method,
signal,
});
}
// キャンセルされた場合は何もしない
if (signal.aborted) return;
// 削除後にページをリロードして状態を反映
// 他にメソッドが用意されているかも
window.location.reload();
this.render();
deleting = false;
})}
>
<button
css={{
marginLeft: "1rem",
padding: "0.25rem 0.5rem",
backgroundColor: "#ef4444",
color: "#ffffff",
border: "none",
borderRadius: "0.375rem",
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: "500",
"&:hover": {
backgroundColor: "#dc2626",
},
}}
type="submit"
>
{deleting ? "Deleting..." : "Delete"}
</button>
</form>
);
}
);
ボタンのクリックイベントハンドリングを使用する代わりに、form
要素の onsubmit
イベントハンドリングを使用しています。これにより JavaScript が動作しない環境でもフォーム送信が行われ、記事の削除が可能になります。form
要素の action
属性と method
属性には routes.posts.destroy.href({ postId })
と routes.posts.destroy.method
を使用して、型安全にルーティング情報を設定しています。
dom.submit
イベントハンドラー内では fetch
API を使用して記事削除のリクエストを送信し、削除後にページをリロードして状態を反映しています。Remix v3 のイベントハンドラーでは AbortController の signal
を受け取ることができ、非同期処理のキャンセルができるように設計されています。連続して同じ操作が行われた際に、自動で前の操作がキャンセルされるようになっています。
app/posts.tsx
ファイルを編集し、記事一覧の各記事に DeletePostButton
コンポーネントを追加します。
import { DeletePostButton } from "./assets/DeletePostButton";
// ...
{posts.map((post) => (
<li
key={post.id}
css={{
padding: "1rem",
backgroundColor: "#f9fafb",
borderRadius: "0.5rem",
border: "1px solid #e5e7eb",
transition: "all 0.2s ease",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
"&:hover": {
backgroundColor: "#f3f4f6",
borderColor: "#2563eb",
transform: "translateX(4px)",
},
}}
>
<a
href={routes.posts.show.href({ postId: post.id })}
css={{
color: "#1f2937",
textDecoration: "none",
fontSize: "1.125rem",
fontWeight: "500",
padding: "0.5rem",
"&:hover": {
color: "#2563eb",
},
}}
>
{post.title}
</a>
<DeletePostButton postId={post.id} />
</li>
))}
destroy
ルートハンドラーで記事を削除できるように実装します。
import { deletePost, getAllPosts } from "./models/post";
// DELETE /posts/:postId
destroy: ({ params }) => {
const success = deletePost(params.postId);
if (success) {
return new Response("Post deleted");
} else {
return new Response("Post not found", { status: 404 });
}
},
削除ボタンを押したとき記事が削除されることを確認しましょう。
記事の新規作成
新しい記事を作成するフォームを実装してみましょう。app/post.tsx
の handlers
オブジェクトを編集し、new
ルートハンドラーを実装します。
// GET /posts/new
new: () => {
return render(
<Layout>
<h1
css={{
fontSize: "2.5rem",
fontWeight: "700",
color: "#2563eb",
marginBottom: "2rem",
borderBottom: "3px solid #e5e7eb",
paddingBottom: "0.5rem",
}}
>
New Post
</h1>
<form
method="POST"
action={routes.posts.create.href()}
css={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
maxWidth: "600px",
}}
>
<div
css={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
<label
htmlFor="title"
css={{
fontSize: "1rem",
fontWeight: "600",
color: "#374151",
}}
>
Title
</label>
<input
type="text"
id="title"
name="title"
required
css={{
padding: "0.75rem",
fontSize: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.375rem",
"&:focus": {
outline: "none",
borderColor: "#2563eb",
boxShadow: "0 0 0 3px rgba(37, 99, 235, 0.1)",
},
}}
/>
</div>
<div
css={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
<label
htmlFor="content"
css={{
fontSize: "1rem",
fontWeight: "600",
color: "#374151",
}}
>
Content
</label>
<textarea
id="content"
name="content"
required
rows={10}
css={{
padding: "0.75rem",
fontSize: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.375rem",
fontFamily: "inherit",
resize: "vertical",
"&:focus": {
outline: "none",
borderColor: "#2563eb",
boxShadow: "0 0 0 3px rgba(37, 99, 235, 0.1)",
},
}}
/>
</div>
<div css={{ display: "flex", gap: "1rem" }}>
<button
type="submit"
css={{
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: "600",
color: "#ffffff",
backgroundColor: "#2563eb",
border: "none",
borderRadius: "0.375rem",
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: "#1d4ed8",
},
}}
>
Create Post
</button>
<a
href={routes.posts.index.href()}
css={{
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: "600",
color: "#374151",
backgroundColor: "#f3f4f6",
border: "none",
borderRadius: "0.375rem",
textDecoration: "none",
display: "inline-block",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: "#e5e7eb",
},
}}
>
Cancel
</a>
</div>
</form>
</Layout>
);
},
HTML のフォームを使用して記事のタイトルと内容を入力できるようにしています。create
ルートハンドラーでは引数として formData
オブジェクトを受け取り、フォームから送信されたデータを取得して記事を作成します。記事の作成が成功したら @remix-run/fetch-router
パッケージの redirect
関数を使用して記事一覧ページにリダイレクトします。
import { createPost, getAllPosts } from "./models/post";
import { redirect } from "@remix-run/fetch-router";
// POST /posts
create: async ({ formData }) => {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || !content) {
return new Response("Title and content are required", { status: 400 });
}
createPost(title, content);
return redirect(routes.posts.index.href());
},
ブラウザで http://localhost:44100/posts/new
にアクセスし、記事の新規作成フォームが表示されることを確認しましょう。
その他記事の詳細画面や、編集画面も同様に実装可能です。ここでは割愛しますが、興味がある方は以下のレポジトリを参考にしてください。
まとめ
- 2025 年 10 月 10 日の Remix Jam 2025 で Remix v3 が発表された。v3 は React から離れて独自のフレームワークとして再設計された
@remix-run/dom
,@remix-run/events
,@remix-run/node-fetch-server
,@remix-run/fetch-router
,@remix-run/lazy-file
など複数のパッケージをインストールして Remix v3 アプリをセットアップした@remix-run/fetch-router
のroute
関数を使用してルーティングを定義し、型安全なルーティングが可能- JavaScript のクロージャーを使用してコンポーネントの状態を管理し、
this.render()
メソッドで再レンダリング on
属性と@remix-run/events
パッケージのpress
、dom.submit
などのイベントハンドラーを使用し、型安全性とイベントの抽象化を実現しているcreateInteraction
を使用して独自のイベントハンドラー(例:tempo
イベント)を実装できるcss
属性を使用してインラインでスタイルを定義でき、疑似クラス(:hover
など)もサポート- イベントハンドラーで
signal
を受け取り、非同期処理のキャンセルが可能で、連続操作時に自動的に前の操作がキャンセルされる