mcpField
E
epic-react-patterns

epic-react-patterns

github.com/epicweb-dev開発5.5k45 回インストール3 日前に更新
リポジトリ

epic-react-patterns について

Epic Stack アプリケーションの開発者向けに、React のパターンと規約をまとめています。React Router のデータ取得、コンポーネント設計、パフォーマンス最適化、よくあるアンチパターンを扱います。

主な機能

  1. 01React Router の loader によるデータ取得
  2. 02不要な useEffect の抑制
  3. 03ルート単位のコード分割と遅延読み込み
  4. 04再レンダリングとバンドルサイズの最適化
  5. 05フォーム・ロード状態・エラーバウンダリーの処理

活用シーン

  1. 01ルートベースのページのデータ取得設計
  2. 02大規模コンポーネントやリストの描画性能改善
  3. 03React コードの一般的なアンチパターンの確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/epicweb-dev/epic-stack --skill epic-react-patterns

インストール後、Claude Code で/epic-react-patternsを入力して読み込まれたか確認します。

詳細

Epic Stack:React のパターンとガイドライン

このスキルを使用する場面

次のような場合にこのスキルを使用します。

  • Epic Stack アプリケーションで効率的な React コンポーネントを作成する
  • パフォーマンスとバンドルサイズを最適化する
  • React Router のパターンと規約に従う
  • よくある React のアンチパターンを避ける
  • 適切なコード分割を実装する
  • 再レンダリングとデータ取得を最適化する
  • React フックを正しく使用する

基本理念

Epic Web の原則に従います。

  • まず動かし、次に正しくし、最後に速くする - この順番が重要です。まず機能させ、次に明快さを高めるためにリファクタリングし、その後、パフォーマンスを最適化します。
  • 純粋さより実用性 - 理論上の完璧さではなく、状況に適した実用的で効果的な解決策を選びます。
  • 持続可能な開発速度を優先する - 最初に素早く書けるだけでなく、保守や拡張が容易なコードを書きます。
  • 必要最小限にする - 実質的な価値がある場合にのみ、複雑さを加えます。

パターンと規約

React Router でのデータ取得

Epic Stack では、データ取得に useEffect ではなく React Router のローダーを使用します。

✅ 推奨 - ローダーを使用する:

// app/routes/users/$username.tsx
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
	})
	return { user }
}

export default function UserRoute({ loaderData }: Route.ComponentProps) {
	return <div>{loaderData.user.name}</div>
}

❌ 避ける - useEffect 内でデータを取得しない:

// ❌ Don't do this
export default function UserRoute({ params }: Route.ComponentProps) {
	const [user, setUser] = useState(null)

	useEffect(() => {
		fetch(`/api/users/${params.username}`)
			.then(res => res.json())
			.then(setUser)
	}, [params.username])

	return user ? <div>{user.name}</div> : <div>Loading...</div>
}
副作用には useEffect を避ける

You Might Not Need useEffect

useEffect を使用する代わりに、イベントハンドラー、CSS、ref コールバック、または useSyncExternalStore を使用します。

✅ 推奨 - イベントハンドラーを使用する:

function ProductPage({ product, addToCart }: Route.ComponentProps) {
	function buyProduct() {
		addToCart(product)
		showNotification(`Added ${product.name} to cart!`)
	}

	function handleBuyClick() {
		buyProduct()
	}

	function handleCheckoutClick() {
		buyProduct()
		navigate('/checkout')
	}

	return (
		<div>
			<button onClick={handleBuyClick}>Buy Now</button>
			<button onClick={handleCheckoutClick}>Checkout</button>
		</div>
	)
}

❌ 避ける - useEffect 内で副作用を実行する:

// ❌ Don't do this
function ProductPage({ product, addToCart }: Route.ComponentProps) {
	useEffect(() => {
		if (product.isInCart) {
			showNotification(`Added ${product.name} to cart!`)
		}
	}, [product])

	function handleBuyClick() {
		addToCart(product)
	}

	// ...
}

✅ useEffect の適切な使用例:

