mcpField
R
react-best-practices

react-best-practices

github.com/dedalus-erp-pas開発257 回インストール
リポジトリ

react-best-practices について

React と Next.js の開発者向けガイドです。コンポーネント設計、パフォーマンス、UI パターン、アクセシビリティ、React 19+ の機能を扱います。

主な機能

  1. 01コンポーネント設計と合成パターン
  2. 02非同期処理とバンドルの最適化
  3. 03サーバーコンポーネントと Actions
  4. 04shadcn/ui と状態管理パターン
  5. 05アニメーションとアクセシビリティの指針
  6. 06TypeScript と React 19+ のパターン

活用シーン

  1. 01React コンポーネントの設計やリファクタリング
  2. 02データ取得とバンドル性能の改善
  3. 03shadcn/ui によるインターフェース構築
  4. 04アクセシビリティと TypeScript の確認

インストール

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

$npx skills add https://github.com/dedalus-erp-pas/foundation-skills --skill react-best-practices

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

詳細

React のベストプラクティス

モダンな React および Next.js アプリケーションを構築するための包括的なガイドです。パフォーマンスの最適化、コンポーネントアーキテクチャ、shadcn/ui のパターン、Motion アニメーション、アクセシビリティ、React 19 以降の機能を扱います。

適用するタイミング

以下の場合にこれらのガイドラインを参照してください。

  • 新しい React コンポーネントや Next.js ページを作成するとき
  • データ取得(クライアント側またはサーバー側)を実装するとき
  • shadcn/ui コンポーネントで UI を構築するとき
  • アニメーションやマイクロインタラクションを追加するとき
  • コードの品質やパフォーマンスをレビューするとき
  • 既存の React/Next.js コードをリファクタリングするとき
  • バンドルサイズや読み込み時間を最適化するとき

優先度別のルールカテゴリ

優先度カテゴリ影響度プレフィックス
1コンポーネントアーキテクチャCRITICALarch-
2ウォーターフォールの解消CRITICALasync-
3バンドルサイズの最適化CRITICALbundle-
4サーバーコンポーネントとアクションHIGHserver-
5shadcn/ui のパターンHIGHshadcn-
6状態管理MEDIUM-HIGHstate-
7Motion とアニメーションMEDIUMmotion-
8再レンダリングの最適化MEDIUMrerender-
9アクセシビリティMEDIUMa11y-
10TypeScript のパターンMEDIUMts-

1. コンポーネントアーキテクチャ(CRITICAL)

クイックリファレンス
  • arch-functional-components - フックを使用する関数コンポーネントのみを使う
  • arch-composition-over-inheritance - 既存のコンポーネントを基に構築し、拡張はしない
  • arch-single-responsibility - 各コンポーネントは一つの役割を適切に担う
  • arch-presentational-container - 有益な場合は UI とロジックを分離する
  • arch-colocation - 関連ファイル(コンポーネント、スタイル、テスト)をまとめて配置する
  • arch-avoid-prop-drilling - 深い階層への props の受け渡しには Context またはコンポジションを使う
主な原則

関数コンポーネントのみを使う

// Correct: Functional component with hooks
function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useUser(userId)
  return <div>{user?.name}</div>
}

// Incorrect: Class component
class UserProfile extends React.Component { /* ... */ }

コンポジションパターン

// Correct: Compose smaller components
function Card({ children }: { children: React.ReactNode }) {
  return <div className="rounded-lg border p-4">{children}</div>
}

function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="font-semibold">{children}</div>
}

// Usage
<Card>
  <CardHeader>Title</CardHeader>
  <p>Content</p>
</Card>

Prop Drilling を避ける

// Incorrect: Passing props through many levels
<App user={user}>
  <Layout user={user}>
    <Sidebar user={user}>
      <UserMenu user={user} />
    </Sidebar>
  </Layout>
</App>

// Correct: Use Context for shared state
const UserContext = createContext<User | null>(null)

function App() {
  const user = useCurrentUser()
  return (
    <UserContext.Provider value={user}>
      <Layout>
        <Sidebar>
          <UserMenu />
        </Sidebar>
      </Layout>
    </UserContext.Provider>
  )
}

2. ウォーターフォールの解消(CRITICAL)

クイックリファレンス
  • async-defer-await - await を実際に使う分岐の中へ移動する
  • async-parallel - 独立した処理には Promise.all() を使う
  • async-dependencies - 部分的な依存関係を適切に処理する
  • async-api-routes - API ルートでは早めに Promise を開始し、await は後で行う
  • async-suspense-boundaries - Suspense を使ってコンテンツをストリーミングする
主な原則

ウォーターフォールはパフォーマンスを損なう最大の要因です。逐次的な await ごとに、ネットワーク遅延が丸ごと追加されます。

並列データ取得

// Incorrect: Sequential waterfalls
async function Page() {
  const user = await fetchUser()
  const posts = await fetchPosts()
  const comments = await fetchComments()
  return <div>{/* render */}</div>
}

// Correct: Parallel fetching
async function Page() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ])
  return <div>{/* render */}</div>
}

戦略的な Suspense 境界

// Stream content as it becomes available
function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  )
}

3. バンドルサイズの最適化(CRITICAL)

クイックリファレンス
  • bundle-barrel-imports - 直接インポートし、バレルファイルは避ける
  • bundle-dynamic-imports - 重いコンポーネントには next/dynamic を使う
  • bundle-defer-third-party - アナリティクスやロギングはハイドレーション後に読み込む
  • bundle-conditional - 機能が有効になった場合にのみモジュールを読み込む
  • bundle-preload - 体感速度を高めるため、ホバーまたはフォーカス時にプリロードする
主な原則

バレルファイルからのインポートを避ける

// Incorrect: Imports entire library
import { Button } from '@/components'
import { formatDate } from '@/utils'

// Correct: Direct imports enable tree-shaking
import { Button } from '@/components/ui/button'
import { formatDate } from '@/utils/date'

動的インポート

import dynamic from 'next/dynamic'

// Load only when needed
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false
})

function Dashboard({ showChart }) {
  return showChart ? <HeavyChart /> : null
}

4. サーバーコンポーネントとアクション(HIGH)

クイックリファレンス
  • server-default-server - コンポーネントはデフォルトでサーバーコンポーネントになる
  • server-use-client-boundary - 必要な場合にのみ 'use client' を追加する
  • server-actions - ミューテーションには Server Actions を使う
  • server-cache-react - リクエストごとの重複排除には React.cache() を使う
  • server-serialization - クライアントコンポーネントに渡すデータを最小限にする
主な原則

デフォルトでサーバーコンポーネントを使う

// Server Component (default) - can be async
async function ProductPage({ id }: { id: string }) {
  const product = await db.product.findUnique({ where: { id } })
  return <ProductDetails product={product} />
}

// Client Component - only when needed for interactivity
'use client'
function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition()

  return (
    <Button
      onClick={() => startTransition(() => addToCart(productId))}
      disabled={isPending}
    >
      Add to Cart
    </Button>
  )
}

Server Actions

// actions.ts
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({ data: { title, content } })
  revalidatePath('/posts')
}

// Component usage
function CreatePostForm() {
  return (
    <form action={createPost}>
      <Input name="title" placeholder="Title" />
      <Textarea name="content" placeholder="Content" />
      <Button type="submit">Create Post</Button>
    </form>
  )
}

5. shadcn/ui のパターン(HIGH)

クイックリファレンス
  • shadcn-composition - 既存の shadcn/ui プリミティブを基に構築する
  • shadcn-variants - コンポーネントのバリアントには class-variance-authority を使う
  • shadcn-theme-integration - テーマ設定には CSS カスタムプロパティを使う
  • shadcn-accessibility - Radix に組み込まれたアクセシビリティ機能を活用する
  • shadcn-customization - コピーしたコンポーネントを変更し、過度なラップは避ける
基本原則

shadcn/ui は、次の原則に基づいて構築されています。

  • オープンコード:コンポーネントをプロジェクトにコピーし、完全にカスタマイズできます
  • コンポジション:すべてのコンポーネントが、共通の組み合わせ可能なインターフェースを使用します
  • 美しいデフォルト設定:慎重に選ばれたデフォルトスタイルを提供します
  • デフォルトでアクセシブル:Radix UI のプリミティブを基盤としています
コンポーネントのインストール
# Add components as needed
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form
カスタムコンポーネントの構築

作成よりコンポジションを優先

// Correct: Build on existing primitives
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'

interface ProductCardProps {
  product: Product
  onSelect?: () => void
}

