mcpField
R
react-typescript

react-typescript

github.com/tenequm開発3981 回インストール2 日前に更新
リポジトリ

react-typescript について

react-typescript は、TypeScript 5.9 で型安全な React 19.2 アプリを構築する開発者向けのスキルです。新しい React のパターンと厳密な型付けを扱い、React Compiler を活用して手動のメモ化を減らす方法を示します。

主な機能

  1. 01React 19 のコンポーネントと Hook のパターン
  2. 02型安全な props と状態のモデリング
  3. 03React Compiler によるメモ化の指針
  4. 04厳密な TypeScript 設定
  5. 05Activity と useEffectEvent のパターン

活用シーン

  1. 01ネイティブ要素の props を使った React コンポーネント作成
  2. 02無効な組み合わせを避ける非同期状態のモデリング
  3. 03非表示のタブ状態を保持する UI の構築
  4. 04React Compiler による手動メモ化の削減

インストール

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

$npx skills add https://github.com/tenequm/skills --skill react-typescript

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

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

LICENSE.txt
references/
react-19-features.md
typescript-patterns.md

詳細

React TypeScript

TypeScript 5.9 を使用して、型安全な React 19.2 アプリケーションを構築するためのパターンです。React Compiler がメモ化を自動的に処理するため、シンプルなコンポーネントを書けば、ツールが最適化してくれます。

重要なルール

forwardRef は使わない — ref はプロパティになりました
// WRONG - deprecated pattern
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input ref={ref} {...props} />
))

// CORRECT - React 19: ref is a regular prop
function Input({ ref, ...props }: React.ComponentProps<"input">) {
  return <input ref={ref} {...props} />
}
React Compiler 使用時は手動でメモ化しない
// WRONG - unnecessary with React Compiler
const MemoizedList = memo(function List({ items }: { items: Item[] }) {
  const sorted = useMemo(() => items.toSorted(compare), [items])
  const handleClick = useCallback((id: string) => onSelect(id), [onSelect])
  return sorted.map(item => <Row key={item.id} onClick={() => handleClick(item.id)} />)
})

// CORRECT - React Compiler auto-memoizes all of this
function List({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
  const sorted = items.toSorted(compare)
  return sorted.map(item => <Row key={item.id} onClick={() => onSelect(item.id)} />)
}
要素のプロパティには React.ComponentProps<> を使う
// WRONG - manual HTML attribute typing
interface ButtonProps {
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void
  disabled?: boolean
  children: React.ReactNode
  className?: string
}

// CORRECT - extend native element props
type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "ghost"
}
状態はブール値ではなく、判別可能なユニオン型で定義する
// WRONG - impossible states possible
interface RequestState { isLoading: boolean; error: string | null; data: User | null }

// CORRECT - discriminated union prevents impossible states
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: User }
型安全なリテラルには satisfies を使う
// WRONG - widens to Record<string, Route>
const routes: Record<string, Route> = { home: { path: "/" }, about: { path: "/about" } }

// CORRECT - preserves literal keys while checking shape
const routes = {
  home: { path: "/" },
  about: { path: "/about" },
} satisfies Record<string, Route>

routes.home // typed, autocomplete works
Context には厳密なデフォルト値を設定するか、エラーをスローする
// WRONG - null default with no guard
const AuthContext = createContext<AuthState | null>(null)
// consumers must null-check every time

// CORRECT - factory hook that throws on missing provider
const AuthContext = createContext<AuthState | null>(null)

function useAuth(): AuthState {
  const ctx = use(AuthContext)
  if (ctx === null) throw new Error("useAuth must be used within AuthProvider")
  return ctx
}
useContext() より use() を優先する
// OLD pattern
function Header() {
  const theme = useContext(ThemeContext) // cannot use after early return
  if (!isVisible) return null
  return <h1 style={{ color: theme.color }}>Title</h1>
}

// CORRECT - React 19: use() works after early returns
function Header({ isVisible }: { isVisible: boolean }) {
  if (!isVisible) return null
  const theme = use(ThemeContext) // works here - use() is not bound by hook rules
  return <h1 style={{ color: theme.color }}>Title</h1>
}

React 19 のパターン

コンポーネントの作成

スタイル設定用のフックとして data-slot を使ったシンプルな関数にします。forwardRef や FC は使いません。

type CardProps = React.ComponentProps<"div"> & {
  variant?: "elevated" | "outlined"
}