// ✅ Good - Event listeners are appropriate
useEffect(() => {
	const controller = new AbortController()

	window.addEventListener(
		'keydown',
		(event: KeyboardEvent) => {
			if (event.key !== 'Escape') return
			// handle escape key
		},
		{ signal: controller.signal },
	)

	return () => {
		controller.abort()
	}
}, [])
React Router でのコード分割

React Router はルートごとにコードを自動で分割します。重いコンポーネントには動的インポートを使用します。

✅ 推奨 - 動的インポート:

// app/routes/admin/dashboard.tsx
import { lazy } from 'react'

const AdminChart = lazy(() => import('#app/components/admin/chart.tsx'))

export default function AdminDashboard() {
	return (
		<Suspense fallback={<div>Loading chart...</div>}>
			<AdminChart />
		</Suspense>
	)
}
再レンダリングの最適化

✅ 推奨 - コストの高い計算をメモ化する:

import { useMemo } from 'react'

function UserList({ users }: { users: User[] }) {
	const sortedUsers = useMemo(() => {
		return [...users].sort((a, b) => a.name.localeCompare(b.name))
	}, [users])

	return (
		<ul>
			{sortedUsers.map(user => (
				<li key={user.id}>{user.name}</li>
			))}
		</ul>
	)
}

✅ 推奨 - コールバックをメモ化する:

import { useCallback } from 'react'

function NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) {
	const handleSave = useCallback((note: Note) => {
		onSave(note)
	}, [onSave])

	return <Editor onSave={handleSave} />
}

❌ 避ける - 不要なメモ化:

// ❌ Don't memoize simple values
const count = useMemo(() => items.length, [items]) // Just use items.length directly

// ❌ Don't memoize simple callbacks
const handleClick = useCallback(() => {
	console.log('clicked')
}, []) // Just define the function normally if it doesn't need memoization
バンドルサイズの最適化

✅ 推奨 - 必要なものだけをインポートする:

// ✅ Import specific functions
import { useSearchParams } from 'react-router'
import { parseWithZod } from '@conform-to/zod'

❌ 避ける - バレルインポート:

// ❌ Don't import entire libraries if you only need one thing
import * as ReactRouter from 'react-router'
import * as Conform from '@conform-to/zod'
Conform を使ったフォーム処理

✅ 推奨 - フォームには Conform を使用する:

import { useForm, getFormProps } from '@conform-to/react'
import { parseWithZod } from '@conform-to/zod'
import { Form } from 'react-router'

const SignupSchema = z.object({
	email: z.string().email(),
	password: z.string().min(6),
})

export default function SignupRoute({ actionData }: Route.ComponentProps) {
	const [form, fields] = useForm({
		id: 'signup-form',
		lastResult: actionData?.result,
		onValidate({ formData }) {
			return parseWithZod(formData, { schema: SignupSchema })
		},
	})

	return (
		<Form method="POST" {...getFormProps(form)}>
			{/* form fields */}
		</Form>
	)
}
コンポーネントの合成

✅ 推奨 - コンポーネントを合成する:

function UserProfile({ user }: { user: User }) {
	return (
		<Card>
			<UserHeader user={user} />
			<UserDetails user={user} />
			<UserActions userId={user.id} />
		</Card>
	)
}

❌ 避ける - 大規模でモノリシックなコンポーネント:

// ❌ Don't put everything in one component
function UserProfile({ user }: { user: User }) {
	return (
		<div className="card">
			<div className="header">
				<img src={user.avatar} alt={user.name} />
				<h1>{user.name}</h1>
			</div>
			<div className="details">
				<p>{user.email}</p>
				<p>{user.bio}</p>
			</div>
			<div className="actions">
				<button>Edit</button>
				<button>Delete</button>
			</div>
		</div>
	)
}
エラーバウンダリー

✅ 推奨 - エラーバウンダリーを使用する:

// app/routes/users/$username.tsx
export function ErrorBoundary() {
	return (
		<GeneralErrorBoundary
			statusHandlers={{
				404: ({ params }) => (
					<p>User "{params.username}" not found</p>
				),
			}}
		/>
	)
}
TypeScript のガイドライン

✅ 推奨 - props の型を明示する:

interface UserCardProps {
	user: {
		id: string
		name: string
		email: string
	}
	onEdit?: (userId: string) => void
}