function ProductCard({ product, onSelect }: ProductCardProps) {
  return (
    <Card
      className="cursor-pointer hover:shadow-md transition-shadow"
      onClick={onSelect}
    >
      <CardHeader>
        <div className="flex items-center justify-between">
          <CardTitle>{product.name}</CardTitle>
          {product.isNew && <Badge>New</Badge>}
        </div>
      </CardHeader>
      <CardContent>
        <p className="text-muted-foreground">{product.description}</p>
        <p className="text-lg font-bold mt-2">${product.price}</p>
      </CardContent>
    </Card>
  )
}

CVA でバリアントを使用

import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const statusBadgeVariants = cva(
  'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
  {
    variants: {
      status: {
        pending: 'bg-yellow-100 text-yellow-800',
        active: 'bg-green-100 text-green-800',
        inactive: 'bg-gray-100 text-gray-800',
        error: 'bg-red-100 text-red-800',
      },
    },
    defaultVariants: {
      status: 'pending',
    },
  }
)

interface StatusBadgeProps
  extends React.HTMLAttributes<HTMLSpanElement>,
    VariantProps<typeof statusBadgeVariants> {
  label: string
}

function StatusBadge({ status, label, className, ...props }: StatusBadgeProps) {
  return (
    <span className={cn(statusBadgeVariants({ status }), className)} {...props}>
      {label}
    </span>
  )
}
よく使われる shadcn/ui コンポーネント

React Hook Form + Zod を使ったフォーム

'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'

const formSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

function LoginForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })

  function onSubmit(values: z.infer<typeof formSchema>) {
    console.log(values)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="[email protected]" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" className="w-full">Sign In</Button>
      </form>
    </Form>
  )
}

ダイアログ/モーダルのパターン

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

function ConfirmDialog({
  onConfirm,
  title,
  description
}: {
  onConfirm: () => void
  title: string
  description: string
}) {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">Delete</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline">Cancel</Button>
          <Button variant="destructive" onClick={onConfirm}>
            Confirm
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Tanstack Table を使ったデータテーブル

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table'

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id}>
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext()
                  )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

6. 状態管理(中〜高)

クイックリファレンス
  • state-local-first - ローカル状態には useState/useReducer を使用します
  • state-context-static - 更新頻度の低いデータには Context を使用します
  • state-derived-compute - 派生値は保存せず、計算します
  • state-url-state - 共有やブックマークが可能な状態には URL を使用します
  • state-server-state - サーバー状態には SWR/TanStack Query を使用します
主な原則

派生状態を避ける

// Incorrect: Storing derived state
function ProductList({ products }) {
  const [items, setItems] = useState(products)
  const [count, setCount] = useState(products.length) // Derived!

  // Bug: count can get out of sync with items
}

// Correct: Compute derived values
function ProductList({ products }) {
  const [items, setItems] = useState(products)
  const count = items.length // Always in sync
}

フィルター/ページネーションには URL の状態を使用

'use client'

import { useSearchParams, useRouter } from 'next/navigation'

function ProductFilters() {
  const searchParams = useSearchParams()
  const router = useRouter()

  const category = searchParams.get('category') || 'all'

  function setCategory(newCategory: string) {
    const params = new URLSearchParams(searchParams)
    params.set('category', newCategory)
    router.push(`?${params.toString()}`)
  }

  return (
    <Select value={category} onValueChange={setCategory}>
      {/* options */}
    </Select>
  )
}

7. モーションとアニメーション(中)

クイックリファレンス
  • motion-purposeful - アニメーションは UX を向上させるために使い、注意をそらさないようにします
  • motion-performance - transform/opacity を使用し、レイアウトを発生させるプロパティは避けます
  • motion-reduced-motion - prefers-reduced-motion を尊重します
  • motion-layout-id - 共通要素間のトランジションには layoutId を使用します
  • motion-exit-animations - 終了アニメーションには AnimatePresence を使用します
  • motion-variants - 再利用可能なアニメーション状態を定義します
インストール
npm install motion
基本原則

Motion(旧称 Framer Motion)は、ユーザー体験を向上させる宣言的なアニメーションを提供します。

基本的なアニメーション

'use client'

import { motion } from 'motion/react'

function FadeInCard({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3 }}
      className="rounded-lg border p-4"
    >
      {children}
    </motion.div>
  )
}

インタラクションの状態