function Card({ variant = "outlined", className, ...props }: CardProps) {
  return (
    <div
      data-slot="card"
      data-variant={variant}
      className={cn("rounded-xl border bg-card", className)}
      {...props}
    />
  )
}

function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
  return <h3 data-slot="card-title" className={cn("font-semibold", className)} {...props} />
}
Actions と useTransition

トランジション内の非同期関数が、保留中の状態、エラー、フォームのリセットを自動的に処理します。

function UpdateProfile({ userId }: { userId: string }) {
  const [error, submitAction, isPending] = useActionState(
    async (_prev: string | null, formData: FormData) => {
      const result = await updateProfile(userId, formData)
      if (result.error) return result.error
      redirect("/profile")
      return null
    },
    null
  )

  return (
    <form action={submitAction}>
      <input type="text" name="displayName" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
      {error && <p className="text-destructive">{error}</p>}
    </form>
  )
}

フォーム以外の Actions に useTransition を使う場合:

function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(async () => { await onDelete() })}
    >
      {isPending ? "Deleting..." : "Delete"}
    </button>
  )
}

即時のフィードバックに useOptimistic を使う場合:

function LikeButton({ likes, onLike }: { likes: number; onLike: () => Promise<void> }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (prev) => prev + 1)

  const handleLike = async () => {
    addOptimisticLike(null)
    await onLike()
  }

  return (
    <form action={handleLike}>
      <button type="submit">{optimisticLikes} Likes</button>
    </form>
  )
}
use() フック

レンダー中に Promise と Context を読み取ります。早期リターンの後など、条件付きでも使用できます。

// Reading a promise - suspends until resolved
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise)
  return (
    <ul>
      {comments.map(c => <li key={c.id}>{c.text}</li>)}
    </ul>
  )
}

// Parent gets promise from loader/cache, NOT created during render
function PostPage({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  return (
    <Suspense fallback={<Skeleton />}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  )
}

// Reading context conditionally
function AdminPanel({ user }: { user: User | null }) {
  if (!user) return <LoginPrompt />
  const permissions = use(PermissionsContext) // legal - use() works after early return
  if (!permissions.isAdmin) return <Forbidden />
  return <Dashboard user={user} permissions={permissions} />
}

重要: use() はレンダー中に作成された Promise をサポートしません。ローダー、サーバー関数、またはキャッシュされたソースから Promise を渡してください。

Activity コンポーネント(React 19.2)

非表示の UI の状態を保持します。非表示の子要素は状態と DOM を維持したまま、エフェクトがアンマウントされます。

import { Activity, useState } from "react"

function TabLayout({ tabs }: { tabs: TabConfig[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id)

  return (
    <div>
      <nav>
        {tabs.map(tab => (
          <button key={tab.id} onClick={() => setActiveTab(tab.id)}>
            {tab.label}
          </button>
        ))}
      </nav>

      {tabs.map(tab => (
        <Activity key={tab.id} mode={activeTab === tab.id ? "visible" : "hidden"}>
          <tab.component />
        </Activity>
      ))}
    </div>
  )
}

主な動作:

  • visible — 通常どおりレンダーされ、エフェクトがマウントされます
  • hidden — display: none で非表示になり、エフェクトがクリーンアップされ、状態は保持され、更新は遅延されます
  • 事前レンダー:<Activity mode="hidden"> は将来すばやく表示できるよう、子要素を低い優先度でレンダーします
  • DOM の副作用(動画、音声)は非表示にしても継続します。useLayoutEffect のクリーンアップを追加してください
useEffectEvent(React 19.2)

エフェクトから非リアクティブなロジックを切り離します。イベント関数は、エフェクトを再実行せずに最新のプロパティと状態を常に参照します。

function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme) // always reads latest theme
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on("connected", () => onConnected())
    connection.connect()
    return () => connection.disconnect()
  }, [roomId]) // theme NOT in deps - onConnected is an Effect Event
}

ルール:

  • エフェクトまたはほかのエフェクトイベントの内部からのみ呼び出してください
  • 子コンポーネントに渡したり、依存配列に含めたりしないでください
  • レンダー中に呼び出さないでください
  • エフェクトから発火する、概念上の「イベント」であるロジックに使用してください

カスタムフックのパターン:

function useInterval(callback: () => void, delay: number | null) {
  const onTick = useEffectEvent(callback)

  useEffect(() => {
    if (delay === null) return
    const id = setInterval(() => onTick(), delay)
    return () => clearInterval(id)
  }, [delay])
}
ドキュメントメタデータ

コンポーネント内に <title>、<meta>、<link> を直接レンダーします。React がこれらを <head> に移動します。

function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.excerpt} />
      <meta name="author" content={post.author} />
      <link rel="canonical" href={`https://example.com/posts/${post.slug}`} />
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}
Context をプロバイダーとして使う
const ThemeContext = createContext<Theme>("light")

// React 19 - use Context directly as provider (no .Provider)
function App({ children }: { children: React.ReactNode }) {
  return (
    <ThemeContext value="dark">
      {children}
    </ThemeContext>
  )
}
Ref のクリーンアップ関数
function MeasuredBox() {
  return (
    <div
      ref={(node) => {
        if (node) {
          const observer = new ResizeObserver(handleResize)
          observer.observe(node)
          return () => observer.disconnect() // cleanup on unmount
        }
      }}
    />
  )
}

React Compiler

機能

React Compiler(babel-plugin-react-compiler)はビルド時にコードを解析し、メモ化を自動的に挿入します。多くの場合、手動での useMemo、useCallback、React.memo の使用に置き換わります。

自動的にメモ化されるもの:

  • コンポーネントの戻り値(プロパティが変更されていない場合は再レンダーをスキップ)
  • コンポーネント内のコストの高い計算
  • プロパティとして渡されるコールバック関数
  • JSX 要素の作成
セットアップ(Vite)
pnpm add -D babel-plugin-react-compiler
// vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ["babel-plugin-react-compiler"], // must be first
      },
    }),
  ],
})
してはいけないこと
// DON'T - compiler handles this
const MemoComponent = memo(MyComponent)
const memoized = useMemo(() => expensive(data), [data])
const stableCallback = useCallback(() => handler(id), [id])

// DO - write plain code, compiler optimizes
function MyComponent({ data, onSelect }: Props) {
  const processed = expensive(data)
  return <Child onClick={() => onSelect(data.id)} />
}
手動でのメモ化が引き続き有効な場合
  • エフェクトの実行タイミングを正確に制御する必要がある場合に、エフェクトの依存値として useMemo/useCallback を使う
  • 多数のコンポーネントで共有される値(Compiler がメモ化するのはコンポーネント単位であり、グローバルではありません)
  • コンパイルを無効にする場合:"use no memo" ディレクティブを指定すると、特定のコンポーネントのコンパイルがスキップされます
検証

Compiler によって最適化されたコンポーネントには、React DevTools に「Memo」バッジが表示されます。ビルド出力に react/compiler-runtime のインポートがあるか確認してください。

TypeScript のパターン

React 用の厳格な tsconfig
{
  "compilerOptions": {
    "strict": true,
    "target": "esnext",
    "module": "nodenext",
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedSideEffectImports": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "types": []
  }
}
コンポーネントのプロパティのパターン
// Extending native element props
type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "secondary"
  isLoading?: boolean
}

function Button({ variant = "primary", isLoading, children, ...props }: ButtonProps) {
  return (
    <button data-slot="button" disabled={isLoading || props.disabled} {...props}>
      {isLoading ? <Spinner /> : children}
    </button>
  )
}

// Polymorphic "as" prop
type PolymorphicProps<E extends React.ElementType> = {
  as?: E
} & Omit<React.ComponentProps<E>, "as">

function Text<E extends React.ElementType = "span">({
  as,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || "span"
  return <Component {...props} />
}

// Usage: <Text as="h1">Hello</Text>
イベントハンドラーの型
function Form() {
  // Inferred from handler context - no explicit typing needed
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    // process formData
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value)
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") submit()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyDown={handleKeyDown} />
    </form>
  )
}
フックの型
// useState - inferred when initial value provided
const [count, setCount] = useState(0) // number
const [user, setUser] = useState<User | null>(null) // explicit for null init

// useReducer - discriminated union actions
type CounterAction =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function counterReducer(state: number, action: CounterAction): number {
  switch (action.type) {
    case "increment": return state + action.amount
    case "decrement": return state - action.amount
    case "reset": return 0
  }
}

const [count, dispatch] = useReducer(counterReducer, 0)
dispatch({ type: "increment", amount: 5 })