function UserCard({ user, onEdit }: UserCardProps) {
	return (
		<div>
			<h2>{user.name}</h2>
			<p>{user.email}</p>
			{onEdit && <button onClick={() => onEdit(user.id)}>Edit</button>}
		</div>
	)
}

✅ 推奨 - Route の型を使用する:

import type { Route } from './+types/users.$username'

export async function loader({ params }: Route.LoaderArgs) {
	// params is type-safe!
	const user = await prisma.user.findUnique({
		where: { username: params.username },
	})
	return { user }
}

export default function UserRoute({ loaderData }: Route.ComponentProps) {
	// loaderData is type-safe!
	return <div>{loaderData.user.name}</div>
}
ローディング状態

✅ 推奨 - React Router の保留状態を使用する:

import { useNavigation } from 'react-router'

function NoteForm() {
	const navigation = useNavigation()
	const isSubmitting = navigation.state === 'submitting'

	return (
		<Form method="POST">
			<button type="submit" disabled={isSubmitting}>
				{isSubmitting ? 'Saving...' : 'Save'}
			</button>
		</Form>
	)
}
データ取得のウォーターフォールを防ぐ

React Router のローダーを使うと、データを並列に取得してウォーターフォールを防げます。

❌ 避ける - 順次データを取得する(ウォーターフォール):

// ❌ Don't do this - creates a waterfall
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
	})
	// Second fetch waits for first to complete
	const notes = await prisma.note.findMany({
		where: { ownerId: user.id },
	})
	return { user, notes }
}

✅ 推奨 - データを並列に取得する:

// ✅ Fetch data in parallel
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
		select: { id: true, username: true, name: true },
	})

	// Fetch notes in parallel with user data
	const [notes, stats] = await Promise.all([
		user
			? prisma.note.findMany({
					where: { ownerId: user.id },
					select: { id: true, title: true, updatedAt: true },
				})
			: Promise.resolve([]),
		user
			? prisma.note.count({ where: { ownerId: user.id } })
			: Promise.resolve(0),
	])

	return { user, notes, stats }
}

✅ 推奨 - ネストされたルートで並列に読み込む:

// Parent route loader
// app/routes/users/$username.tsx
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
		select: { id: true, username: true, name: true },
	})
	return { user }
}

// Child route loader runs in parallel
// app/routes/users/$username/notes.tsx
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
		select: { id: true },
	})

	if (!user) {
		throw new Response('Not Found', { status: 404 })
	}

	const notes = await prisma.note.findMany({
		where: { ownerId: user.id },
		select: { id: true, title: true, updatedAt: true },
	})

	return { notes }
}
サーバーサイドレンダリング(SSR)のパフォーマンス

React Router はデフォルトで SSR を提供します。次の方法で最適化します。

✅ 推奨 - データ取得を選択的に行う:

export async function loader({ request }: Route.LoaderArgs) {
	// Only fetch what's needed for initial render
	const searchParams = new URL(request.url).searchParams
	const page = Number(searchParams.get('page') || '1')

	const [items, total] = await Promise.all([
		prisma.item.findMany({
			take: 20,
			skip: (page - 1) * 20,
			select: { id: true, title: true }, // Only needed fields
		}),
		prisma.item.count(),
	])

	return { items, total, page }
}

✅ 推奨 - コストの高い処理にキャッシュを使用する:

import { cachified, cache } from '#app/utils/cache.server.ts'

export async function loader({ request }: Route.LoaderArgs) {
	const timings: Timings = {}

	// Cache expensive database queries
	const stats = await cachified({
		key: 'user-stats',
		cache,
		timings,
		getFreshValue: async () => {
			return await prisma.user.aggregate({
				_count: { id: true },
			})
		},
		ttl: 1000 * 60 * 5, // 5 minutes
	})

	return { stats }
}
レンダリングのパフォーマンス

✅ 推奨 - コストの高いコンポーネントには React.memo を使用する:

import { memo } from 'react'

const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) {
	// Expensive rendering logic
	return <Chart data={data} />
})

// Only re-renders when data changes
export default function Dashboard({ chartData }: { chartData: Data[] }) {
	return <ExpensiveChart data={chartData} />
}

