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