// useRef - element refs (React 19: returns RefObject<T | null>, always nullable)
const inputRef = useRef<HTMLInputElement>(null)

// useRef - mutable value (no null)
const intervalRef = useRef<number | undefined>(undefined)
ジェネリックコンポーネント
type SelectProps<T> = {
  items: T[]
  value: T
  onChange: (item: T) => void
  getLabel: (item: T) => string
  getKey: (item: T) => string
}

function Select<T>({ items, value, onChange, getLabel, getKey }: SelectProps<T>) {
  return (
    <select
      value={getKey(value)}
      onChange={(e) => {
        const item = items.find(i => getKey(i) === e.target.value)
        if (item) onChange(item)
      }}
    >
      {items.map(item => (
        <option key={getKey(item)} value={getKey(item)}>
          {getLabel(item)}
        </option>
      ))}
    </select>
  )
}

// Usage - T inferred as User
<Select
  items={users}
  value={selectedUser}
  onChange={setSelectedUser}
  getLabel={u => u.name}
  getKey={u => u.id}
/>
コンポーネントの状態に判別可能なユニオン型を使う
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T }

function AsyncContent<T>({
  state,
  render,
}: {
  state: AsyncState<T>
  render: (data: T) => React.ReactNode
}) {
  switch (state.status) {
    case "idle": return null
    case "loading": return <Spinner />
    case "error": return <ErrorDisplay error={state.error} />
    case "success": return <>{render(state.data)}</>
  }
}
Zod v4 との統合
import { z } from "zod"

const UserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "viewer"]),
})

type User = z.infer<typeof UserSchema>

// Form validation with useActionState
type FieldErrors = { name?: string[]; email?: string[]; role?: string[] }

function CreateUser() {
  const [errors, submitAction, isPending] = useActionState(
    async (_prev: FieldErrors | null, formData: FormData) => {
      const result = UserSchema.safeParse(Object.fromEntries(formData))
      if (!result.success) {
        // Zod v4: use z.flattenError() to get field-level errors
        const flat = z.flattenError(result.error)
        return flat.fieldErrors as FieldErrors
      }
      await saveUser(result.data)
      return null
    },
    null
  )

  return (
    <form action={submitAction}>
      <input name="name" />
      {errors?.name && (
        <span className="text-destructive">{errors.name[0]}</span>
      )}
      <input name="email" type="email" />
      <select name="role">
        <option value="user">User</option>
        <option value="admin">Admin</option>
        <option value="viewer">Viewer</option>
      </select>
      <button type="submit" disabled={isPending}>Create</button>
    </form>
  )
}

ベストプラクティス

  1. コンポーネントには通常の関数を使う - FC、forwardRef、memoは使いません。以前の型ではFCが暗黙的にchildrenを型付けしていましたが、通常の関数シグネチャより優れた点はありません。React Compilerに最適化を任せましょう。
  2. ネイティブ要素を拡張するにはReact.ComponentProps<"element">を使う - すべてのHTML属性を捕捉できます。
  3. 状態にはブール値より判別可能なユニオンを使う - 型レベルで不可能な状態を防げます。
  4. useContext()よりuse()を使う - 条件付きで使用でき、ガードを使うコンテキスト処理もすっきりします。
  5. 設定オブジェクトにはsatisfiesを使う - 形状を検証しながらリテラル型を保持できます。
  6. 状態の保持が重要な場合(タブ、サイドバー、ウィザード)は、条件付きレンダリングよりActivityを使う
  7. 依存関係の抑制よりuseEffectEventを使う - エフェクトから非リアクティブなロジックをすっきり切り出せます。
  8. 厳格なtsconfigを使う - noUncheckedIndexedAccess、exactOptionalPropertyTypes、verbatimModuleSyntaxを有効にします。
  9. ルート設定、テーマトークン、ルックアップオブジェクトにはas const satisfiesを使う
  10. switchで型を絞り込む - defaultケースでneverを使って網羅性を確認します。
  11. import defer(TS 5.9)- 遅延読み込みする重いモジュールの評価を、最初のプロパティアクセスまで先送りできます。typescript-patterns.mdを参照してください。

詳細解説

  • react-19-features.md - 詳細な例を含む、React 19/19.2の機能を網羅したリファレンス
  • typescript-patterns.md - React向けの高度なTypeScriptパターン:ジェネリクス、ユーティリティ型、厳格な設定、型レベルプログラミング

リソース

コメント