✅ 推奨 - リストのレンダリングを最適化する:

import { memo } from 'react'

const UserItem = memo(function UserItem({ user }: { user: User }) {
	return (
		<li>
			<h3>{user.name}</h3>
			<p>{user.email}</p>
		</li>
	)
}, (prev, next) => prev.user.id === next.user.id)

function UserList({ users }: { users: User[] }) {
	return (
		<ul>
			{users.map(user => (
				<UserItem key={user.id} user={user} />
			))}
		</ul>
	)
}

❌ 避ける - レンダリング時に新しいオブジェクトや配列を作成する:

// ❌ Don't create new objects on every render
function UserProfile({ user }: { user: User }) {
	return <Card user={{ ...user, fullName: `${user.firstName} ${user.lastName}` }} />
}

// ✅ Good - Compute in loader or memoize
export async function loader({ params }: Route.LoaderArgs) {
	const user = await prisma.user.findUnique({
		where: { username: params.username },
		select: { firstName: true, lastName: true },
	})

	return {
		user: {
			...user,
			fullName: `${user.firstName} ${user.lastName}`,
		},
	}
}
バンドルサイズを最適化する方法

✅ 推奨 - ルートベースのコード分割: React Router はルートごとにコードを自動で分割します。これを活用します。

// Heavy dependencies are automatically split by route
// app/routes/admin/dashboard.tsx
import { Chart } from 'chart.js' // Only loaded on /admin/dashboard route

✅ 推奨 - 重いコンポーネントには動的インポートを使用する:

import { lazy, Suspense } from 'react'

const HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx'))

export default function Route() {
	return (
		<Suspense fallback={<div>Loading...</div>}>
			<HeavyComponent />
		</Suspense>
	)
}

✅ 推奨 - ツリーシェイキング可能なインポート:

// ✅ Tree-shakeable - only imports what you use
import { format } from 'date-fns/format'
import { addDays } from 'date-fns/addDays'

// ❌ Avoid - imports entire library
import * as dateFns from 'date-fns'
パフォーマンス向上に役立つ React 18 以降の機能

✅ 推奨 - 緊急性の低い更新にはトランジションを使用する:

import { useTransition } from 'react'
import { useNavigation } from 'react-router'

function SearchInput() {
	const [isPending, startTransition] = useTransition()
	const navigation = useNavigation()

	function handleSearch(query: string) {
		startTransition(() => {
			// Update search results (non-urgent)
			navigation.navigate(`/search?q=${query}`)
		})
	}

	return (
		<input
			onChange={(e) => handleSearch(e.target.value)}
			placeholder={isPending ? 'Searching...' : 'Search'}
		/>
	)
}

よくある避けるべきミス

  • ❌ useEffect でデータを取得する:代わりに React Router のローダーを使用します
  • ❌ useEffect を過剰に使用する:イベントハンドラー、CSS、または ref コールバックを優先します
  • ❌ 時期尚早なメモ化:測定可能なパフォーマンス上のメリットがある場合にのみメモ化します
  • ❌ バレルインポート:必要なものだけをインポートします
  • ❌ TypeScript の型を無視する:型安全性を確保するために Route 型を使用します
  • ❌ ローディング状態を処理しない:React Router のナビゲーション状態を使用します
  • ❌ 巨大でモノリシックなコンポーネント:コンポーネントを小さく、役割を絞った単位に分割します
  • ❌ エラーバウンダリーを使用しない:必ずルートにエラーバウンダリーを追加します
  • ❌ サーバー側で処理できるのにクライアント側でルーティングする:サーバー側でのデータ取得を優先します
  • ❌ データ取得のウォーターフォール:Promise.all() を使ってデータを並行して取得します
  • ❌ 不要なデータを取得する:初回のレンダリングに必要なものだけを取得します
  • ❌ レンダリング中に新しいオブジェクトを作成する:派生データはローダーで計算するか、メモ化します
  • ❌ 負荷の高いリストで React.memo を使用しない:パフォーマンスを向上させるため、リスト項目をメモ化します
  • ❌ ルートベースのコード分割を活用しない:React Router はルートごとに自動でコードを分割します

参考資料

コメント