Dioxus実践ガイド2025 - Rust製フルスタックフレームワークで次世代アプリ開発
Rustで構築されたクロスプラットフォームフレームワークDioxusの完全ガイド。React風のアプローチでWeb、デスクトップ、モバイルアプリを単一コードベースで開発する実践的な手法を詳しく解説します。
Remix v3の衝撃的な方向転換とReact Router v7への移行パスを徹底解説。Vite統合、React Server Components対応、Next.jsとの比較まで、最新のフルスタック開発手法を実践的に紹介します。
2025 年 5 月、Remix チームは Web 開発コミュニティに衝撃を与える発表を行いました。Remix v3はReactを完全に放棄し、Preactのフォークを採用するという大胆な方向転換です。一方で、現在の Remix ユーザーには React Router v7 への明確な移行パスが用意されています。
Remix v3 は予想外の進化を遂げ、Reactから独立した新しいフレームワークとして生まれ変わります。既存の Remix プロジェクトは React Router v7 への移行が推奨されています。
React Routerチームが新しいフルスタックフレームワークを発表
Remixチームがクラウドプラットフォームから独立
デフォルトコンパイラーとしてViteを採用
RemixとReact Routerが統合
Reactから独立、Preactフォークを採用
# 最新のRemixプロジェクトを作成(Vite使用)
npx create-remix@latest my-app
# テンプレートを指定
npx create-remix@latest --template remix-run/remix/templates/vite
# Express + Vite
npx create-remix@latest --template remix-run/remix/templates/vite-express
# Cloudflare Pages + Vite
npx create-remix@latest --template remix-run/remix/templates/vite-cloudflare
// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
remix({
// future flagsを有効化(React Router v7への準備)
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}),
tsconfigPaths(),
],
// サーバーサイドの最適化
ssr: {
noExternal: ["@remix-run/*"],
},
// ビルド最適化
build: {
target: "esnext",
minify: true,
sourcemap: true,
},
});
// React + useEffect
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <Spinner />;
if (error) return <Error />;
return <ProductGrid products={products} />;
}
// Remixのloader
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }) {
// サーバーサイドで実行
const url = new URL(request.url);
const category = url.searchParams.get("category");
const products = await db.products.findMany({
where: category ? { category } : undefined,
include: { reviews: true }
});
// 自動的にキャッシュヘッダーを設定
return json(products, {
headers: {
"Cache-Control": "max-age=300, s-maxage=3600"
}
});
}
export default function ProductList() {
// エラーハンドリングとローディングは自動
const products = useLoaderData<typeof loader>();
return <ProductGrid products={products} />;
}
// React + useEffect
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <Spinner />;
if (error) return <Error />;
return <ProductGrid products={products} />;
}
// Remixのloader
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }) {
// サーバーサイドで実行
const url = new URL(request.url);
const category = url.searchParams.get("category");
const products = await db.products.findMany({
where: category ? { category } : undefined,
include: { reviews: true }
});
// 自動的にキャッシュヘッダーを設定
return json(products, {
headers: {
"Cache-Control": "max-age=300, s-maxage=3600"
}
});
}
export default function ProductList() {
// エラーハンドリングとローディングは自動
const products = useLoaderData<typeof loader>();
return <ProductGrid products={products} />;
}
// app/routes/dashboard.tsx
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ request }: LoaderFunctionArgs) {
// 認証チェック
const user = await requireUser(request);
// 並列でデータを取得
const [stats, recentOrders, notifications] = await Promise.all([
getStats(user.id),
getRecentOrders(user.id),
getNotifications(user.id)
]);
// 遅延ローディングも可能
const analyticsPromise = getAnalytics(user.id);
return defer({
user,
stats,
recentOrders,
notifications,
analytics: analyticsPromise // ストリーミング
});
}
export default function Dashboard() {
const { user, stats, recentOrders, notifications, analytics } =
useLoaderData<typeof loader>();
return (
<div className="dashboard">
<h1>Welcome, {user.name}!</h1>
<StatsGrid stats={stats} />
<RecentOrders orders={recentOrders} />
<NotificationList notifications={notifications} />
{/* 遅延ローディングコンテンツ */}
<Suspense fallback={<AnalyticsSkeleton />}>
<Await resolve={analytics}>
{(data) => <Analytics data={data} />}
</Await>
</Suspense>
</div>
);
}
// app/routes/products.$id.edit.tsx
import {
json,
redirect,
unstable_createMemoryUploadHandler,
unstable_parseMultipartFormData,
} from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request, params }: ActionFunctionArgs) {
const productId = params.id;
// マルチパートフォームデータの処理
const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 5_000_000, // 5MB
});
const formData = await unstable_parseMultipartFormData(
request,
uploadHandler
);
// バリデーション
const errors = validateProductForm(formData);
if (errors) {
return json({ errors }, { status: 400 });
}
try {
// データベース更新
const product = await db.products.update({
where: { id: productId },
data: {
name: formData.get("name"),
price: Number(formData.get("price")),
description: formData.get("description"),
image: await processImage(formData.get("image")),
},
});
// 成功時はリダイレクト
return redirect(`/products/${product.id}`);
} catch (error) {
return json(
{ error: "商品の更新に失敗しました" },
{ status: 500 }
);
}
}
export default function EditProduct() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post" encType="multipart/form-data">
<fieldset disabled={isSubmitting}>
<label>
商品名
<input
name="name"
required
aria-invalid={actionData?.errors?.name ? true : undefined}
/>
{actionData?.errors?.name && (
<span className="error">{actionData.errors.name}</span>
)}
</label>
<label>
価格
<input
name="price"
type="number"
required
min="0"
/>
</label>
<label>
画像
<input
name="image"
type="file"
accept="image/*"
/>
</label>
<button type="submit">
{isSubmitting ? "保存中..." : "保存"}
</button>
</fieldset>
{actionData?.error && (
<div className="error-message">{actionData.error}</div>
)}
</Form>
);
}
// app/routes/stream.tsx
import {
createReadableStreamFromReadable
} from "@remix-run/node";
import { Readable } from "stream";
export async function loader() {
// カスタムストリームの作成
const stream = new Readable({
read() {}
});
// 非同期でデータをストリーミング
streamData(stream);
return new Response(
createReadableStreamFromReadable(stream),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
}
);
}
async function streamData(stream: Readable) {
// リアルタイムデータのストリーミング
for (let i = 0; i < 100; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
const data = {
id: i,
timestamp: new Date().toISOString(),
value: Math.random() * 100
};
stream.push(`data: ${JSON.stringify(data)}\n\n`);
}
stream.push(null); // ストリーム終了
}
// クライアント側
export default function StreamingData() {
const [data, setData] = useState<any[]>([]);
useEffect(() => {
const eventSource = new EventSource("/stream");
eventSource.onmessage = (event) => {
const newData = JSON.parse(event.data);
setData(prev => [...prev, newData]);
};
return () => eventSource.close();
}, []);
return (
<div>
<h2>リアルタイムデータ</h2>
<div className="data-stream">
{data.map(item => (
<div key={item.id}>
{item.timestamp}: {item.value.toFixed(2)}
</div>
))}
</div>
</div>
);
}
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
useRouteError,
} from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<html>
<head>
<title>{error.status} {error.statusText}</title>
<Meta />
<Links />
</head>
<body>
<div className="error-container">
<h1>{error.status}</h1>
<p>{error.statusText}</p>
{error.data && <pre>{JSON.stringify(error.data, null, 2)}</pre>}
</div>
<Scripts />
</body>
</html>
);
}
// 予期しないエラー
const errorMessage = error instanceof Error
? error.message
: "Unknown error";
return (
<html>
<head>
<title>エラーが発生しました</title>
<Meta />
<Links />
</head>
<body>
<div className="error-container">
<h1>申し訳ございません</h1>
<p>予期しないエラーが発生しました。</p>
<details>
<summary>詳細情報</summary>
<pre>{errorMessage}</pre>
</details>
</div>
<Scripts />
</body>
</html>
);
}
Remix の強力な特徴の 1 つは、JavaScriptが無効でも動作するプログレッシブエンハンスメントです。
チャートを読み込み中...
// プログレッシブエンハンスメントの例
import { Form, useFetcher } from "@remix-run/react";
export default function Newsletter() {
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
// JavaScriptが無効でも動作する
return (
<section>
<h2>ニュースレター登録</h2>
{/* プログレッシブエンハンスメント対応フォーム */}
<fetcher.Form method="post" action="/newsletter">
<input
type="email"
name="email"
placeholder="メールアドレス"
required
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "登録中..." : "登録"}
</button>
</fetcher.Form>
{/* 成功メッセージ */}
{fetcher.data?.success && (
<p className="success">登録ありがとうございます!</p>
)}
{/* エラーメッセージ */}
{fetcher.data?.error && (
<p className="error">{fetcher.data.error}</p>
)}
</section>
);
}
2025 年 5 月、React Router で RSC のプレビューサポートが開始されました。
// app/routes/_rsc.products.tsx
// Server Component Route
export async function loader() {
const products = await db.products.findMany();
// RSCとして返す
return (
<ProductList products={products}>
<SomeExpensiveComponent />
</ProductList>
);
}
// app/components/ProductList.tsx
'use client';
import { useState } from 'react';
export function ProductList({ products, children }) {
const [filter, setFilter] = useState('');
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="商品を検索"
/>
{/* Server Componentの子要素 */}
{children}
{/* クライアントサイドのフィルタリング */}
{products
.filter(p => p.name.includes(filter))
.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
機能 | Remix | Next.js App Router | 評価 |
---|---|---|---|
データフェッチング | loader/action | Server Components | 引き分け |
ルーティング | ネステッド優先 | ファイルシステム | Remix ⭐ |
フォーム処理 | ネイティブサポート | Server Actions | Remix ⭐ |
プログレッシブエンハンスメント | 完全対応 | 部分的 | Remix ⭐ |
静的生成 | なし | フル対応 | Next.js ⭐ |
エッジランタイム | 対応 | 対応 | 引き分け |
開発体験 | シンプル | 複雑 | Remix ⭐ |
エコシステム | 成長中 | 成熟 | Next.js ⭐ |
既存の Remix アプリケーションは、React Router v7 への移行が推奨されています。
# 1. Future Flagsを有効化
# remix.config.js
module.exports = {
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
};
# 2. 依存関係の更新
npm uninstall @remix-run/node @remix-run/react @remix-run/serve
npm install react-router@7 @react-router/node @react-router/serve
# 3. インポートの更新(自動化可能)
npx codemod remix-to-react-router
// app/routes.ts (新しい形式)
import {
type RouteConfig,
route,
index,
layout,
prefix,
} from "@react-router/dev/routes";
export default [
layout("layouts/main.tsx", [
index("routes/home.tsx"),
route("about", "routes/about.tsx"),
route("products/:id", "routes/products.$id.tsx"),
prefix("admin", [
layout("layouts/admin.tsx", [
index("routes/admin/index.tsx"),
route("users", "routes/admin/users.tsx"),
route("settings", "routes/admin/settings.tsx"),
]),
]),
]),
] satisfies RouteConfig;
// Remix v3の予想される構文(Preactベース)
import { h } from '@remix-run/core';
import { Router, Route } from '@remix-run/router';
export function App() {
return (
<Router>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/products/:id" loader={loadProduct}>
{(props) => <Product {...props} />}
</Route>
</Router>
);
}
// より軽量なコンポーネント
export function Home() {
return h('div', { class: 'home' }, [
h('h1', null, 'Welcome to Remix v3'),
h('p', null, 'Blazing fast with Preact'),
]);
}
// app/entry.server.tsx (Cloudflare Workers)
import { RemixServer } from "@remix-run/react";
import { renderToReadableStream } from "react-dom/server";
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
{
signal: request.signal,
onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
}
);
responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
// wrangler.toml
name = "remix-app"
main = "build/index.js"
compatibility_date = "2024-01-01"
[site]
bucket = "./public"
[build]
command = "npm run build"
// vercel.json
{
"functions": {
"app/entry.server.tsx": {
"runtime": "nodejs20.x"
}
},
"rewrites": [
{
"source": "/(.*)",
"destination": "/app/entry.server.tsx"
}
]
}
// package.json scripts
{
"scripts": {
"build": "remix build",
"vercel-build": "remix build && cp -r public .vercel/output/static"
}
}
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
RUN npm ci --production
EXPOSE 3000
CMD ["npm", "start"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://...
volumes:
- ./data:/app/data
Remix は、Web プラットフォームの基本に立ち返ることで、より良い開発体験とユーザー体験を提供します。フレームワークの複雑さではなく、Web の仕組みを理解することが重要です。
Remix は大きな転換期を迎えています。現在の v2.x は Vite統合により素晴らしい開発体験を提供し、React Router v7 への移行パスも明確です。一方、Remix v3 の大胆な方向転換は、Web 開発の未来に新たな可能性を示しています。
プロジェクトの要件に応じて、適切な選択をすることが重要です: