TypeScriptの型安全なReactコンポーネント設計 - 実践ガイド
TypeScriptReact設計ベストプラクティス
TypeScriptの型システムを活用して、保守性とパフォーマンスを両立したReactコンポーネントを設計する実践的な方法を紹介します。
TypeScriptの型安全なReactコンポーネント設計 - 実践ガイド
概要
TypeScriptとReactを組み合わせることで、型安全で保守性の高いコンポーネントを作成できます。この記事では、実践的なパターンとベストプラクティスを紹介します。
基本的なPropsの型定義
1. シンプルなコンポーネント
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger'
size: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
onClick: () => void
children: React.ReactNode
}
export const Button: React.FC<ButtonProps> = ({
variant,
size,
disabled = false,
loading = false,
onClick,
children
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
onClick={onClick}
>
{loading ? '読み込み中...' : children}
</button>
)
}
2. 条件付きPropsの型定義
interface BaseCardProps {
title: string
children: React.ReactNode
}
interface ClickableCardProps extends BaseCardProps {
onClick: () => void
isClickable: true
}
interface StaticCardProps extends BaseCardProps {
isClickable?: false
}
type CardProps = ClickableCardProps | StaticCardProps
export const Card: React.FC<CardProps> = (props) => {
const { title, children, isClickable, onClick } = props
if (isClickable) {
return (
<div className="card clickable" onClick={onClick}>
<h3>{title}</h3>
{children}
</div>
)
}
return (
<div className="card">
<h3>{title}</h3>
{children}
</div>
)
}
型安全なイベントハンドリング
フォームイベント
interface FormData {
email: string
password: string
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const data: FormData = {
email: formData.get('email') as string,
password: formData.get('password') as string
}
// バリデーション
if (!data.email || !data.password) {
console.error('必須項目が入力されていません')
return
}
// API呼び出し
submitForm(data)
}
入力イベント
const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const { name, value, type, checked } = event.target
if (type === 'checkbox') {
setFormData(prev => ({
...prev,
[name]: checked
}))
} else {
setFormData(prev => ({
...prev,
[name]: value
}))
}
}
ジェネリクスを活用した再利用可能コンポーネント
汎用リストコンポーネント
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
keyExtractor: (item: T, index: number) => string | number
emptyMessage?: string
loading?: boolean
}
function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'データがありません',
loading = false
}: ListProps<T>) {
if (loading) {
return <div>読み込み中...</div>
}
if (items.length === 0) {
return <div>{emptyMessage}</div>
}
return (
<ul className="list">
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>
{renderItem(item, index)}
</li>
))}
</ul>
)
}
// 使用例
interface User {
id: number
name: string
email: string
}
const UserList: React.FC<{ users: User[] }> = ({ users }) => (
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<h4>{user.name}</h4>
<p>{user.email}</p>
</div>
)}
/>
)
汎用フォームコンポーネント
interface FieldConfig<T> {
name: keyof T
label: string
type: 'text' | 'email' | 'password' | 'number'
required?: boolean
validation?: (value: any) => string | null
}
interface FormProps<T> {
fields: FieldConfig<T>[]
onSubmit: (data: T) => void
initialData?: Partial<T>
submitLabel?: string
}
function Form<T extends Record<string, any>>({
fields,
onSubmit,
initialData = {},
submitLabel = '送信'
}: FormProps<T>) {
const [formData, setFormData] = useState<T>(initialData as T)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
// バリデーション
const newErrors: Partial<Record<keyof T, string>> = {}
fields.forEach(field => {
if (field.required && !formData[field.name]) {
newErrors[field.name] = `${field.label}は必須です`
} else if (field.validation) {
const error = field.validation(formData[field.name])
if (error) {
newErrors[field.name] = error
}
}
})
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
onSubmit(formData)
}
return (
<form onSubmit={handleSubmit}>
{fields.map(field => (
<div key={String(field.name)}>
<label>{field.label}</label>
<input
type={field.type}
name={String(field.name)}
value={formData[field.name] || ''}
onChange={(e) => setFormData(prev => ({
...prev,
[field.name]: e.target.value
}))}
/>
{errors[field.name] && (
<span className="error">{errors[field.name]}</span>
)}
</div>
))}
<button type="submit">{submitLabel}</button>
</form>
)
}
パフォーマンス最適化
React.memoとuseCallback
interface ExpensiveComponentProps {
data: string[]
onItemClick: (index: number) => void
}
const ExpensiveComponent = React.memo<ExpensiveComponentProps>(({
data,
onItemClick
}) => {
return (
<div>
{data.map((item, index) => (
<div key={index} onClick={() => onItemClick(index)}>
{item}
</div>
))}
</div>
)
})
// 親コンポーネント
const ParentComponent: React.FC = () => {
const [data, setData] = useState<string[]>([])
const handleItemClick = useCallback((index: number) => {
console.log(`アイテム ${index} がクリックされました`)
}, [])
return (
<ExpensiveComponent
data={data}
onItemClick={handleItemClick}
/>
)
}
カスタムフックの型定義
interface UseApiState<T> {
data: T | null
loading: boolean
error: string | null
}
interface UseApiActions<T> {
fetch: () => Promise<void>
reset: () => void
}
function useApi<T>(
apiCall: () => Promise<T>
): UseApiState<T> & UseApiActions<T> {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: false,
error: null
})
const fetch = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const data = await apiCall()
setState({ data, loading: false, error: null })
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : 'エラーが発生しました'
})
}
}, [apiCall])
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null })
}, [])
return { ...state, fetch, reset }
}
エラーハンドリングとバリデーション
エラーバウンダリ
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
class ErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('エラーが発生しました:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>エラーが発生しました</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
再試行
</button>
</div>
)
}
return this.props.children
}
}
バリデーション関数
type ValidationRule<T> = (value: T) => string | null
const createValidator = <T extends Record<string, any>>(
rules: Record<keyof T, ValidationRule<any>[]
) => {
return (data: T): Partial<Record<keyof T, string>> => {
const errors: Partial<Record<keyof T, string>> = {}
Object.keys(rules).forEach(key => {
const fieldRules = rules[key as keyof T]
const value = data[key as keyof T]
for (const rule of fieldRules) {
const error = rule(value)
if (error) {
errors[key as keyof T] = error
break
}
}
})
return errors
}
}
// 使用例
interface UserForm {
name: string
email: string
age: number
}
const userValidator = createValidator<UserForm>({
name: [
(value) => !value ? '名前は必須です' : null,
(value) => value.length < 2 ? '名前は2文字以上で入力してください' : null
],
email: [
(value) => !value ? 'メールアドレスは必須です' : null,
(value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '有効なメールアドレスを入力してください' : null
],
age: [
(value) => value < 0 ? '年齢は0以上で入力してください' : null,
(value) => value > 150 ? '有効な年齢を入力してください' : null
]
})
実践的な例:モーダルコンポーネント
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg'
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'md'
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="modal-overlay" onClick={onClose}>
<div
className={`modal modal-${size}`}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose}>×</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
)
}
まとめ
TypeScriptの型システムを適切に活用することで、以下のメリットが得られます:
- 開発時のエラー検出: コンパイル時に多くのバグを発見できます
- コードの可読性向上: 型定義により、コンポーネントの使用方法が明確になります
- リファクタリングの安全性: 型チェックにより、安全なコード変更が可能です
- チーム開発の効率化: 型定義により、APIの使用方法が明確になります
これらのパターンを組み合わせることで、保守性と拡張性に優れたReactアプリケーションを構築できます。