React デザインパターン
はじめに
長らくReactを雰囲気で書いてきました。コンポーネントを作り、propsを渡し、状態を管理する基本的な概念は理解していたものの、より体系的なアプローチや設計パターンについては深く考えずにコードを書いていました。しかし、より大規模で保守性の高いアプリケーションを構築するにつれ、単なる「動くコード」を超えた、堅牢な設計原則の必要性を痛感するようになりました。
この記事は、私自身の再学習を共有するものです。
※2025/04/21時点、私が関わっているプロダクトのCrowd AgentのReactのバージョンは"18.3.1"
です。
目次
- Reactデザインパターンとは
- HOC (高階コンポーネント) パターン[※React18以降では、カスタムフック推奨]
- Provider パターン
- Presentational と Container コンポーネントパターン
- React Hooks デザインパターン
- Compound Component パターン
- 条件付きレンダリングパターン
- Render Props パターン
- State Reducer パターン
- Controlled Components パターン
- Extensible Styles パターン
- State Initializer パターン
- パターン選択の指針
- まとめ
Reactデザインパターンとは
Reactデザインパターンとは、React開発における共通の問題を解決するための検証済みの設計手法です。これらのパターンを活用することで、開発者は時間を節約し、コーディングの労力を減らすことができます。TypeScriptと組み合わせることで、型安全性も確保できます。
デザインパターンは特に以下のような場面で役立ちます:
- コードの再利用性を高める
- 複雑なアプリケーションの構造を整理する
- コンポーネント間でロジックを共有する
- コードの保守性と拡張性を向上させる
- 型の一貫性を保証し、バグを早期に発見する
React公式ドキュメントによれば、「効果的なコンポーネント設計の基本は、関心の分離(separation of concerns)と単一責任の原則に基づいています」。それでは、最も重要なReactデザインパターンをTypeScript形式で見ていきましょう。
HOC (高階コンポーネント) パターン[※React18以降では、カスタムフック推奨]
概要
HOC(High Order Component)パターンは、コンポーネントを引数として受け取り、新しい機能を追加した別のコンポーネントを返す関数です。このパターンはReactの「継承よりもコンポジション」という哲学に基づいています。これはReact公式ドキュメントの高階コンポーネントガイドにも明記されています。
React 16.8でHooksが導入されて以降、HOCの使用はほぼないです。
主な用途
- 複数のコンポーネント間でロジックを再利用する
- サードパーティのサブスクリプションデータを使用するコンポーネント
- 同一のデザイン要素(影やボーダーなど)で異なるカードビューを強化
- ログインユーザーデータを必要とするアプリコンポーネント
基本構造
import { ComponentType, FC } from "react";
// コンポーネントのプロップスの型を定義
type BaseProps = {
// 共通のプロップス
};
// HOCの型定義
export function higherOrderComponent<P extends BaseProps>(
WrappedComponent: ComponentType<P>
): FC<P> {
// HOCが返すコンポーネント
const EnhancedComponent: FC<P> = (props) => {
// ここに共通のロジックを追加
return <WrappedComponent {...props} />;
};
// デバッグしやすくするための表示名の設定
const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
EnhancedComponent.displayName = `HOC(${wrappedName})`;
return EnhancedComponent;
}
// 使用例
const EnhancedComponent = higherOrderComponent(OriginalComponent);
実践例:ログイン状態チェック
// withAuth.tsx
import { ComponentType, FC, useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
// 認証チェック結果の型
type AuthState = {
isAuthenticated: boolean;
isLoading: boolean;
};
// 認証が必要なコンポーネントに追加される追加のプロップスがあれば定義
type WithAuthProps = {
// 必要に応じて認証関連のpropsを追加
};
// HOC関数の定義
export function withAuth<P extends object>(Component: ComponentType<P>): FC<Omit<P, keyof WithAuthProps>> {
const AuthComponent: FC<Omit<P, keyof WithAuthProps>> = (props) => {
// 認証状態を管理
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
isLoading: true
});
useEffect(() => {
// 認証状態の確認(例:ローカルストレージからトークンを取得)
const checkAuth = async (): Promise<void> => {
try {
const token = localStorage.getItem('token');
// 実際のアプリでは、トークンの有効性を検証するAPIコールも行うべき
setAuthState({
isAuthenticated: !!token,
isLoading: false
});
} catch (error) {
console.error('認証チェックエラー:', error);
setAuthState({
isAuthenticated: false,
isLoading: false
});
}
};
checkAuth();
}, []);
// ローディング中の表示
if (authState.isLoading) {
return <div>Loading...</div>;
}
// 未認証の場合はログインページへリダイレクト
if (!authState.isAuthenticated) {
return <Navigate to="/login" />;
}
// 認証済みの場合は元のコンポーネントを表示
// TypeScriptはpropsの型が正しいことを保証
return <Component {...(props as P)} />;
};
// デバッグを容易にするための表示名の設定
const componentName = Component.displayName || Component.name || 'Component';
AuthComponent.displayName = `withAuth(${componentName})`;
return AuthComponent;
}
HOCの現代的な代替案
React Hooksの導入に関する公式ブログによれば、React 18以降では、多くの場合HOCの代わりにカスタムフックを使用することが推奨されています。上記の認証例は次のようにカスタムフックで書き直すことができます:
// useAuth.tsx
import { useEffect, useState } from 'react';
type AuthState = {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
};
export const useAuth = (): AuthState => {
const [authState, setState] = useState<AuthState>({
isAuthenticated: false,
isLoading: true,
error: null
});
useEffect(() => {
const checkAuth = async (): Promise<void> => {
try {
const token = localStorage.getItem('token');
setState({
isAuthenticated: !!token,
isLoading: false,
error: null
});
} catch (error) {
setState({
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : '認証チェック中にエラーが発生しました'
});
}
};
checkAuth();
}, []);
return authState;
};
// 使用例
// ProfilePage.tsx
import { FC } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from './useAuth';
type ProfilePageProps = {
userId?: string;
};
export const ProfilePage: FC<ProfilePageProps> = ({ userId }) => {
const { isAuthenticated, isLoading, error } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!isAuthenticated) return <Navigate to="/login" />;
return <div>プロフィールページの内容 (ユーザーID: {userId || '未設定'})</div>;
};
Provider パターン
概要
Providerパターンは、コンポーネントツリー全体でグローバルデータを共有するための方法です。React Context API公式ドキュメントで説明されているように、このパターンを使ってデータをコンポーネントツリーの下層に直接渡すことができます。TypeScriptを使用することで、共有されるデータの型を明確に定義できます。
React 18以降では、このパターンはさらに重要になり、特にServer Componentsを使用するNext.jsドキュメントで説明されているように、フレームワークでの状態管理に不可欠になっています。
主な用途
- プロップドリリング(props drilling)の問題を解決
- グローバルな状態管理
- テーマや認証情報など、多くのコンポーネントで必要とされるデータの共有
基本構造(React-Reduxの例)
import { FC, ReactNode, StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import store from './store';
import App from './App';
// Providerに渡すpropsの型定義
type AppProviderProps = {
store: Store;
children: ReactNode;
};
// アプリケーションのProviderコンポーネント
export const AppProvider: FC<AppProviderProps> = ({ store, children }) => (
<Provider store={store}>
{children}
</Provider>
);
// React 18の新しいレンダリング方法
const rootElement = document.getElementById('root');
if (rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<AppProvider store={store}>
<App />
</AppProvider>
</StrictMode>
);
}
実践例:テーマプロバイダー
// ThemeContext.tsx
import { createContext, useState, FC, ReactNode, useContext } from 'react';
// テーマの型定義
type ThemeType = 'light' | 'dark';
// コンテキストの値の型定義
type ThemeContextType = {
theme: ThemeType;
toggleTheme: () => void;
};
// デフォルト値を持つコンテキストの作成
export const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {}, // ダミー関数
});
// プロバイダーのプロップスの型定義
type ThemeProviderProps = {
children: ReactNode;
initialTheme?: ThemeType;
};
// テーマプロバイダーコンポーネント
export const ThemeProvider: FC<ThemeProviderProps> = ({
children,
initialTheme = 'light'
}) => {
// テーマの状態管理
const [theme, setTheme] = useState<ThemeType>(initialTheme);
// テーマ切り替え関数
const toggleTheme = (): void => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
// コンテキスト値の作成
const contextValue: ThemeContextType = {
theme,
toggleTheme
};
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
};
// カスタムフックでコンテキストへのアクセスを簡単に
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// App.tsx
import { FC } from 'react';
import { ThemeProvider } from './ThemeContext';
import { ThemedButton } from './ThemedButton';
export const App: FC = () => {
return (
<ThemeProvider>
<div className="app">
<h1>テーマの切り替え</h1>
<ThemedButton />
</div>
</ThemeProvider>
);
};
// ThemedButton.tsx
import { FC } from 'react';
import { useTheme } from './ThemeContext';
// ボタンのスタイル型
type ButtonStyle = {
backgroundColor: string;
color: string;
padding: string;
border: string;
borderRadius: string;
};
export const ThemedButton: FC = () => {
// カスタムフックを使用してテーマコンテキストにアクセス
const { theme, toggleTheme } = useTheme();
// テーマに基づいたスタイルの作成
const buttonStyle: ButtonStyle = {
backgroundColor: theme === 'light' ? '#ffffff' : '#333333',
color: theme === 'light' ? '#333333' : '#ffffff',
padding: '10px 15px',
border: `1px solid ${theme === 'light' ? '#333333' : '#ffffff'}`,
borderRadius: '4px'
};
return (
<button
onClick={toggleTheme}
style={buttonStyle}
>
{theme === 'light' ? 'ダークモードに切り替え' : 'ライトモードに切り替え'}
</button>
);
};
Presentational と Container コンポーネントパターン
概要
このパターンは、UIを担当する「Presentational(表示)」コンポーネントとデータ取得や状態管理を担当する「Container(コンテナ)」コンポーネントに分けるアプローチです。このパターンはDan Abramovによる有名な記事で紹介され、広く採用されました。TypeScriptを使用することで、これらのコンポーネント間のデータの流れをより厳密に型付けできます。
React Hooksの導入により、このパターンの明確な区別は曖昧になってきていますが、関心の分離という原則は依然として重要です。現代のReactでは、カスタムフックを使用してデータ取得ロジックを分離する方法が一般的です。
主な特徴
Presentationalコンポーネント | Containerコンポーネント |
---|---|
見た目に関心がある | 動作に関心がある |
独自のDOMマークアップとスタイルを持つ | DOMマークアップやスタイルを持たない |
アプリの他の部分に依存しない | データやビヘイビアを提供する |
データとコールバックはpropsのみで受け取る | 状態を持つことが多い |
データの取得方法や変更方法には言及しない | アクションやAPIを呼び出す |
基本構造
import { FC } from 'react';
// 共通の型定義
type User = {
id: string;
username: string;
};
// Presentationalコンポーネント
type UsersListProps = {
users: User[];
};
export const UsersList: FC<UsersListProps> = ({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.username}
</li>
))}
</ul>
);
// Containerコンポーネント
import { Component } from 'react';
type UsersContainerState = {
users: User[];
loading: boolean;
error: string | null;
};
export class UsersContainer extends Component<{}, UsersContainerState> {
state: UsersContainerState = {
users: [],
loading: true,
error: null
};
componentDidMount() {
this.fetchUsers();
}
fetchUsers = async (): Promise<void> => {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const users = await response.json();
this.setState({ users, loading: false, error: null });
} catch (error) {
this.setState({
loading: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
render() {
const { users, loading, error } = this.state;
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <UsersList users={users} />;
}
}
現代的なアプローチ:カスタムフックを使った実装
Kent C. Doddsのブログ記事で説明されているように、React 18以降では、同様の関心の分離をHooksを使って実現することが一般的です:
// useUsers.ts - ロジックを分離したカスタムフック
import { useState, useEffect } from 'react';
export type User = {
id: string;
username: string;
};
export const useUsers = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsers = async (): Promise<void> => {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUsers(data);
setLoading(false);
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error');
setLoading(false);
}
};
fetchUsers();
}, []);
return { users, loading, error };
};
// UsersPage.tsx - ロジックとUIを組み合わせる
import { FC } from 'react';
import { useUsers, User } from './useUsers';
export const UsersList: FC<{ users: User[] }> = ({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.username}</li>
))}
</ul>
);
export const UsersPage: FC = () => {
const { users, loading, error } = useUsers();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <UsersList users={users} />;
};
このアプローチでは、データ取得ロジックがカスタムフックに分離され、UIコンポーネントはそのフックを使用するだけとなり、より明確な関心の分離が実現されています。
React Hooks デザインパターン
概要
React Hooksは、関数コンポーネントで状態やライフサイクルなどのReact機能を使用できるようにする仕組みです。React Hooks公式ドキュメントで詳しく説明されているように、TypeScriptと組み合わせることで、型安全なHooksを作成できます。
React 18では、新しいフックであるuseId
やuseSyncExternalStore
、useTransition
などが導入され、パフォーマンスの最適化やサーバーサイドレンダリングのサポートが強化されています。
主な用途
- 状態管理(useState, useReducer)
- 副作用の処理(useEffect, useLayoutEffect)
- コンテキストへのアクセス(useContext)
- メモ化による最適化(useMemo, useCallback)
- 参照の保持(useRef)
- カスタムフックを通じたロジックの再利用
基本的なフックの使用例
import { useState, useEffect, FC } from 'react';
// 型定義
type User = {
id: string;
name: string;
email: string;
};
type ProfileProps = {
userId: string;
};
// ユーザーデータを取得するAPI関数
const fetchUser = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return response.json();
};
export const Profile: FC<ProfileProps> = ({ userId }) => {
// 状態の型を明示的に指定
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 非同期関数を定義
const loadUser = async (): Promise<void> => {
try {
setLoading(true);
const userData = await fetchUser(userId);
setUser(userData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setUser(null);
} finally {
setLoading(false);
}
};
loadUser();
// クリーンアップ関数
return () => {
// コンポーネントのアンマウント時に実行される処理
// 例: データ取得のキャンセルなど
};
}, [userId]); // 依存配列
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
パフォーマンス最適化フックの例
React公式ドキュメントのパフォーマンス最適化セクションによれば、React 18以降では、パフォーマンス最適化がより重要になっています。以下はuseMemo
とuseCallback
を使用して不要な再計算や再レンダリングを防ぐ例です:
import { useState, useMemo, useCallback, FC } from 'react';
type Item = {
id: number;
name: string;
price: number;
};
type ShoppingCartProps = {
items: Item[];
};
export const ShoppingCart: FC<ShoppingCartProps> = ({ items }) => {
const [quantity, setQuantity] = useState<Record<number, number>>({});
// 数量変更ハンドラー - useCallbackでメモ化
const handleQuantityChange = useCallback((id: number, newQuantity: number) => {
setQuantity(prev => ({
...prev,
[id]: Math.max(0, newQuantity)
}));
}, []);
// 合計金額の計算 - useMemoでメモ化
const totalPrice = useMemo(() => {
return items.reduce((sum, item) => {
const itemQuantity = quantity[item.id] || 0;
return sum + (item.price * itemQuantity);
}, 0);
}, [items, quantity]);
return (
<div className="shopping-cart">
<h2>ショッピングカート</h2>
<ul>
{items.map(item => (
<li key={item.id}>
<span>{item.name} - {item.price}円</span>
<input
type="number"
min="0"
value={quantity[item.id] || 0}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value, 10))}
/>
</li>
))}
</ul>
<div className="total">
<strong>合計: {totalPrice}円</strong>
</div>
</div>
);
};
カスタムフックの例:フォーム管理
React Hook FormやFormikのようなライブラリも参考にしながら、カスタムフックを使ったフォーム管理の例を示します:
// useForm.ts
import { useState, ChangeEvent } from 'react';
// フォームの値の型
type FormValues = Record<string, any>;
// バリデーションルールの型
type ValidationRule = {
required?: string;
pattern?: RegExp;
message?: string;
custom?: (value: any) => boolean;
customMessage?: string;
};
type ValidationRules = Record<string, ValidationRule>;
// エラーメッセージの型
type FormErrors = Record<string, string | undefined>;
// useFormフックが返す値の型
type UseFormReturn<T extends FormValues> = {
values: T;
errors: FormErrors;
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
validate: (rules: ValidationRules) => boolean;
resetForm: () => void;
setFieldValue: (name: string, value: any) => void;
touchField: (name: string) => void;
touched: Record<string, boolean>;
};
// カスタムフックの実装
export const useForm = <T extends FormValues>(initialValues: T): UseFormReturn<T> => {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// 入力変更ハンドラー
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>): void => {
const { name, value, type, checked } = e.target;
// チェックボックスとその他の入力で異なる処理
const newValue = type === 'checkbox' ? checked : value;
setValues(prevValues => ({
...prevValues,
[name]: newValue
}));
// フィールドを触れた状態にする
if (!touched[name]) {
setTouched(prev => ({ ...prev, [name]: true }));
}
// 入力時にエラーをクリア
if (errors[name]) {
setErrors(prevErrors => ({
...prevErrors,
[name]: undefined
}));
}
};
// 特定のフィールドの値を設定
const setFieldValue = (name: string, value: any): void => {
setValues(prevValues => ({
...prevValues,
[name]: value
}));
};
// フィールドを触れた状態にする
const touchField = (name: string): void => {
setTouched(prev => ({ ...prev, [name]: true }));
};
// バリデーション関数
const validate = (validationRules: ValidationRules): boolean => {
let isValid = true;
const newErrors: FormErrors = {};
// バリデーションルールを適用
Object.keys(validationRules).forEach(field => {
const value = values[field];
const rules = validationRules[field];
// 必須チェック
if (rules.required && !value) {
newErrors[field] = rules.required;
isValid = false;
}
// パターンチェック
else if (rules.pattern && value && !rules.pattern.test(value)) {
newErrors[field] = rules.message;
isValid = false;
}
// カスタムバリデーション
else if (rules.custom && value && !rules.custom(value)) {
newErrors[field] = rules.customMessage;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
};
// フォームリセット
const resetForm = (): void => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return {
values,
errors,
handleChange,
validate,
resetForm,
setFieldValue,
touchField,
touched
};
};
使用例
import { FC, FormEvent } from 'react';
import { useForm } from './useForm';
// フォームの値の型
type SignupFormValues = {
username: string;
email: string;
password: string;
};
export const SignupForm: FC = () => {
// 初期値を指定してフックを使用
const {
values,
errors,
touched,
handleChange,
validate,
resetForm
} = useForm<SignupFormValues>({
username: '',
email: '',
password: ''
});
// バリデーションルール
const validationRules = {
username: {
required: 'ユーザー名は必須です'
},
email: {
required: 'メールアドレスは必須です',
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: '有効なメールアドレスを入力してください'
},
password: {
required: 'パスワードは必須です',
pattern: /^.{8,}$/,
message: 'パスワードは8文字以上である必要があります',
// カスタムバリデーションの例
custom: (value: string) => /[A-Z]/.test(value) && /[0-9]/.test(value),
customMessage: 'パスワードは少なくとも1つの大文字と数字を含む必要があります'
}
};
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
if (validate(validationRules)) {
console.log('Form submitted:', values);
// APIリクエストなどの処理
resetForm();
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">ユーザー名:</label>
<input
type="text"
id="username"
name="username"
value={values.username}
onChange={handleChange}
className={touched.username && errors.username ? 'error' : ''}
/>
{touched.username && errors.username && <p className="error">{errors.username}</p>}
</div>
<div>
<label htmlFor="email">メールアドレス:</label>
<input
type="email"
id="email"
name="email"
value={values.email}
onChange={handleChange}
className={touched.email && errors.email ? 'error' : ''}
/>
{touched.email && errors.email && <p className="error">{errors.email}</p>}
</div>
<div>
<label htmlFor="password">パスワード:</label>
<input
type="password"
id="password"
name="password"
value={values.password}
onChange={handleChange}
className={touched.password && errors.password ? 'error' : ''}
/>
{touched.password && errors.password && <p className="error">{errors.password}</p>}
</div>
<button type="submit">登録</button>
</form>
);
};
Compound Component パターン
概要
Compound Component(複合コンポーネント)パターンは、親コンポーネントと子コンポーネント間で暗黙的に状態と振る舞いを共有する方法です。このパターンはRyan Florenceによる投稿で初めて紹介され、Kent C. Dodds氏のAdvanced React Patternsで詳しく解説されています。React 18以降では、Context APIとHooksを組み合わせたこのパターンの実装がより一般的になっています。
主な用途
- 密接に関連したコンポーネント群を構築する
- APIの表現力を高める
- 複数のコンポーネント間で状態とロジックを共有する
基本構造
import { useState, createContext, useContext, FC, ReactNode } from "react";
// 選択肢の値の型
type OptionValue = string | number;
// SelectContextの型
type SelectContextType = {
activeOption: OptionValue | null;
setActiveOption: (value: OptionValue) => void;
} | undefined;
// コンテキストの作成
const SelectContext = createContext<SelectContextType>(undefined);
// Selectコンポーネントの型
type SelectProps = {
children: ReactNode;
defaultValue?: OptionValue;
onChange?: (value: OptionValue) => void;
};
// Optionコンポーネントの型
type OptionProps = {
value: OptionValue;
children: ReactNode;
disabled?: boolean;
};
// 子コンポーネント
export const Option: FC<OptionProps> = ({ value, children, disabled = false }) => {
const context = useContext(SelectContext);
// コンテキストが未定義の場合(Selectの外で使用された場合)
if (!context) {
throw new Error(
"Option should be used within the scope of a Select component!"
);
}
const { activeOption, setActiveOption } = context;
const isActive = activeOption === value;
const handleClick = () => {
if (!disabled) {
setActiveOption(value);
}
};
return (
<div
className={`select-option ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
style={{
backgroundColor: isActive ? 'grey' : 'white',
opacity: disabled ? 0.5 : 1,
padding: "8px 12px",
cursor: disabled ? "not-allowed" : "pointer"
}}
onClick={handleClick}
aria-selected={isActive}
role="option"
>
{children}
</div>
);
};
// 親コンポーネント
export const Select: FC<SelectProps> & {
Option: FC<OptionProps>;
} = ({ children, defaultValue = null, onChange }) => {
const [activeOption, setActiveOption] = useState<OptionValue | null>(defaultValue);
const handleOptionChange = (value: OptionValue): void => {
setActiveOption(value);
onChange?.(value);
};
return (
<SelectContext.Provider value={{ activeOption, setActiveOption: handleOptionChange }}>
<div className="select-container">
{children}
</div>
</SelectContext.Provider>
);
};
// コンポーネントの関連付け
Select.Option = Option;
// 使用例を包含したコンポーネント
export const SelectExample: FC = () => {
const handleChange = (value: OptionValue) => {
console.log('Selected value:', value);
};
return (
<Select defaultValue="john" onChange={handleChange}>
<Select.Option value="oliver">Oliver</Select.Option>
<Select.Option value="eve">Eve</Select.Option>
<Select.Option value="john">John</Select.Option>
<Select.Option value="disabled" disabled>Disabled Option</Select.Option>
</Select>
);
};
実践例:タブコンポーネント
import { createContext, useContext, useState, FC, ReactNode } from 'react';
// タブIDの型
type TabId = string | number;
// タブコンテキストの型
type TabContextType = {
activeTab: TabId | null;
setActiveTab: (id: TabId) => void;
} | undefined;
// Tabコンテキストを作成
const TabContext = createContext<TabContextType>(undefined);
// Tabsコンポーネントの型
type TabsProps = {
children: ReactNode;
defaultTab?: TabId;
onChange?: (tabId: TabId) => void;
};
// TabListコンポーネントの型
type TabListProps = {
children: ReactNode;
className?: string;
};
// TabListコンポーネント
export const TabList: FC<TabListProps> = ({ children, className }) => {
return (
<div className={`tab-list ${className || ''}`}>
{children}
</div>
);
};
// Tabコンポーネントの型
type TabProps = {
id: TabId;
children: ReactNode;
disabled?: boolean;
};
// Tabコンポーネント
export const Tab: FC<TabProps> = ({ id, children, disabled = false }) => {
const context = useContext(TabContext);
if (!context) {
throw new Error("Tab must be used within a Tabs component");
}
const { activeTab, setActiveTab } = context;
const isActive = activeTab === id;
const handleClick = () => {
if (!disabled) {
setActiveTab(id);
}
};
return (
<div
className={`tab ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={handleClick}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
tabIndex={isActive ? 0 : -1}
>
{children}
</div>
);
};
// TabPanelコンポーネントの型
type TabPanelProps = {
id: TabId;
children: ReactNode;
};
// TabPanelコンポーネント
export const TabPanel: FC<TabPanelProps> = ({ id, children }) => {
const context = useContext(TabContext);
if (!context) {
throw new Error("TabPanel must be used within a Tabs component");
}
const { activeTab } = context;
const isActive = activeTab === id;
if (!isActive) {
return null;
}
return (
<div className="tab-panel" role="tabpanel">
{children}
</div>
);
};
// Tabsコンポーネント(親)
export const Tabs: FC<TabsProps> & {
TabList: FC<TabListProps>;
Tab: FC<TabProps>;
TabPanel: FC<TabPanelProps>;
} = ({ children, defaultTab = null, onChange }) => {
const [activeTab, setActiveTab] = useState<TabId | null>(defaultTab);
const handleTabChange = (tabId: TabId): void => {
setActiveTab(tabId);
onChange?.(tabId);
};
return (
<TabContext.Provider value={{ activeTab, setActiveTab: handleTabChange }}>
<div className="tabs-container" role="tablist">
{children}
</div>
</TabContext.Provider>
);
};
// コンポーネントの関連付け
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanel = TabPanel;
// 使用例
export const TabsExample: FC = () => {
const handleTabChange = (tabId: TabId) => {
console.log('Active tab:', tabId);
};
return (
<Tabs defaultTab="tab1" onChange={handleTabChange}>
<Tabs.TabList>
<Tabs.Tab id="tab1">タブ1</Tabs.Tab>
<Tabs.Tab id="tab2">タブ2</Tabs.Tab>
<Tabs.Tab id="tab3">タブ3</Tabs.Tab>
<Tabs.Tab id="tab4" disabled>無効なタブ</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanel id="tab1">
<h2>タブ1の内容</h2>
<p>これはタブ1のコンテンツです。</p>
</Tabs.TabPanel>
<Tabs.TabPanel id="tab2">
<h2>タブ2の内容</h2>
<p>これはタブ2のコンテンツです。</p>
</Tabs.TabPanel>
<Tabs.TabPanel id="tab3">
<h2>タブ3の内容</h2>
<p>これはタブ3のコンテンツです。</p>
</Tabs.TabPanel>
<Tabs.TabPanel id="tab4">
<h2>タブ4の内容</h2>
<p>このコンテンツは表示されません(タブが無効のため)。</p>
</Tabs.TabPanel>
</Tabs>
);
};
条件付きレンダリングパターン
概要
条件付きレンダリングは、特定の条件に基づいてUIの一部を表示または非表示にする手法です。React公式ドキュメントの条件付きレンダリングセクションで詳しく説明されているように、TypeScriptでは、条件によって異なる型のコンポーネントを返す場合に型の安全性を確保できます。
React 18では、サスペンスとフォールバックを使用した新しい条件付きレンダリングのアプローチも導入されています。
主なアプローチ
- if文を使用した条件付きレンダリング
- 三項演算子を使用した条件付きレンダリング
- 論理AND演算子(&&)を使用した条件付きレンダリング
- switch/case文を使用した条件付きレンダリング
- Suspenseとフォールバックを使用した条件付きレンダリング(React 18以降)
実践例:さまざまな条件付きレンダリング手法
import { useState, FC, Suspense, lazy, ChangeEvent } from 'react';
// ユーザーロールの型
type UserRole = 'user' | 'moderator' | 'admin';
// データの状態を表す型
type DataState<T> = {
status: 'idle' | 'loading' | 'success' | 'error';
data: T | null;
error: string | null;
};
// プロップスの型
type ConditionalRenderingExampleProps = {
// 必要に応じてプロップスを定義
};
// 遅延ロードするコンポーネント
const LazyComponent = lazy(() =>
import('./LazyComponent').then(module => {
// 読み込みを遅延させるため
return new Promise(resolve => {
setTimeout(() => {
resolve(module);
}, 2000);
});
})
);
export const ConditionalRenderingExample: FC<ConditionalRenderingExampleProps> = () => {
// 状態の型を明示的に定義
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [userRole, setUserRole] = useState<UserRole>('user');
const [dataState, setDataState] = useState<DataState<string[]>>({
status: 'idle',
data: null,
error: null
});
// ユーザーロールの変更ハンドラー
const handleRoleChange = (e: ChangeEvent<HTMLSelectElement>): void => {
setUserRole(e.target.value as UserRole);
};
// ログイン状態の切り替え
const toggleLogin = (): void => {
setIsLoggedIn(!isLoggedIn);
};
// データ取得のシミュレーション
const fetchData = (): void => {
setDataState({
status: 'loading',
data: null,
error: null
});
setTimeout(() => {
// 50%の確率でエラーをシミュレート
if (Math.random() > 0.5) {
setDataState({
status: 'success',
data: ['アイテム1', 'アイテム2', 'アイテム3'],
error: null
});
} else {
setDataState({
status: 'error',
data: null,
error: 'データの取得中にエラーが発生しました'
});
}
}, 1500);
};
// 1. if文を使用した条件付きレンダリング(コンポーネント全体を条件に応じて変更)
if (dataState.status === 'loading') {
return <div className="loading">データを読み込み中...</div>;
}
if (dataState.status === 'error') {
return (
<div className="error">
<p>{dataState.error}</p>
<button onClick={fetchData}>再試行</button>
</div>
);
}
// ロールパネルを生成する関数(switch/caseと同様のロジック)
const renderRolePanel = (): JSX.Element => {
switch (userRole) {
case 'admin':
return <div className="role-panel admin">管理者パネル</div>;
case 'moderator':
return <div className="role-panel moderator">モデレーターパネル</div>;
default:
return <div className="role-panel user">ユーザーパネル</div>;
}
};
return (
<div className="conditional-rendering-example">
<h2>条件付きレンダリングの例</h2>
<div className="controls">
<button onClick={toggleLogin}>
{isLoggedIn ? 'ログアウト' : 'ログイン'}
</button>
<select value={userRole} onChange={handleRoleChange}>
<option value="user">一般ユーザー</option>
<option value="moderator">モデレーター</option>
<option value="admin">管理者</option>
</select>
<button onClick={fetchData}>データを取得</button>
</div>
<div className="auth-status">
{/* 2. 三項演算子を使用した条件付きレンダリング */}
<p>
ステータス: {isLoggedIn ? 'ログイン中' : 'ログアウト中'}
</p>
</div>
{/* 3. 論理AND演算子(&&)を使用した条件付きレンダリング */}
{isLoggedIn && (
<div className="user-info">
<p>ようこそ!あなたの権限は「{userRole}」です</p>
{/* ネストされた条件付きレンダリング */}
{userRole === 'admin' && (
<button className="admin-button">管理者設定</button>
)}
</div>
)}
{/* 4. switch/caseと同様の複数条件のレンダリング(関数を使用) */}
{renderRolePanel()}
{/* リストのレンダリングと条件付きメッセージの組み合わせ */}
<div className="items-list">
<h3>アイテム一覧</h3>
{/* 条件付きレンダリングの別アプローチ - 三項演算子 */}
{dataState.data && dataState.data.length > 0 ? (
<ul>
{dataState.data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
) : (
<p>
アイテムがありません。
{dataState.status === 'idle' && '「データを取得」ボタンをクリックしてください。'}
</p>
)}
</div>
{/* 5. React 18のSuspenseを使った条件付きレンダリング */}
<h3>Suspenseの例</h3>
<Suspense fallback={<div>ローディング中...</div>}>
<LazyComponent />
</Suspense>
</div>
);
};
Render Props パターン
概要
Render Propsパターンは、コンポーネント間でロジックを再利用するための手法です。React公式ドキュメントのレンダープロップスセクションで説明されているように、具体的にはレンダリング内容を決める関数をpropsとして渡すことで実現します。TypeScriptを使うと、このパターンでも型安全性を確保できます。
React Hooksが導入されて以降、このパターンの人気は低下していますが、特定のユースケースでは依然として有用です。
主な用途
- コード重複の削減
- コンポーネント間でのロジック共有
- UIとデータ処理の分離
基本構造
import { Component, ReactNode } from 'react';
// データの型
type Data = {
id: string;
name: string;
// その他のプロパティ
};
// 状態の型
type DataFetcherState = {
loading: boolean;
data: Data[] | null;
error: Error | null;
};
// render propの引数の型
type RenderProps = {
loading: boolean;
data: Data[] | null;
error: Error | null;
};
// コンポーネントのプロップスの型
type DataFetcherProps = {
url: string;
render: (props: RenderProps) => ReactNode;
};
export class DataFetcher extends Component<DataFetcherProps, DataFetcherState> {
state: DataFetcherState = {
loading: true,
data: null,
error: null
};
componentDidMount() {
this.fetchData();
}
componentDidUpdate(prevProps: DataFetcherProps) {
// URLが変更された場合に再フェッチ
if (prevProps.url !== this.props.url) {
this.fetchData();
}
}
fetchData = async (): Promise<void> => {
this.setState({ loading: true });
try {
const response = await fetch(this.props.url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.setState({
loading: false,
data,
error: null
});
} catch (error) {
this.setState({
loading: false,
data: null,
error: error instanceof Error ? error : new Error('Unknown error occurred')
});
}
};
render() {
// render propに状態を渡す
return this.props.render({
loading: this.state.loading,
data: this.state.data,
error: this.state.error
});
}
}
// 使用例
export const App = () => (
<DataFetcher
url="https://api.example.com/data"
render={({ loading, data, error }) => {
if (loading) return <div>ローディング中...</div>;
if (error) return <div>エラーが発生しました: {error.message}</div>;
if (!data) return <div>データがありません</div>;
return (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}}
/>
);
現代的なアプローチ:カスタムフックを使った実装
Ryan Florenceによるカスタムフックのベストプラクティスで説明されているように、React 18以降では、同様の機能をカスタムフックで実装する方法が一般的です:
// useDataFetcher.ts
import { useState, useEffect } from 'react';
type Data = {
id: string;
name: string;
};
type DataState = {
loading: boolean;
data: Data[] | null;
error: Error | null;
};
export const useDataFetcher = (url: string): DataState => {
const [state, setState] = useState<DataState>({
loading: true,
data: null,
error: null
});
useEffect(() => {
let isMounted = true;
const fetchData = async (): Promise<void> => {
setState({ loading: true, data: null, error: null });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (isMounted) {
setState({
loading: false,
data,
error: null
});
}
} catch (error) {
if (isMounted) {
setState({
loading: false,
data: null,
error: error instanceof Error ? error : new Error('Unknown error occurred')
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return state;
};
// 使用例
import { useDataFetcher } from './useDataFetcher';
export const DataList = () => {
const { loading, data, error } = useDataFetcher('https://api.example.com/data');
if (loading) return <div>ローディング中...</div>;
if (error) return <div>エラーが発生しました: {error.message}</div>;
if (!data) return <div>データがありません</div>;
return (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
};
実践例:マウスポジショントラッカー
import { Component, FC, ReactNode, useState, useEffect, MouseEvent } from 'react';
// マウス座標の型
type MousePosition = {
x: number;
y: number;
};
// MouseTrackerのプロップスの型
type MouseTrackerProps = {
children: (position: MousePosition) => ReactNode;
};
// MouseTrackerの状態の型
type MouseTrackerState = MousePosition;
// クラスコンポーネントでの実装
export class MouseTracker extends Component<MouseTrackerProps, MouseTrackerState> {
state: MouseTrackerState = { x: 0, y: 0 };
handleMouseMove = (event: MouseEvent): void => {
this.setState({
x: event.clientX,
y: event.clientY
});
};
render() {
return (
<div
style={{
height: '100vh',
width: '100%',
position: 'relative'
}}
onMouseMove={this.handleMouseMove}
>
{/* 子要素として関数を呼び出し、現在の状態を渡す */}
{this.props.children(this.state)}
</div>
);
}
}
// Hooksを使用した同等の実装
type HookMouseTrackerProps = {
children: (position: MousePosition) => ReactNode;
};
export const HookMouseTracker: FC<HookMouseTrackerProps> = ({ children }) => {
const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
// マウス移動イベントのハンドラー
const handleMouseMove = (event: MouseEvent): void => {
setPosition({
x: event.clientX,
y: event.clientY
});
};
return (
<div
style={{ height: '100vh', width: '100%', position: 'relative' }}
onMouseMove={handleMouseMove}
>
{children(position)}
</div>
);
};
// グローバルなマウス位置を追跡するカスタムフック
export const useMousePosition = (): MousePosition => {
const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event: MouseEvent): void => {
setPosition({
x: event.clientX,
y: event.clientY
});
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return position;
};
// 使用例1:カスタムカーソル(クラスコンポーネント版)
export const CustomCursor: FC = () => (
<MouseTracker>
{({ x, y }) => (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: 'red',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none'
}}
/>
)}
</MouseTracker>
);
// 使用例2:カスタムカーソル(フック版)
export const HookCustomCursor: FC = () => {
const { x, y } = useMousePosition();
return (
<div
style={{
position: 'fixed',
left: x,
top: y,
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: 'blue',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 9999
}}
/>
);
};
State Reducer パターン
概要
State Reducerパターンは、コンポーネントの状態を直接変更する代わりに、リデューサー関数を通じて状態変更を管理する方法です。このパターンはKent C. Doddsのブログ記事で詳しく説明されています。TypeScriptと組み合わせることで、状態とアクションの型を厳密に定義できます。
React 18では、useReducerフックを使用したこのパターンがより一般的になっています。
主な用途
- 複雑な状態ロジックの管理
- 高度なコンポーネントのカスタマイズ
- 既存のコンポーネントの再利用と拡張
基本構造
import { useReducer, FC } from 'react';
// カウンターの状態の型
type CounterState = {
count: number;
};
// アクションの型
type CounterAction =
| { type: 'increment'; payload?: number }
| { type: 'decrement'; payload?: number }
| { type: 'reset'; payload?: number };
// 初期状態
const initialState: CounterState = {
count: 0
};
// リデューサー関数
function reducer(state: CounterState, action: CounterAction): CounterState {
const step = action.payload ?? 1; // ペイロードがない場合はデフォルト値を使用
switch (action.type) {
case 'increment':
return { count: state.count + step };
case 'decrement':
return { count: state.count - step };
case 'reset':
return { count: action.payload ?? 0 };
default:
// TypeScriptのExhaustive checkで未処理のアクションがないことを保証
const _exhaustiveCheck: never = action;
return state;
}
}
// カウンターコンポーネント
export const Counter: FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'increment', payload: 5 })}>+5</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
</div>
);
};
実践例:カスタマイズ可能なカウンター
import { useReducer, FC } from 'react';
// カウンターの状態の型
type CounterState = {
count: number;
};
// アクションの型
type CounterAction =
| { type: 'increment'; step?: number }
| { type: 'decrement'; step?: number }
| { type: 'reset'; initialCount?: number };
// デフォルトのリデューサー
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + (action.step ?? 1) };
case 'decrement':
return { count: state.count - (action.step ?? 1) };
case 'reset':
return { count: action.initialCount ?? 0 };
default:
// TypeScriptのExhaustive checkで未処理のアクションがないことを保証
const _exhaustiveCheck: never = action;
return state;
}
}
// カスタムリデューサーの型
type StateReducer<S, A> = (state: S, action: A, defaultChanges: S) => S;
// カスタマイズ可能なカウンターのプロップスの型
type CustomizableCounterProps = {
initialCount?: number;
step?: number;
min?: number;
max?: number;
stateReducer?: StateReducer<CounterState, CounterAction>;
label?: string;
};
// カスタマイズ可能なカウンターコンポーネント
export const CustomizableCounter: FC<CustomizableCounterProps> = ({
initialCount = 0,
step = 1,
min = Number.MIN_SAFE_INTEGER,
max = Number.MAX_SAFE_INTEGER,
stateReducer = (_, __, changes) => changes,
label = 'カウンター'
}) => {
// カスタムリデューサーを適用するためのラッパー関数
const customReducer = (state: CounterState, action: CounterAction): CounterState => {
// デフォルトの処理を適用
const defaultChanges = counterReducer(state, action);
// カスタムステートリデューサーを呼び出し
return stateReducer(state, action, defaultChanges);
};
const [state, dispatch] = useReducer(
customReducer,
{ count: initialCount }
);
// インクリメント処理
const increment = (): void => {
if (state.count < max) {
dispatch({ type: 'increment', step });
}
};
// デクリメント処理
const decrement = (): void => {
if (state.count > min) {
dispatch({ type: 'decrement', step });
}
};
// リセット処理
const reset = (): void => {
dispatch({ type: 'reset', initialCount });
};
return (
<div className="counter">
<h2>{label}</h2>
<div className="counter-display">
<span>現在の値: {state.count}</span>
</div>
<div className="counter-controls">
<button onClick={decrement} disabled={state.count <= min}>-{step}</button>
<button onClick={reset}>リセット</button>
<button onClick={increment} disabled={state.count >= max}>+{step}</button>
</div>
</div>
);
};
// 使用例1:基本的な使用方法
export const BasicCounter: FC = () => <CustomizableCounter label="基本カウンター" />;
// 使用例2:ステップサイズとリミットの設定
export const StepCounter: FC = () => (
<CustomizableCounter
initialCount={10}
step={5}
min={0}
max={50}
label="ステップカウンター (5ずつ増減、0-50の範囲)"
/>
);
// 使用例3:カスタムステートリデューサーを使用
export const CustomReducerCounter: FC = () => {
// カスタムリデューサーロジック
const myStateReducer: StateReducer<CounterState, CounterAction> = (state, action, changes) => {
// 偶数値のみを許可
if (action.type === 'increment' || action.type === 'decrement') {
const newCount = changes.count;
// 奇数の場合は次の偶数に調整
if (newCount % 2 !== 0) {
return { count: action.type === 'increment' ? newCount + 1 : newCount - 1 };
}
}
return changes;
};
return (
<CustomizableCounter
stateReducer={myStateReducer}
step={1}
label="偶数のみのカウンター"
/>
);
};
Controlled Components パターン
概要
Controlled Componentsパターンは、フォーム要素の値をReactの状態で管理する手法です。React公式ドキュメントのフォームセクションで説明されているように、TypeScriptと組み合わせることで、フォーム値の型を厳密に管理できます。
React 18以降でも、このパターンは重要であり、特にフォーム処理において広く使用されています。
主な用途
- フォーム入力の管理
- 入力値の検証
- 複数の入力フィールドの同期
基本構造
import { useState, FC, ChangeEvent, FormEvent } from 'react';
// フォーム値の型
type FormValues = {
inputValue: string;
};
export const ControlledForm: FC = () => {
const [formValues, setFormValues] = useState<FormValues>({
inputValue: ''
});
const [submitted, setSubmitted] = useState<boolean>(false);
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
const { name, value } = event.target;
setFormValues(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
console.log('Submitted value:', formValues.inputValue);
setSubmitted(true);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="inputValue">入力値:</label>
<input
type="text"
id="inputValue"
name="inputValue"
value={formValues.inputValue}
onChange={handleChange}
/>
</div>
<button type="submit">送信</button>
{submitted && (
<div className="success-message">
フォームが送信されました: {formValues.inputValue}
</div>
)}
</form>
);
};
実践例:複数フィールドを持つフォーム
import { useState, FC, ChangeEvent, FormEvent, useEffect } from 'react';
// フォームの値の型
type FormData = {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
agreeTerms: boolean;
};
// フィールドの状態を追跡するための型
type FieldState = {
touched: boolean;
valid: boolean;
error: string | null;
};
// すべてのフィールドの状態を保持する型
type FormFieldStates = {
[K in keyof FormData]: FieldState;
};
// フォームの検証ルール
type ValidationRules = {
[K in keyof FormData]?: {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
customValidate?: (value: FormData[K], formData: FormData) => string | null;
};
};
export const ControlledFormExample: FC = () => {
// フォームの状態を管理
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false
});
// フィールドの状態を管理
const [fieldStates, setFieldStates] = useState<FormFieldStates>({
firstName: { touched: false, valid: false, error: null },
lastName: { touched: false, valid: false, error: null },
email: { touched: false, valid: false, error: null },
password: { touched: false, valid: false, error: null },
confirmPassword: { touched: false, valid: false, error: null },
agreeTerms: { touched: false, valid: false, error: null }
});
// フォーム全体の有効性
const [formValid, setFormValid] = useState<boolean>(false);
// 送信成功メッセージ
const [submitted, setSubmitted] = useState<boolean>(false);
// 検証ルール
const validationRules: ValidationRules = {
firstName: {
required: true,
minLength: 2
},
lastName: {
required: true,
minLength: 2
},
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
password: {
required: true,
minLength: 8,
pattern: /^(?=.*[A-Z])(?=.*\d).+$/
},
confirmPassword: {
required: true,
customValidate: (value, formData) =>
value !== formData.password ? 'パスワードが一致しません' : null
},
agreeTerms: {
customValidate: (value) =>
value ? null : '利用規約に同意する必要があります'
}
};
// フィールドを検証する関数
const validateField = (name: keyof FormData, value: any): FieldState => {
const rules = validationRules[name];
if (!rules) {
return { touched: true, valid: true, error: null };
}
// 必須チェック
if (rules.required && (!value || (typeof value === 'string' && !value.trim()))) {
return {
touched: true,
valid: false,
error: `${name}は必須です`
};
}
// 最小長チェック
if (rules.minLength && typeof value === 'string' && value.length < rules.minLength) {
return {
touched: true,
valid: false,
error: `${name}は${rules.minLength}文字以上である必要があります`
};
}
// 最大長チェック
if (rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) {
return {
touched: true,
valid: false,
error: `${name}は${rules.maxLength}文字以下である必要があります`
};
}
// パターンチェック
if (rules.pattern && typeof value === 'string' && !rules.pattern.test(value)) {
return {
touched: true,
valid: false,
error: `${name}の形式が正しくありません`
};
}
// カスタム検証
if (rules.customValidate) {
const error = rules.customValidate(value, formData);
if (error) {
return { touched: true, valid: false, error };
}
}
return { touched: true, valid: true, error: null };
};
// フォーム全体の検証
const validateForm = (): boolean => {
const newFieldStates = { ...fieldStates };
let isValid = true;
// すべてのフィールドを検証
(Object.keys(formData) as Array<keyof FormData>).forEach(field => {
const value = formData[field];
const fieldState = validateField(field, value);
newFieldStates[field] = fieldState;
if (!fieldState.valid) {
isValid = false;
}
});
setFieldStates(newFieldStates);
return isValid;
};
// 入力変更ハンドラー
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
const { name, value, type, checked } = e.target;
// チェックボックスとその他の入力で異なる処理
const newValue = type === 'checkbox' ? checked : value;
// フォームデータの更新
setFormData(prevData => ({
...prevData,
[name]: newValue
}));
// フィールドの検証
const fieldName = name as keyof FormData;
const fieldState = validateField(fieldName, newValue);
// フィールド状態の更新
setFieldStates(prev => ({
...prev,
[fieldName]: fieldState
}));
};
// フォームの有効性を監視
useEffect(() => {
// すべてのフィールドが有効かチェック
const isValid = Object.values(fieldStates).every(field => field.valid);
setFormValid(isValid);
}, [fieldStates]);
// フォーム送信ハンドラー
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
// フォーム全体の検証
if (validateForm()) {
console.log('フォームデータ:', formData);
// ここでAPIリクエストなどを行う
// 送信成功メッセージを表示
setSubmitted(true);
// フォームをリセット
setFormData({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false
});
// フィールド状態をリセット
setFieldStates({
firstName: { touched: false, valid: false, error: null },
lastName: { touched: false, valid: false, error: null },
email: { touched: false, valid: false, error: null },
password: { touched: false, valid: false, error: null },
confirmPassword: { touched: false, valid: false, error: null },
agreeTerms: { touched: false, valid: false, error: null }
});
// 3秒後に成功メッセージを消す
setTimeout(() => {
setSubmitted(false);
}, 3000);
}
};
return (
<div className="controlled-form-example">
<h2>会員登録フォーム</h2>
{submitted && (
<div className="success-message">
登録が完了しました!確認メールをお送りしました。
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="form-group">
<label htmlFor="firstName">名</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className={fieldStates.firstName.touched && !fieldStates.firstName.valid ? 'error' : ''}
/>
{fieldStates.firstName.touched && fieldStates.firstName.error && (
<div className="error-message">{fieldStates.firstName.error}</div>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">姓</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className={fieldStates.lastName.touched && !fieldStates.lastName.valid ? 'error' : ''}
/>
{fieldStates.lastName.touched && fieldStates.lastName.error && (
<div className="error-message">{fieldStates.lastName.error}</div>
)}
</div>
</div>
<div className="form-group">
<label htmlFor="email">メールアドレス</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className={fieldStates.email.touched && !fieldStates.email.valid ? 'error' : ''}
/>
{fieldStates.email.touched && fieldStates.email.error && (
<div className="error-message">{fieldStates.email.error}</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">パスワード</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className={fieldStates.password.touched && !fieldStates.password.valid ? 'error' : ''}
/>
{fieldStates.password.touched && fieldStates.password.error && (
<div className="error-message">{fieldStates.password.error}</div>
)}
<small>パスワードは8文字以上で、少なくとも1つの大文字と数字を含む必要があります。</small>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">パスワード(確認)</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
className={fieldStates.confirmPassword.touched && !fieldStates.confirmPassword.valid ? 'error' : ''}
/>
{fieldStates.confirmPassword.touched && fieldStates.confirmPassword.error && (
<div className="error-message">{fieldStates.confirmPassword.error}</div>
)}
</div>
<div className="form-group checkbox">
<input
type="checkbox"
id="agreeTerms"
name="agreeTerms"
checked={formData.agreeTerms}
onChange={handleChange}
/>
<label htmlFor="agreeTerms">利用規約に同意します</label>
{fieldStates.agreeTerms.touched && fieldStates.agreeTerms.error && (
<div className="error-message">{fieldStates.agreeTerms.error}</div>
)}
</div>
<button type="submit" className="submit-button" disabled={!formValid}>
登録
</button>
</form>
</div>
);
};
Extensible Styles パターン
概要
Extensible Stylesパターンは、スタイルを一貫して適用しながらも柔軟にカスタマイズできるようにするアプローチです。CSS-in-JSライブラリのスタイル方法ガイドを参考に、TypeScriptを使うと、スタイルオブジェクトの型も厳密に定義できます。
React 18以降では、TailwindCSSやCSS-in-JSなどのソリューションとの組み合わせが一般的になっています。
主な用途
- スタイリングの統一性と一貫性の確保
- コンポーネントのスタイルをカスタマイズ可能にする
- テーマの適用
実践例:スタイル拡張可能なボタンコンポーネント
import { FC, CSSProperties, MouseEvent, ReactNode } from 'react';
// ボタンのバリアント
type ButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'outline';
// ボタンのサイズ
type ButtonSize = 'small' | 'medium' | 'large';
// スタイルオブジェクトの型
type StyleObject = {
[key: string]: string | number | StyleObject;
};
// ホバー状態のスタイルを含むスタイルオブジェクト
type StyleWithHover = StyleObject & {
'&:hover'?: StyleObject;
};
// ボタンのプロップス
type ButtonProps = {
children: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
style?: StyleWithHover;
className?: string;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
ariaLabel?: string;
};
// ベーススタイル
const baseStyles: StyleWithHover = {
padding: '10px 15px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
border: 'none',
transition: 'background-color 0.3s, transform 0.1s',
outline: 'none',
};
// バリアントスタイル
const variantStyles: Record<ButtonVariant, StyleWithHover> = {
primary: {
backgroundColor: '#3498db',
color: 'white',
'&:hover': {
backgroundColor: '#2980b9'
}
},
secondary: {
backgroundColor: '#95a5a6',
color: 'white',
'&:hover': {
backgroundColor: '#7f8c8d'
}
},
success: {
backgroundColor: '#2ecc71',
color: 'white',
'&:hover': {
backgroundColor: '#27ae60'
}
},
danger: {
backgroundColor: '#e74c3c',
color: 'white',
'&:hover': {
backgroundColor: '#c0392b'
}
},
outline: {
backgroundColor: 'transparent',
color: '#3498db',
border: '1px solid #3498db',
'&:hover': {
backgroundColor: 'rgba(52, 152, 219, 0.1)'
}
}
};
// サイズスタイル
const sizeStyles: Record<ButtonSize, StyleObject> = {
small: {
padding: '6px 12px',
fontSize: '12px'
},
medium: {
padding: '10px 15px',
fontSize: '14px'
},
large: {
padding: '12px 20px',
fontSize: '16px'
}
};
const mergeStyles = (...styles: (StyleWithHover | undefined)[]): StyleWithHover => {
return styles.reduce((merged, style) => {
if (!style) return merged;
// オブジェクトをディープコピー
const result = { ...merged };
// 各スタイルプロパティをマージ
Object.keys(style).forEach(key => {
if (key === '&:hover' && merged[key]) {
result[key] = {
...merged[key] as StyleObject,
...style[key] as StyleObject
};
} else if (
typeof style[key] === 'object' &&
merged[key] &&
typeof merged[key] === 'object'
) {
result[key] = {
...merged[key] as StyleObject,
...style[key] as StyleObject
};
} else {
result[key] = style[key];
}
});
return result;
}, {});
};
// 拡張可能なボタンコンポーネント
export const Button: FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
style,
className,
onClick,
disabled = false,
type = 'button',
ariaLabel,
...props
}) => {
// スタイルの計算
const computedStyle = mergeStyles(
baseStyles,
variantStyles[variant],
sizeStyles[size],
style
);
// ホバー状態の処理
const hoverStyle = computedStyle['&:hover'];
// CSSのクラス名を使用する場合
const classNames = [
'extensible-button',
`extensible-button--${variant}`,
`extensible-button--${size}`,
className
].filter(Boolean).join(' ');
// CSSPropertiesに変換するためにホバー関連のプロパティを削除
const cleanStyle = { ...computedStyle };
delete cleanStyle['&:hover'];
// マウスオーバーハンドラー
const handleMouseOver = (e: MouseEvent<HTMLButtonElement>): void => {
if (hoverStyle && !disabled) {
Object.assign(
e.currentTarget.style,
hoverStyle as CSSProperties
);
}
};
// マウスアウトハンドラー
const handleMouseOut = (e: MouseEvent<HTMLButtonElement>): void => {
if (hoverStyle && !disabled) {
// ホバー時のスタイルを元に戻す
Object.keys(hoverStyle).forEach(key => {
e.currentTarget.style[key as any] =
(cleanStyle as any)[key] !== undefined
? (cleanStyle as any)[key]
: '';
});
}
};
return (
<button
className={classNames}
style={cleanStyle as CSSProperties}
onClick={onClick}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
disabled={disabled}
type={type}
aria-label={ariaLabel}
{...props}
>
{children}
</button>
);
};
// 使用例
export const ButtonExample: FC = () => {
return (
<div>
<h2>スタイル拡張可能なボタン</h2>
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
<Button variant="primary">プライマリ</Button>
<Button variant="secondary">セカンダリ</Button>
<Button variant="success">成功</Button>
<Button variant="danger">危険</Button>
<Button variant="outline">アウトライン</Button>
</div>
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
<Button size="small">小</Button>
<Button size="medium">中</Button>
<Button size="large">大</Button>
</div>
<div>
<Button
variant="primary"
style={{
borderRadius: '50px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
backgroundColor: 'purple',
'&:hover': {
backgroundColor: 'darkpurple'
}
}}
>
カスタムスタイル
</Button>
</div>
</div>
);
};
State Initializer パターン
概要
State Initializerパターンは、コンポーネントの初期状態を外部から制御可能にするパターンです。このパターンはKent C. Doddsの記事で紹介されています。TypeScriptでは初期値の型を厳密に指定できます。
React公式ドキュメントによれば、「コンポーネントの状態をリセット可能にすることで、ユーザーエクスペリエンスを向上させることができます」。
主な用途
- 初期状態のカスタマイズ
- リセット機能の実装
- テスト環境での状態の制御
実践例:リセット可能なフォーム
import { useState, useEffect, FC, FormEvent, ChangeEvent } from 'react';
// フォームの値の型
type FormValues = {
name: string;
email: string;
message: string;
};
// リセット可能なフォームのプロップスの型
type ResetableFormProps = {
initialValues?: FormValues;
onSubmit: (values: FormValues) => void;
submitButtonText?: string;
resetButtonText?: string;
};
// State Initializerパターンを使用したフォームコンポーネント
export const ResetableForm: FC<ResetableFormProps> = ({
initialValues = { name: '', email: '', message: '' },
onSubmit,
submitButtonText = '送信',
resetButtonText = 'リセット'
}) => {
const [values, setValues] = useState<FormValues>(initialValues);
const [dirty, setDirty] = useState<boolean>(false);
const [touched, setTouched] = useState<Record<keyof FormValues, boolean>>({
name: false,
email: false,
message: false
});
// initialValuesが変更された場合に状態を更新
useEffect(() => {
setValues(initialValues);
setDirty(false);
setTouched({
name: false,
email: false,
message: false
});
}, [initialValues]);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const { name, value } = e.target;
setValues(prevValues => ({
...prevValues,
[name]: value
}));
// フィールドを触れた状態にする
setTouched(prev => ({
...prev,
[name]: true
}));
setDirty(true);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
// すべてのフィールドを触れた状態にする
setTouched({
name: true,
email: true,
message: true
});
// 簡易バリデーション
if (values.name.trim() && values.email.trim() && values.message.trim()) {
onSubmit(values);
}
};
const handleReset = (): void => {
setValues(initialValues);
setDirty(false);
setTouched({
name: false,
email: false,
message: false
});
};
// フィールドのエラーチェック
const getFieldError = (name: keyof FormValues): string | null => {
if (!touched[name]) return null;
const value = values[name];
if (!value.trim()) {
return `${name === 'name' ? 'お名前' : name === 'email' ? 'メールアドレス' : 'メッセージ'}は必須です`;
}
if (name === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return '有効なメールアドレスを入力してください';
}
return null;
};
return (
<form onSubmit={handleSubmit} className="resetable-form">
<div className="form-group">
<label htmlFor="name">お名前</label>
<input
type="text"
id="name"
name="name"
value={values.name}
onChange={handleChange}
className={getFieldError('name') ? 'error' : ''}
/>
{getFieldError('name') && <div className="error-message">{getFieldError('name')}</div>}
</div>
<div className="form-group">
<label htmlFor="email">メールアドレス</label>
<input
type="email"
id="email"
name="email"
value={values.email}
onChange={handleChange}
className={getFieldError('email') ? 'error' : ''}
/>
{getFieldError('email') && <div className="error-message">{getFieldError('email')}</div>}
</div>
<div className="form-group">
<label htmlFor="message">メッセージ</label>
<textarea
id="message"
name="message"
value={values.message}
onChange={handleChange}
rows={4}
className={getFieldError('message') ? 'error' : ''}
/>
{getFieldError('message') && <div className="error-message">{getFieldError('message')}</div>}
</div>
<div className="form-buttons">
<button type="submit">{submitButtonText}</button>
{dirty && (
<button type="button" onClick={handleReset}>
{resetButtonText}
</button>
)}
</div>
</form>
);
};
// 使用例コンポーネント
export const StateInitializerExample: FC = () => {
const [submittedData, setSubmittedData] = useState<FormValues | null>(null);
const [formInitialValues, setFormInitialValues] = useState<FormValues>({
name: '',
email: '',
message: ''
});
const handleSubmit = (data: FormValues): void => {
console.log('フォームが送信されました:', data);
setSubmittedData(data);
};
const handleLoadSampleData = (): void => {
setFormInitialValues({
name: 'サンプル太郎',
email: 'sample@example.com',
message: 'これはサンプルメッセージです。'
});
};
const handleClearSampleData = (): void => {
setFormInitialValues({
name: '',
email: '',
message: ''
});
};
return (
<div className="state-initializer-example">
<h2>リセット可能なフォーム</h2>
<div className="sample-data-controls">
<button onClick={handleLoadSampleData}>サンプルデータを読み込む</button>
<button onClick={handleClearSampleData}>クリア</button>
</div>
<ResetableForm
initialValues={formInitialValues}
onSubmit={handleSubmit}
/>
{submittedData && (
<div className="submitted-data">
<h3>送信されたデータ:</h3>
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
</div>
)}
</div>
);
};
パターン選択の指針
適切なReactデザインパターンを選択するためのガイドラインを以下に示します:
-
再利用性と分離が重要な場合:
- カスタムフック:現代のReactアプリケーションでは、関数コンポーネントにロジックを追加する最も効率的な方法
- Render Propsパターン:より柔軟なコンポジションが必要な場合
-
状態管理:
- Provider パターン:アプリケーション全体で状態を共有する場合(React ContextAPIと併用)
- State Reducerパターン:複雑な状態遷移ロジックがある場合(useReducerと併用)
- React Hooks:関数コンポーネントで状態とライフサイクルを管理する場合
-
コンポーネント設計:
- Presentational と Container パターン:UIとロジックを分離したい場合(カスタムフックを使って実装)
- Compound Component パターン:密接に関連したコンポーネントグループを作成する場合
-
ユーザー入力:
- Controlled Components パターン:フォーム入力とその検証を処理する場合
- State Initializer パターン:初期値や状態のリセットが必要な場合
-
UIの変更:
- 条件付きレンダリングパターン:条件に基づいてUIを表示/非表示にする場合
- Extensible Styles パターン:スタイルのカスタマイズが必要な場合
-
パフォーマンス最適化:
- メモ化パターン:useMemo, useCallbackを使って不要な再計算や再レンダリングを防ぐ
- コード分割とLazy Loading:大きなコンポーネントの読み込みを遅延させる
まとめ
Reactデザインパターンはアプリケーション開発において非常に重要な役割を果たします。TypeScriptと組み合わせることで、型安全性を確保しながら、コードの再利用性、保守性、拡張性を大幅に向上させることができます。
「雰囲気でReactを書いていた」一人でしたが、これらのパターンを学び、実践することで、より堅牢で保守性の高いコードを書けるようになりました。特にTypeScriptとの組み合わせは、大規模なアプリケーション開発において、多くのバグを事前に発見し、開発効率を向上させるのに役立ちます。
React 18以降では、Concurrent Modeやサーバーコンポーネントなどのパワフルな新機能が導入され、よりリッチなユーザー体験を提供できるようになりました。これらの新機能を最大限に活用するために、適切なデザインパターンの選択がこれまで以上に重要となっています。
この記事で紹介した12の主要なReactデザインパターンは、状況やニーズに応じて選択・組み合わせることが重要です。最も効果的なパターンは、あなたの具体的な問題に最もよく対応するものです。
Reactチームのソフトウェアエンジニアであるソフィア・ポルサ氏も「良いデザインパターンは問題を解決するだけでなく、コードの意図を明確に伝えます」と述べています。
TypeScriptの型システムは、単なる制約ではなく、より良いコードを書くための道具です。適切に活用することで、開発体験が向上し、より安全で保守性の高いコードベースを作ることができます。
最後に、良いコードは一朝一夕には生まれません。継続的な学習と実践が重要です。
参考資料
- React公式ドキュメント - React 18以降の最新の設計パターンやベストプラクティス
- React TypeScriptチートシート - ReactとTypeScriptの統合に関する包括的なガイド
- Kent C. Doddsのブログ - コンポーネントパターン、Render Props、Hooksに関する優れた資料
- patterns.dev - 現代的なReactパターン、特にHooksパターンについての詳細な解説
- TypeScript公式ドキュメント - TypeScriptの型システムについての詳細な説明
- React Hooks Handbook - React Hooksの完全ガイド
- Redux Toolkit - モダンなReduxの公式推奨アプローチ
- Bulletproof React - スケーラブルなReactアプリケーションのアーキテクチャとベストプラクティス
Discussion