react-patterns.ts raw

   1  /**
   2   * TypeScript React Patterns
   3   *
   4   * This file demonstrates type-safe React patterns including:
   5   * - Component props typing
   6   * - Hooks with TypeScript
   7   * - Context with type safety
   8   * - Generic components
   9   * - Event handlers
  10   * - Ref types
  11   */
  12  
  13  import { createContext, useContext, useEffect, useReducer, useRef, useState } from 'react'
  14  import type { ReactNode, InputHTMLAttributes, FormEvent, ChangeEvent } from 'react'
  15  
  16  // ============================================================================
  17  // Component Props Patterns
  18  // ============================================================================
  19  
  20  // Basic component with props
  21  interface ButtonProps {
  22    variant?: 'primary' | 'secondary' | 'tertiary'
  23    size?: 'sm' | 'md' | 'lg'
  24    disabled?: boolean
  25    onClick?: () => void
  26    children: ReactNode
  27  }
  28  
  29  export function Button({
  30    variant = 'primary',
  31    size = 'md',
  32    disabled = false,
  33    onClick,
  34    children,
  35  }: ButtonProps) {
  36    return (
  37      <button
  38        className={`btn-${variant} btn-${size}`}
  39        disabled={disabled}
  40        onClick={onClick}
  41      >
  42        {children}
  43      </button>
  44    )
  45  }
  46  
  47  // Props extending HTML attributes
  48  interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  49    label?: string
  50    error?: string
  51    helperText?: string
  52  }
  53  
  54  export function Input({ label, error, helperText, ...inputProps }: InputProps) {
  55    return (
  56      <div className="input-wrapper">
  57        {label && <label>{label}</label>}
  58        <input className={error ? 'input-error' : ''} {...inputProps} />
  59        {error && <span className="error">{error}</span>}
  60        {helperText && <span className="helper">{helperText}</span>}
  61      </div>
  62    )
  63  }
  64  
  65  // Generic component
  66  interface ListProps<T> {
  67    items: T[]
  68    renderItem: (item: T, index: number) => ReactNode
  69    keyExtractor: (item: T, index: number) => string
  70    emptyMessage?: string
  71  }
  72  
  73  export function List<T>({
  74    items,
  75    renderItem,
  76    keyExtractor,
  77    emptyMessage = 'No items',
  78  }: ListProps<T>) {
  79    if (items.length === 0) {
  80      return <div>{emptyMessage}</div>
  81    }
  82  
  83    return (
  84      <ul>
  85        {items.map((item, index) => (
  86          <li key={keyExtractor(item, index)}>{renderItem(item, index)}</li>
  87        ))}
  88      </ul>
  89    )
  90  }
  91  
  92  // Component with children render prop
  93  interface ContainerProps {
  94    isLoading: boolean
  95    error: Error | null
  96    children: (props: { retry: () => void }) => ReactNode
  97  }
  98  
  99  export function Container({ isLoading, error, children }: ContainerProps) {
 100    const retry = () => {
 101      // Retry logic
 102    }
 103  
 104    if (isLoading) return <div>Loading...</div>
 105    if (error) return <div>Error: {error.message}</div>
 106  
 107    return <>{children({ retry })}</>
 108  }
 109  
 110  // ============================================================================
 111  // Hooks Patterns
 112  // ============================================================================
 113  
 114  // useState with explicit type
 115  function useCounter(initialValue: number = 0) {
 116    const [count, setCount] = useState<number>(initialValue)
 117  
 118    const increment = () => setCount((c) => c + 1)
 119    const decrement = () => setCount((c) => c - 1)
 120    const reset = () => setCount(initialValue)
 121  
 122    return { count, increment, decrement, reset }
 123  }
 124  
 125  // useState with union type
 126  type LoadingState = 'idle' | 'loading' | 'success' | 'error'
 127  
 128  function useLoadingState() {
 129    const [state, setState] = useState<LoadingState>('idle')
 130  
 131    const startLoading = () => setState('loading')
 132    const setSuccess = () => setState('success')
 133    const setError = () => setState('error')
 134    const reset = () => setState('idle')
 135  
 136    return { state, startLoading, setSuccess, setError, reset }
 137  }
 138  
 139  // Custom hook with options
 140  interface UseFetchOptions<T> {
 141    initialData?: T
 142    onSuccess?: (data: T) => void
 143    onError?: (error: Error) => void
 144  }
 145  
 146  interface UseFetchReturn<T> {
 147    data: T | undefined
 148    loading: boolean
 149    error: Error | null
 150    refetch: () => Promise<void>
 151  }
 152  
 153  function useFetch<T>(url: string, options?: UseFetchOptions<T>): UseFetchReturn<T> {
 154    const [data, setData] = useState<T | undefined>(options?.initialData)
 155    const [loading, setLoading] = useState(false)
 156    const [error, setError] = useState<Error | null>(null)
 157  
 158    const fetchData = async () => {
 159      setLoading(true)
 160      setError(null)
 161  
 162      try {
 163        const response = await fetch(url)
 164        if (!response.ok) {
 165          throw new Error(`HTTP ${response.status}`)
 166        }
 167        const json = await response.json()
 168        setData(json)
 169        options?.onSuccess?.(json)
 170      } catch (err) {
 171        const error = err instanceof Error ? err : new Error(String(err))
 172        setError(error)
 173        options?.onError?.(error)
 174      } finally {
 175        setLoading(false)
 176      }
 177    }
 178  
 179    useEffect(() => {
 180      fetchData()
 181    }, [url])
 182  
 183    return { data, loading, error, refetch: fetchData }
 184  }
 185  
 186  // useReducer with discriminated unions
 187  interface User {
 188    id: string
 189    name: string
 190    email: string
 191  }
 192  
 193  type FetchState<T> =
 194    | { status: 'idle' }
 195    | { status: 'loading' }
 196    | { status: 'success'; data: T }
 197    | { status: 'error'; error: Error }
 198  
 199  type FetchAction<T> =
 200    | { type: 'FETCH_START' }
 201    | { type: 'FETCH_SUCCESS'; payload: T }
 202    | { type: 'FETCH_ERROR'; error: Error }
 203    | { type: 'RESET' }
 204  
 205  function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
 206    switch (action.type) {
 207      case 'FETCH_START':
 208        return { status: 'loading' }
 209      case 'FETCH_SUCCESS':
 210        return { status: 'success', data: action.payload }
 211      case 'FETCH_ERROR':
 212        return { status: 'error', error: action.error }
 213      case 'RESET':
 214        return { status: 'idle' }
 215    }
 216  }
 217  
 218  function useFetchWithReducer<T>(url: string) {
 219    const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
 220  
 221    useEffect(() => {
 222      let isCancelled = false
 223  
 224      const fetchData = async () => {
 225        dispatch({ type: 'FETCH_START' })
 226  
 227        try {
 228          const response = await fetch(url)
 229          const data = await response.json()
 230  
 231          if (!isCancelled) {
 232            dispatch({ type: 'FETCH_SUCCESS', payload: data })
 233          }
 234        } catch (error) {
 235          if (!isCancelled) {
 236            dispatch({
 237              type: 'FETCH_ERROR',
 238              error: error instanceof Error ? error : new Error(String(error)),
 239            })
 240          }
 241        }
 242      }
 243  
 244      fetchData()
 245  
 246      return () => {
 247        isCancelled = true
 248      }
 249    }, [url])
 250  
 251    return state
 252  }
 253  
 254  // ============================================================================
 255  // Context Patterns
 256  // ============================================================================
 257  
 258  // Type-safe context
 259  interface AuthContextType {
 260    user: User | null
 261    isAuthenticated: boolean
 262    login: (email: string, password: string) => Promise<void>
 263    logout: () => void
 264  }
 265  
 266  const AuthContext = createContext<AuthContextType | undefined>(undefined)
 267  
 268  export function AuthProvider({ children }: { children: ReactNode }) {
 269    const [user, setUser] = useState<User | null>(null)
 270  
 271    const login = async (email: string, password: string) => {
 272      // Login logic
 273      const userData = await fetch('/api/login', {
 274        method: 'POST',
 275        body: JSON.stringify({ email, password }),
 276      }).then((r) => r.json())
 277  
 278      setUser(userData)
 279    }
 280  
 281    const logout = () => {
 282      setUser(null)
 283    }
 284  
 285    const value: AuthContextType = {
 286      user,
 287      isAuthenticated: user !== null,
 288      login,
 289      logout,
 290    }
 291  
 292    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
 293  }
 294  
 295  // Custom hook with error handling
 296  export function useAuth(): AuthContextType {
 297    const context = useContext(AuthContext)
 298  
 299    if (context === undefined) {
 300      throw new Error('useAuth must be used within AuthProvider')
 301    }
 302  
 303    return context
 304  }
 305  
 306  // ============================================================================
 307  // Event Handler Patterns
 308  // ============================================================================
 309  
 310  interface FormData {
 311    name: string
 312    email: string
 313    message: string
 314  }
 315  
 316  function ContactForm() {
 317    const [formData, setFormData] = useState<FormData>({
 318      name: '',
 319      email: '',
 320      message: '',
 321    })
 322  
 323    // Type-safe change handler
 324    const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
 325      const { name, value } = e.target
 326      setFormData((prev) => ({
 327        ...prev,
 328        [name]: value,
 329      }))
 330    }
 331  
 332    // Type-safe submit handler
 333    const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
 334      e.preventDefault()
 335      console.log('Submitting:', formData)
 336    }
 337  
 338    // Specific field handler
 339    const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
 340      setFormData((prev) => ({ ...prev, name: e.target.value }))
 341    }
 342  
 343    return (
 344      <form onSubmit={handleSubmit}>
 345        <input
 346          name="name"
 347          value={formData.name}
 348          onChange={handleChange}
 349          placeholder="Name"
 350        />
 351        <input
 352          name="email"
 353          value={formData.email}
 354          onChange={handleChange}
 355          placeholder="Email"
 356        />
 357        <textarea
 358          name="message"
 359          value={formData.message}
 360          onChange={handleChange}
 361          placeholder="Message"
 362        />
 363        <button type="submit">Submit</button>
 364      </form>
 365    )
 366  }
 367  
 368  // ============================================================================
 369  // Ref Patterns
 370  // ============================================================================
 371  
 372  function FocusInput() {
 373    // useRef with DOM element
 374    const inputRef = useRef<HTMLInputElement>(null)
 375  
 376    const focusInput = () => {
 377      inputRef.current?.focus()
 378    }
 379  
 380    return (
 381      <div>
 382        <input ref={inputRef} />
 383        <button onClick={focusInput}>Focus Input</button>
 384      </div>
 385    )
 386  }
 387  
 388  function Timer() {
 389    // useRef for mutable value
 390    const countRef = useRef<number>(0)
 391    const intervalRef = useRef<NodeJS.Timeout | null>(null)
 392  
 393    const startTimer = () => {
 394      intervalRef.current = setInterval(() => {
 395        countRef.current += 1
 396        console.log(countRef.current)
 397      }, 1000)
 398    }
 399  
 400    const stopTimer = () => {
 401      if (intervalRef.current) {
 402        clearInterval(intervalRef.current)
 403        intervalRef.current = null
 404      }
 405    }
 406  
 407    return (
 408      <div>
 409        <button onClick={startTimer}>Start</button>
 410        <button onClick={stopTimer}>Stop</button>
 411      </div>
 412    )
 413  }
 414  
 415  // ============================================================================
 416  // Generic Component Patterns
 417  // ============================================================================
 418  
 419  // Select component with generic options
 420  interface SelectProps<T> {
 421    options: T[]
 422    value: T
 423    onChange: (value: T) => void
 424    getLabel: (option: T) => string
 425    getValue: (option: T) => string
 426  }
 427  
 428  export function Select<T>({
 429    options,
 430    value,
 431    onChange,
 432    getLabel,
 433    getValue,
 434  }: SelectProps<T>) {
 435    return (
 436      <select
 437        value={getValue(value)}
 438        onChange={(e) => {
 439          const selectedValue = e.target.value
 440          const option = options.find((opt) => getValue(opt) === selectedValue)
 441          if (option) {
 442            onChange(option)
 443          }
 444        }}
 445      >
 446        {options.map((option) => (
 447          <option key={getValue(option)} value={getValue(option)}>
 448            {getLabel(option)}
 449          </option>
 450        ))}
 451      </select>
 452    )
 453  }
 454  
 455  // Data table component
 456  interface Column<T> {
 457    key: keyof T
 458    header: string
 459    render?: (value: T[keyof T], row: T) => ReactNode
 460  }
 461  
 462  interface TableProps<T> {
 463    data: T[]
 464    columns: Column<T>[]
 465    keyExtractor: (row: T) => string
 466  }
 467  
 468  export function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
 469    return (
 470      <table>
 471        <thead>
 472          <tr>
 473            {columns.map((col) => (
 474              <th key={String(col.key)}>{col.header}</th>
 475            ))}
 476          </tr>
 477        </thead>
 478        <tbody>
 479          {data.map((row) => (
 480            <tr key={keyExtractor(row)}>
 481              {columns.map((col) => (
 482                <td key={String(col.key)}>
 483                  {col.render ? col.render(row[col.key], row) : String(row[col.key])}
 484                </td>
 485              ))}
 486            </tr>
 487          ))}
 488        </tbody>
 489      </table>
 490    )
 491  }
 492  
 493  // ============================================================================
 494  // Higher-Order Component Pattern
 495  // ============================================================================
 496  
 497  interface WithLoadingProps {
 498    isLoading: boolean
 499  }
 500  
 501  function withLoading<P extends object>(
 502    Component: React.ComponentType<P>,
 503  ): React.FC<P & WithLoadingProps> {
 504    return ({ isLoading, ...props }: WithLoadingProps & P) => {
 505      if (isLoading) {
 506        return <div>Loading...</div>
 507      }
 508  
 509      return <Component {...(props as P)} />
 510    }
 511  }
 512  
 513  // Usage
 514  interface UserListProps {
 515    users: User[]
 516  }
 517  
 518  const UserList: React.FC<UserListProps> = ({ users }) => (
 519    <ul>
 520      {users.map((user) => (
 521        <li key={user.id}>{user.name}</li>
 522      ))}
 523    </ul>
 524  )
 525  
 526  const UserListWithLoading = withLoading(UserList)
 527  
 528  // ============================================================================
 529  // Exports
 530  // ============================================================================
 531  
 532  export {
 533    useCounter,
 534    useLoadingState,
 535    useFetch,
 536    useFetchWithReducer,
 537    ContactForm,
 538    FocusInput,
 539    Timer,
 540  }
 541  
 542  export type {
 543    ButtonProps,
 544    InputProps,
 545    ListProps,
 546    UseFetchOptions,
 547    UseFetchReturn,
 548    FetchState,
 549    FetchAction,
 550    AuthContextType,
 551    SelectProps,
 552    Column,
 553    TableProps,
 554  }
 555  
 556