function InteractiveButton({ children }: { children: React.ReactNode }) {
  return (
    <motion.button
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.98 }}
      transition={{ type: 'spring', stiffness: 400, damping: 17 }}
      className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
    >
      {children}
    </motion.button>
  )
}

AnimatePresence を使った終了アニメーション

import { motion, AnimatePresence } from 'motion/react'

function NotificationList({ notifications }: { notifications: Notification[] }) {
  return (
    <AnimatePresence>
      {notifications.map((notification) => (
        <motion.div
          key={notification.id}
          initial={{ opacity: 0, x: 50 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -50 }}
          transition={{ duration: 0.2 }}
        >
          <Notification data={notification} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

共通要素間のトランジション

function ProductGrid({ products }: { products: Product[] }) {
  const [selected, setSelected] = useState<Product | null>(null)

  return (
    <>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <motion.div
            key={product.id}
            layoutId={`product-${product.id}`}
            onClick={() => setSelected(product)}
            className="cursor-pointer"
          >
            <img src={product.image} alt={product.name} />
          </motion.div>
        ))}
      </div>

      <AnimatePresence>
        {selected && (
          <motion.div
            layoutId={`product-${selected.id}`}
            className="fixed inset-0 flex items-center justify-center"
          >
            <ProductDetail product={selected} onClose={() => setSelected(null)} />
          </motion.div>
        )}
      </AnimatePresence>
    </>
  )
}

再利用可能なバリアント

const fadeInUp = {
  initial: { opacity: 0, y: 20 },
  animate: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: -20 },
}

const staggerContainer = {
  animate: {
    transition: {
      staggerChildren: 0.1,
    },
  },
}

function StaggeredList({ items }: { items: string[] }) {
  return (
    <motion.ul variants={staggerContainer} initial="initial" animate="animate">
      {items.map((item, i) => (
        <motion.li key={i} variants={fadeInUp}>
          {item}
        </motion.li>
      ))}
    </motion.ul>
  )
}

スクロールをトリガーとするアニメーション

import { motion, useInView } from 'motion/react'
import { useRef } from 'react'

function ScrollReveal({ children }: { children: React.ReactNode }) {
  const ref = useRef(null)
  const isInView = useInView(ref, { once: true, margin: '-100px' })

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 50 }}
      animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 50 }}
      transition={{ duration: 0.5 }}
    >
      {children}
    </motion.div>
  )
}

動きを抑えた表示への対応

import { motion, useReducedMotion } from 'motion/react'

function AccessibleAnimation({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
    >
      {children}
    </motion.div>
  )
}
パフォーマンスのヒント
  • 滑らかな 60fps のアニメーションには transform と opacity を使用します
  • 複雑なアニメーションでは willChange プロパティを設定します
  • 終了アニメーションは短く(300ms 未満)保ちます
  • useInView を使用してアニメーションを遅延読み込みします
  • width、height、top、left の直接アニメーションは避けます

8. 再レンダリングの最適化(中)

クイックリファレンス
  • rerender-memo - コストの高い処理をメモ化したコンポーネントに分離します
  • rerender-usememo - コストの高い計算をメモ化します
  • rerender-usecallback - コールバックの参照を安定させます
  • rerender-dependencies - エフェクトではプリミティブな依存値を使用します
  • rerender-transitions - 緊急性の低い更新には startTransition を使用します
主な原則

コストの高いコンポーネントをメモ化

import { memo } from 'react'

const ExpensiveList = memo(function ExpensiveList({
  items
}: {
  items: Item[]
}) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{/* expensive render */}</li>
      ))}
    </ul>
  )
})

安定したコールバック

function Parent() {
  const [count, setCount] = useState(0)

  // Stable reference - won't cause child re-renders
  const handleClick = useCallback(() => {
    setCount(c => c + 1)
  }, [])

  return <MemoizedChild onClick={handleClick} />
}

トランジションによる緊急性の低い更新

function SearchResults() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value) // Urgent: update input immediately

    startTransition(() => {
      setResults(filterResults(e.target.value)) // Non-urgent: can be interrupted
    })
  }

  return (
    <>
      <Input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  )
}

9. アクセシビリティ(中)

クイックリファレンス
  • a11y-semantic-html - 適切な HTML 要素を使用します
  • a11y-keyboard-nav - キーボードで操作できるようにします
  • a11y-aria-labels - スクリーンリーダー向けに説明的なラベルを追加します
  • a11y-focus-management - モーダルや動的コンテンツでフォーカスを管理します
  • a11y-color-contrast - 十分な色のコントラストを確保します
主な原則

セマンティック HTML

// Incorrect: div soup
<div onClick={handleClick}>Click me</div>

// Correct: semantic button
<button onClick={handleClick}>Click me</button>

モーダル内のフォーカス管理

function Modal({ isOpen, onClose, children }) {
  const closeButtonRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (isOpen) {
      closeButtonRef.current?.focus()
    }
  }, [isOpen])

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent>
        {children}
        <Button ref={closeButtonRef} onClick={onClose}>
          Close
        </Button>
      </DialogContent>
    </Dialog>
  )
}

スキップリンク

function Layout({ children }) {
  return (
    <>
      <a
        href="#main-content"
        className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4"
      >
        Skip to main content
      </a>
      <Header />
      <main id="main-content">{children}</main>
    </>
  )
}

10. TypeScript のパターン(中)

クイックリファレンス
  • ts-strict-mode - TypeScript の厳格な設定を有効にします
  • ts-component-props - 明示的な props インターフェースを定義します
  • ts-generics - 再利用可能なコンポーネントにはジェネリクスを使用します
  • ts-discriminated-unions - 状態マシンには判別可能なユニオンを使用します
  • ts-infer-when-possible - 明らかな場合は TypeScript の型推論を利用します
主な原則

コンポーネントの props

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'default' | 'destructive' | 'outline'
  size?: 'sm' | 'md' | 'lg'
  isLoading?: boolean
}

function Button({
  variant = 'default',
  size = 'md',
  isLoading,
  children,
  disabled,
  ...props
}: ButtonProps) {
  return (
    <button
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  )
}

状態に判別可能なユニオンを使用

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function useAsync<T>(asyncFn: () => Promise<T>) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })

  // TypeScript knows exact shape based on status
  if (state.status === 'success') {
    return state.data // TypeScript knows data exists
  }
}

ジェネリックコンポーネント

interface SelectProps<T> {
  options: T[]
  value: T
  onChange: (value: T) => void
  getLabel: (option: T) => string
  getValue: (option: T) => string
}

function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
  return (
    <select
      value={getValue(value)}
      onChange={(e) => {
        const selected = options.find(o => getValue(o) === e.target.value)
        if (selected) onChange(selected)
      }}
    >
      {options.map((option) => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  )
}

React 19 以降の機能

新しいフック

useActionState - フォームの状態管理

'use client'

import { useActionState } from 'react'

function SubscribeForm() {
  const [state, formAction, isPending] = useActionState(
    async (prevState, formData) => {
      const email = formData.get('email')
      const result = await subscribe(email)
      return result
    },
    null
  )

  return (
    <form action={formAction}>
      <Input name="email" type="email" />
      <Button type="submit" disabled={isPending}>
        {isPending ? 'Subscribing...' : 'Subscribe'}
      </Button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

useOptimistic - 楽観的 UI 更新

'use client'

import { useOptimistic } from 'react'

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  )

  async function addTodo(formData: FormData) {
    const title = formData.get('title') as string
    const newTodo = { id: crypto.randomUUID(), title, completed: false }

    addOptimisticTodo(newTodo) // Immediately show in UI
    await createTodo(title)    // Then persist to server
  }

  return (
    <>
      <form action={addTodo}>
        <Input name="title" />
        <Button type="submit">Add</Button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </>
  )
}

use - 非同期リソースの読み取り

import { use, Suspense } from 'react'

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // Suspends until resolved
  return <div>{user.name}</div>
}

function Page({ userId }: { userId: string }) {
  const userPromise = fetchUser(userId)

  return (
    <Suspense fallback={<UserSkeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

Project Structure

src/
├── app/                    # Next.js App Router
│   ├── layout.tsx
│   ├── page.tsx
│   └── (routes)/
├── components/
│   ├── ui/                 # shadcn/ui components
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   └── ...
│   └── features/           # Feature-specific components
│       ├── auth/
│       └── dashboard/
├── hooks/                  # Custom hooks
├── lib/                    # Utilities
│   ├── utils.ts           # cn() helper, etc.
│   └── validations.ts     # Zod schemas
├── actions/               # Server Actions
└── types/                 # TypeScript types

References

コメント