omgkit 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,47 +1,1325 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: frontend-design
|
|
3
|
-
description: Frontend design patterns
|
|
3
|
+
description: Frontend design patterns with compound components, render props, custom hooks, state machines, and scalable architecture
|
|
4
|
+
category: frontend
|
|
5
|
+
triggers:
|
|
6
|
+
- frontend design
|
|
7
|
+
- component patterns
|
|
8
|
+
- design patterns
|
|
9
|
+
- compound components
|
|
10
|
+
- render props
|
|
11
|
+
- custom hooks
|
|
12
|
+
- state management
|
|
13
|
+
- component architecture
|
|
4
14
|
---
|
|
5
15
|
|
|
6
|
-
# Frontend Design
|
|
16
|
+
# Frontend Design Patterns
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
Enterprise-grade **frontend design patterns** following industry best practices. This skill covers compound components, render props, custom hooks, state machines, higher-order components, and scalable architecture patterns used by top engineering teams.
|
|
19
|
+
|
|
20
|
+
## Purpose
|
|
21
|
+
|
|
22
|
+
Build maintainable and scalable frontend applications:
|
|
23
|
+
|
|
24
|
+
- Design reusable component APIs with compound patterns
|
|
25
|
+
- Implement flexible data fetching with render props
|
|
26
|
+
- Create powerful custom hooks for shared logic
|
|
27
|
+
- Manage complex state with state machines
|
|
28
|
+
- Build accessible form systems
|
|
29
|
+
- Implement optimistic UI updates
|
|
30
|
+
- Design scalable component architecture
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
### 1. Compound Components Pattern
|
|
9
35
|
|
|
10
|
-
### Compound Components
|
|
11
36
|
```tsx
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
37
|
+
// components/Select/SelectContext.tsx
|
|
38
|
+
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
|
39
|
+
|
|
40
|
+
interface SelectContextValue {
|
|
41
|
+
isOpen: boolean;
|
|
42
|
+
selectedValue: string | null;
|
|
43
|
+
selectedLabel: string | null;
|
|
44
|
+
highlightedIndex: number;
|
|
45
|
+
open: () => void;
|
|
46
|
+
close: () => void;
|
|
47
|
+
toggle: () => void;
|
|
48
|
+
selectOption: (value: string, label: string) => void;
|
|
49
|
+
setHighlightedIndex: (index: number) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SelectContext = createContext<SelectContextValue | null>(null);
|
|
53
|
+
|
|
54
|
+
function useSelectContext() {
|
|
55
|
+
const context = useContext(SelectContext);
|
|
56
|
+
if (!context) {
|
|
57
|
+
throw new Error('Select components must be used within a Select provider');
|
|
58
|
+
}
|
|
59
|
+
return context;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// components/Select/Select.tsx
|
|
63
|
+
interface SelectProps {
|
|
64
|
+
children: ReactNode;
|
|
65
|
+
defaultValue?: string;
|
|
66
|
+
value?: string;
|
|
67
|
+
onValueChange?: (value: string) => void;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Select({
|
|
72
|
+
children,
|
|
73
|
+
defaultValue,
|
|
74
|
+
value: controlledValue,
|
|
75
|
+
onValueChange,
|
|
76
|
+
disabled = false,
|
|
77
|
+
}: SelectProps) {
|
|
78
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
79
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? null);
|
|
80
|
+
const [selectedLabel, setSelectedLabel] = useState<string | null>(null);
|
|
81
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
82
|
+
|
|
83
|
+
const isControlled = controlledValue !== undefined;
|
|
84
|
+
const selectedValue = isControlled ? controlledValue : internalValue;
|
|
85
|
+
|
|
86
|
+
const open = useCallback(() => {
|
|
87
|
+
if (!disabled) setIsOpen(true);
|
|
88
|
+
}, [disabled]);
|
|
89
|
+
|
|
90
|
+
const close = useCallback(() => {
|
|
91
|
+
setIsOpen(false);
|
|
92
|
+
setHighlightedIndex(-1);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const toggle = useCallback(() => {
|
|
96
|
+
if (!disabled) setIsOpen((prev) => !prev);
|
|
97
|
+
}, [disabled]);
|
|
98
|
+
|
|
99
|
+
const selectOption = useCallback(
|
|
100
|
+
(value: string, label: string) => {
|
|
101
|
+
if (!isControlled) {
|
|
102
|
+
setInternalValue(value);
|
|
103
|
+
}
|
|
104
|
+
setSelectedLabel(label);
|
|
105
|
+
onValueChange?.(value);
|
|
106
|
+
close();
|
|
107
|
+
},
|
|
108
|
+
[isControlled, onValueChange, close]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<SelectContext.Provider
|
|
113
|
+
value={{
|
|
114
|
+
isOpen,
|
|
115
|
+
selectedValue,
|
|
116
|
+
selectedLabel,
|
|
117
|
+
highlightedIndex,
|
|
118
|
+
open,
|
|
119
|
+
close,
|
|
120
|
+
toggle,
|
|
121
|
+
selectOption,
|
|
122
|
+
setHighlightedIndex,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<div className="relative inline-block" data-disabled={disabled}>
|
|
126
|
+
{children}
|
|
127
|
+
</div>
|
|
128
|
+
</SelectContext.Provider>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// components/Select/SelectTrigger.tsx
|
|
133
|
+
interface SelectTriggerProps {
|
|
134
|
+
children?: ReactNode;
|
|
135
|
+
placeholder?: string;
|
|
136
|
+
className?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function SelectTrigger({
|
|
140
|
+
children,
|
|
141
|
+
placeholder = 'Select an option',
|
|
142
|
+
className = '',
|
|
143
|
+
}: SelectTriggerProps) {
|
|
144
|
+
const { isOpen, selectedLabel, toggle } = useSelectContext();
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
role="combobox"
|
|
150
|
+
aria-expanded={isOpen}
|
|
151
|
+
aria-haspopup="listbox"
|
|
152
|
+
className={`flex items-center justify-between gap-2 px-3 py-2 border rounded-md
|
|
153
|
+
bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
154
|
+
${className}`}
|
|
155
|
+
onClick={toggle}
|
|
156
|
+
>
|
|
157
|
+
<span className={selectedLabel ? 'text-gray-900' : 'text-gray-500'}>
|
|
158
|
+
{selectedLabel || children || placeholder}
|
|
159
|
+
</span>
|
|
160
|
+
<ChevronIcon isOpen={isOpen} />
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// components/Select/SelectContent.tsx
|
|
166
|
+
interface SelectContentProps {
|
|
167
|
+
children: ReactNode;
|
|
168
|
+
className?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function SelectContent({ children, className = '' }: SelectContentProps) {
|
|
172
|
+
const { isOpen, close } = useSelectContext();
|
|
173
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
174
|
+
|
|
175
|
+
// Close on outside click
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
178
|
+
if (contentRef.current && !contentRef.current.contains(event.target as Node)) {
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (isOpen) {
|
|
184
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
185
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
186
|
+
}
|
|
187
|
+
}, [isOpen, close]);
|
|
188
|
+
|
|
189
|
+
// Close on escape
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
192
|
+
if (event.key === 'Escape') close();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (isOpen) {
|
|
196
|
+
document.addEventListener('keydown', handleEscape);
|
|
197
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
198
|
+
}
|
|
199
|
+
}, [isOpen, close]);
|
|
200
|
+
|
|
201
|
+
if (!isOpen) return null;
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
ref={contentRef}
|
|
206
|
+
role="listbox"
|
|
207
|
+
className={`absolute z-50 mt-1 w-full bg-white border rounded-md shadow-lg
|
|
208
|
+
max-h-60 overflow-auto ${className}`}
|
|
209
|
+
>
|
|
210
|
+
{children}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// components/Select/SelectItem.tsx
|
|
216
|
+
interface SelectItemProps {
|
|
217
|
+
value: string;
|
|
218
|
+
children: ReactNode;
|
|
219
|
+
disabled?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function SelectItem({ value, children, disabled = false }: SelectItemProps) {
|
|
223
|
+
const { selectedValue, selectOption } = useSelectContext();
|
|
224
|
+
const isSelected = selectedValue === value;
|
|
225
|
+
|
|
226
|
+
const handleSelect = () => {
|
|
227
|
+
if (!disabled) {
|
|
228
|
+
selectOption(value, typeof children === 'string' ? children : value);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
role="option"
|
|
235
|
+
aria-selected={isSelected}
|
|
236
|
+
aria-disabled={disabled}
|
|
237
|
+
className={`px-3 py-2 cursor-pointer
|
|
238
|
+
${isSelected ? 'bg-blue-100 text-blue-900' : 'text-gray-900'}
|
|
239
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'}
|
|
240
|
+
`}
|
|
241
|
+
onClick={handleSelect}
|
|
242
|
+
>
|
|
243
|
+
{children}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Usage example
|
|
249
|
+
function SelectExample() {
|
|
250
|
+
const [country, setCountry] = useState('');
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<Select value={country} onValueChange={setCountry}>
|
|
254
|
+
<SelectTrigger placeholder="Select a country" />
|
|
255
|
+
<SelectContent>
|
|
256
|
+
<SelectItem value="us">United States</SelectItem>
|
|
257
|
+
<SelectItem value="uk">United Kingdom</SelectItem>
|
|
258
|
+
<SelectItem value="ca">Canada</SelectItem>
|
|
259
|
+
<SelectItem value="au" disabled>Australia (Coming Soon)</SelectItem>
|
|
260
|
+
</SelectContent>
|
|
261
|
+
</Select>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
18
264
|
```
|
|
19
265
|
|
|
20
|
-
### Render Props
|
|
266
|
+
### 2. Render Props Pattern
|
|
267
|
+
|
|
21
268
|
```tsx
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
269
|
+
// components/DataFetcher.tsx
|
|
270
|
+
import { useState, useEffect, useCallback, ReactNode } from 'react';
|
|
271
|
+
|
|
272
|
+
interface FetchState<T> {
|
|
273
|
+
data: T | null;
|
|
274
|
+
loading: boolean;
|
|
275
|
+
error: Error | null;
|
|
276
|
+
refetch: () => Promise<void>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface DataFetcherProps<T> {
|
|
280
|
+
url: string;
|
|
281
|
+
options?: RequestInit;
|
|
282
|
+
children: (state: FetchState<T>) => ReactNode;
|
|
283
|
+
onSuccess?: (data: T) => void;
|
|
284
|
+
onError?: (error: Error) => void;
|
|
285
|
+
initialData?: T;
|
|
286
|
+
enabled?: boolean;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function DataFetcher<T>({
|
|
290
|
+
url,
|
|
291
|
+
options,
|
|
292
|
+
children,
|
|
293
|
+
onSuccess,
|
|
294
|
+
onError,
|
|
295
|
+
initialData = null as T,
|
|
296
|
+
enabled = true,
|
|
297
|
+
}: DataFetcherProps<T>) {
|
|
298
|
+
const [data, setData] = useState<T | null>(initialData);
|
|
299
|
+
const [loading, setLoading] = useState(false);
|
|
300
|
+
const [error, setError] = useState<Error | null>(null);
|
|
301
|
+
|
|
302
|
+
const fetchData = useCallback(async () => {
|
|
303
|
+
if (!enabled) return;
|
|
304
|
+
|
|
305
|
+
setLoading(true);
|
|
306
|
+
setError(null);
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const response = await fetch(url, {
|
|
310
|
+
...options,
|
|
311
|
+
headers: {
|
|
312
|
+
'Content-Type': 'application/json',
|
|
313
|
+
...options?.headers,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const result = await response.json();
|
|
322
|
+
setData(result);
|
|
323
|
+
onSuccess?.(result);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
326
|
+
setError(error);
|
|
327
|
+
onError?.(error);
|
|
328
|
+
} finally {
|
|
329
|
+
setLoading(false);
|
|
330
|
+
}
|
|
331
|
+
}, [url, options, enabled, onSuccess, onError]);
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
fetchData();
|
|
335
|
+
}, [fetchData]);
|
|
336
|
+
|
|
337
|
+
return <>{children({ data, loading, error, refetch: fetchData })}</>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Mouse position render props
|
|
341
|
+
interface MousePosition {
|
|
342
|
+
x: number;
|
|
343
|
+
y: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface MouseTrackerProps {
|
|
347
|
+
children: (position: MousePosition) => ReactNode;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function MouseTracker({ children }: MouseTrackerProps) {
|
|
351
|
+
const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
355
|
+
setPosition({ x: event.clientX, y: event.clientY });
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
359
|
+
return () => window.removeEventListener('mousemove', handleMouseMove);
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
return <>{children(position)}</>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Intersection observer render props
|
|
366
|
+
interface IntersectionProps {
|
|
367
|
+
children: (entry: IntersectionObserverEntry | null, ref: React.RefObject<Element>) => ReactNode;
|
|
368
|
+
options?: IntersectionObserverInit;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function IntersectionObserverRender({ children, options }: IntersectionProps) {
|
|
372
|
+
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
|
|
373
|
+
const ref = useRef<Element>(null);
|
|
374
|
+
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
const element = ref.current;
|
|
377
|
+
if (!element) return;
|
|
378
|
+
|
|
379
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
380
|
+
setEntry(entry);
|
|
381
|
+
}, options);
|
|
382
|
+
|
|
383
|
+
observer.observe(element);
|
|
384
|
+
return () => observer.disconnect();
|
|
385
|
+
}, [options]);
|
|
386
|
+
|
|
387
|
+
return <>{children(entry, ref)}</>;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Usage examples
|
|
391
|
+
function RenderPropsExamples() {
|
|
392
|
+
return (
|
|
393
|
+
<>
|
|
394
|
+
{/* Data fetcher */}
|
|
395
|
+
<DataFetcher<User[]> url="/api/users">
|
|
396
|
+
{({ data, loading, error, refetch }) => {
|
|
397
|
+
if (loading) return <Spinner />;
|
|
398
|
+
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
|
|
399
|
+
return <UserList users={data || []} />;
|
|
400
|
+
}}
|
|
401
|
+
</DataFetcher>
|
|
402
|
+
|
|
403
|
+
{/* Mouse tracker */}
|
|
404
|
+
<MouseTracker>
|
|
405
|
+
{({ x, y }) => (
|
|
406
|
+
<div className="fixed pointer-events-none" style={{ left: x, top: y }}>
|
|
407
|
+
Custom cursor at ({x}, {y})
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</MouseTracker>
|
|
411
|
+
|
|
412
|
+
{/* Lazy loading with intersection observer */}
|
|
413
|
+
<IntersectionObserverRender options={{ threshold: 0.1 }}>
|
|
414
|
+
{(entry, ref) => (
|
|
415
|
+
<div ref={ref as React.RefObject<HTMLDivElement>}>
|
|
416
|
+
{entry?.isIntersecting ? <HeavyComponent /> : <Placeholder />}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</IntersectionObserverRender>
|
|
420
|
+
</>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
25
423
|
```
|
|
26
424
|
|
|
27
|
-
### Custom Hooks
|
|
425
|
+
### 3. Custom Hooks Pattern
|
|
426
|
+
|
|
28
427
|
```tsx
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
428
|
+
// hooks/useLocalStorage.ts
|
|
429
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
430
|
+
|
|
431
|
+
export function useLocalStorage<T>(
|
|
432
|
+
key: string,
|
|
433
|
+
initialValue: T
|
|
434
|
+
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
|
435
|
+
// Get initial value from localStorage or use provided initial
|
|
436
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
437
|
+
if (typeof window === 'undefined') return initialValue;
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const item = window.localStorage.getItem(key);
|
|
441
|
+
return item ? JSON.parse(item) : initialValue;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.warn(`Error reading localStorage key "${key}":`, error);
|
|
444
|
+
return initialValue;
|
|
445
|
+
}
|
|
33
446
|
});
|
|
34
447
|
|
|
448
|
+
// Persist to localStorage when value changes
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
if (typeof window === 'undefined') return;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
window.localStorage.setItem(key, JSON.stringify(storedValue));
|
|
454
|
+
} catch (error) {
|
|
455
|
+
console.warn(`Error setting localStorage key "${key}":`, error);
|
|
456
|
+
}
|
|
457
|
+
}, [key, storedValue]);
|
|
458
|
+
|
|
459
|
+
// Listen for changes in other tabs/windows
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
const handleStorageChange = (event: StorageEvent) => {
|
|
462
|
+
if (event.key === key && event.newValue) {
|
|
463
|
+
try {
|
|
464
|
+
setStoredValue(JSON.parse(event.newValue));
|
|
465
|
+
} catch {
|
|
466
|
+
// Ignore parse errors
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
window.addEventListener('storage', handleStorageChange);
|
|
472
|
+
return () => window.removeEventListener('storage', handleStorageChange);
|
|
473
|
+
}, [key]);
|
|
474
|
+
|
|
475
|
+
const remove = useCallback(() => {
|
|
476
|
+
if (typeof window !== 'undefined') {
|
|
477
|
+
window.localStorage.removeItem(key);
|
|
478
|
+
setStoredValue(initialValue);
|
|
479
|
+
}
|
|
480
|
+
}, [key, initialValue]);
|
|
481
|
+
|
|
482
|
+
return [storedValue, setStoredValue, remove];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// hooks/useDebounce.ts
|
|
486
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
487
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
488
|
+
|
|
35
489
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
|
|
490
|
+
const timer = setTimeout(() => {
|
|
491
|
+
setDebouncedValue(value);
|
|
492
|
+
}, delay);
|
|
38
493
|
|
|
39
|
-
|
|
494
|
+
return () => clearTimeout(timer);
|
|
495
|
+
}, [value, delay]);
|
|
496
|
+
|
|
497
|
+
return debouncedValue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// hooks/useDebouncedCallback.ts
|
|
501
|
+
export function useDebouncedCallback<T extends (...args: any[]) => any>(
|
|
502
|
+
callback: T,
|
|
503
|
+
delay: number
|
|
504
|
+
): (...args: Parameters<T>) => void {
|
|
505
|
+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
506
|
+
const callbackRef = useRef(callback);
|
|
507
|
+
|
|
508
|
+
// Keep callback ref updated
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
callbackRef.current = callback;
|
|
511
|
+
}, [callback]);
|
|
512
|
+
|
|
513
|
+
// Cleanup on unmount
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
return () => {
|
|
516
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
517
|
+
};
|
|
518
|
+
}, []);
|
|
519
|
+
|
|
520
|
+
return useCallback(
|
|
521
|
+
(...args: Parameters<T>) => {
|
|
522
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
523
|
+
|
|
524
|
+
timeoutRef.current = setTimeout(() => {
|
|
525
|
+
callbackRef.current(...args);
|
|
526
|
+
}, delay);
|
|
527
|
+
},
|
|
528
|
+
[delay]
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// hooks/useAsync.ts
|
|
533
|
+
interface AsyncState<T> {
|
|
534
|
+
data: T | null;
|
|
535
|
+
loading: boolean;
|
|
536
|
+
error: Error | null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface UseAsyncReturn<T> extends AsyncState<T> {
|
|
540
|
+
execute: () => Promise<T | null>;
|
|
541
|
+
reset: () => void;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function useAsync<T>(
|
|
545
|
+
asyncFunction: () => Promise<T>,
|
|
546
|
+
immediate = true
|
|
547
|
+
): UseAsyncReturn<T> {
|
|
548
|
+
const [state, setState] = useState<AsyncState<T>>({
|
|
549
|
+
data: null,
|
|
550
|
+
loading: immediate,
|
|
551
|
+
error: null,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const execute = useCallback(async () => {
|
|
555
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const data = await asyncFunction();
|
|
559
|
+
setState({ data, loading: false, error: null });
|
|
560
|
+
return data;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
setState({
|
|
563
|
+
data: null,
|
|
564
|
+
loading: false,
|
|
565
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
566
|
+
});
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}, [asyncFunction]);
|
|
570
|
+
|
|
571
|
+
const reset = useCallback(() => {
|
|
572
|
+
setState({ data: null, loading: false, error: null });
|
|
573
|
+
}, []);
|
|
574
|
+
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
if (immediate) {
|
|
577
|
+
execute();
|
|
578
|
+
}
|
|
579
|
+
}, [execute, immediate]);
|
|
580
|
+
|
|
581
|
+
return { ...state, execute, reset };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// hooks/useMediaQuery.ts
|
|
585
|
+
export function useMediaQuery(query: string): boolean {
|
|
586
|
+
const [matches, setMatches] = useState(() => {
|
|
587
|
+
if (typeof window === 'undefined') return false;
|
|
588
|
+
return window.matchMedia(query).matches;
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
const mediaQuery = window.matchMedia(query);
|
|
593
|
+
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
594
|
+
|
|
595
|
+
// Set initial value
|
|
596
|
+
setMatches(mediaQuery.matches);
|
|
597
|
+
|
|
598
|
+
// Modern browsers
|
|
599
|
+
mediaQuery.addEventListener('change', handler);
|
|
600
|
+
return () => mediaQuery.removeEventListener('change', handler);
|
|
601
|
+
}, [query]);
|
|
602
|
+
|
|
603
|
+
return matches;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// hooks/useClickOutside.ts
|
|
607
|
+
export function useClickOutside<T extends HTMLElement>(
|
|
608
|
+
callback: () => void
|
|
609
|
+
): React.RefObject<T> {
|
|
610
|
+
const ref = useRef<T>(null);
|
|
611
|
+
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
const handleClick = (event: MouseEvent) => {
|
|
614
|
+
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
615
|
+
callback();
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
document.addEventListener('mousedown', handleClick);
|
|
620
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
621
|
+
}, [callback]);
|
|
622
|
+
|
|
623
|
+
return ref;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// hooks/usePrevious.ts
|
|
627
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
628
|
+
const ref = useRef<T>();
|
|
629
|
+
|
|
630
|
+
useEffect(() => {
|
|
631
|
+
ref.current = value;
|
|
632
|
+
}, [value]);
|
|
633
|
+
|
|
634
|
+
return ref.current;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Usage examples
|
|
638
|
+
function HooksExamples() {
|
|
639
|
+
const [theme, setTheme] = useLocalStorage('theme', 'light');
|
|
640
|
+
const [search, setSearch] = useState('');
|
|
641
|
+
const debouncedSearch = useDebounce(search, 300);
|
|
642
|
+
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
643
|
+
const { data, loading, error } = useAsync(() => fetchUsers());
|
|
644
|
+
|
|
645
|
+
const dropdownRef = useClickOutside<HTMLDivElement>(() => {
|
|
646
|
+
setIsOpen(false);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<div>
|
|
651
|
+
<ThemeToggle theme={theme} onChange={setTheme} />
|
|
652
|
+
<SearchInput value={search} onChange={setSearch} />
|
|
653
|
+
<SearchResults query={debouncedSearch} />
|
|
654
|
+
{isMobile ? <MobileNav /> : <DesktopNav />}
|
|
655
|
+
</div>
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### 4. State Machine Pattern
|
|
661
|
+
|
|
662
|
+
```tsx
|
|
663
|
+
// lib/createStateMachine.ts
|
|
664
|
+
type StateConfig<TContext, TState extends string, TEvent extends { type: string }> = {
|
|
665
|
+
initial: TState;
|
|
666
|
+
context: TContext;
|
|
667
|
+
states: {
|
|
668
|
+
[K in TState]: {
|
|
669
|
+
on?: {
|
|
670
|
+
[E in TEvent['type']]?: TState | {
|
|
671
|
+
target: TState;
|
|
672
|
+
actions?: (context: TContext, event: Extract<TEvent, { type: E }>) => TContext;
|
|
673
|
+
guard?: (context: TContext, event: Extract<TEvent, { type: E }>) => boolean;
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
entry?: (context: TContext) => TContext | void;
|
|
677
|
+
exit?: (context: TContext) => TContext | void;
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// hooks/useStateMachine.ts
|
|
683
|
+
export function useStateMachine<
|
|
684
|
+
TContext,
|
|
685
|
+
TState extends string,
|
|
686
|
+
TEvent extends { type: string }
|
|
687
|
+
>(config: StateConfig<TContext, TState, TEvent>) {
|
|
688
|
+
const [state, setState] = useState<TState>(config.initial);
|
|
689
|
+
const [context, setContext] = useState<TContext>(config.context);
|
|
690
|
+
|
|
691
|
+
const send = useCallback(
|
|
692
|
+
(event: TEvent) => {
|
|
693
|
+
const currentStateConfig = config.states[state];
|
|
694
|
+
const transition = currentStateConfig.on?.[event.type as TEvent['type']];
|
|
695
|
+
|
|
696
|
+
if (!transition) return;
|
|
697
|
+
|
|
698
|
+
let nextState: TState;
|
|
699
|
+
let newContext = context;
|
|
700
|
+
|
|
701
|
+
if (typeof transition === 'string') {
|
|
702
|
+
nextState = transition;
|
|
703
|
+
} else {
|
|
704
|
+
// Check guard condition
|
|
705
|
+
if (transition.guard && !transition.guard(context, event as any)) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
nextState = transition.target;
|
|
709
|
+
if (transition.actions) {
|
|
710
|
+
newContext = transition.actions(context, event as any);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Run exit action
|
|
715
|
+
const exitAction = currentStateConfig.exit;
|
|
716
|
+
if (exitAction) {
|
|
717
|
+
const result = exitAction(newContext);
|
|
718
|
+
if (result) newContext = result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Run entry action
|
|
722
|
+
const nextStateConfig = config.states[nextState];
|
|
723
|
+
const entryAction = nextStateConfig.entry;
|
|
724
|
+
if (entryAction) {
|
|
725
|
+
const result = entryAction(newContext);
|
|
726
|
+
if (result) newContext = result;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
setState(nextState);
|
|
730
|
+
setContext(newContext);
|
|
731
|
+
},
|
|
732
|
+
[state, context, config]
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const matches = useCallback((s: TState) => state === s, [state]);
|
|
736
|
+
|
|
737
|
+
return { state, context, send, matches };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Example: Form submission state machine
|
|
741
|
+
type FormState = 'idle' | 'validating' | 'submitting' | 'success' | 'error';
|
|
742
|
+
|
|
743
|
+
type FormEvent =
|
|
744
|
+
| { type: 'SUBMIT'; data: FormData }
|
|
745
|
+
| { type: 'VALIDATE_SUCCESS' }
|
|
746
|
+
| { type: 'VALIDATE_ERROR'; errors: Record<string, string> }
|
|
747
|
+
| { type: 'SUBMIT_SUCCESS'; response: any }
|
|
748
|
+
| { type: 'SUBMIT_ERROR'; error: string }
|
|
749
|
+
| { type: 'RESET' };
|
|
750
|
+
|
|
751
|
+
interface FormContext {
|
|
752
|
+
data: FormData | null;
|
|
753
|
+
errors: Record<string, string>;
|
|
754
|
+
response: any;
|
|
755
|
+
submitError: string | null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const formMachine: StateConfig<FormContext, FormState, FormEvent> = {
|
|
759
|
+
initial: 'idle',
|
|
760
|
+
context: {
|
|
761
|
+
data: null,
|
|
762
|
+
errors: {},
|
|
763
|
+
response: null,
|
|
764
|
+
submitError: null,
|
|
765
|
+
},
|
|
766
|
+
states: {
|
|
767
|
+
idle: {
|
|
768
|
+
on: {
|
|
769
|
+
SUBMIT: {
|
|
770
|
+
target: 'validating',
|
|
771
|
+
actions: (ctx, event) => ({ ...ctx, data: event.data, errors: {} }),
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
validating: {
|
|
776
|
+
on: {
|
|
777
|
+
VALIDATE_SUCCESS: 'submitting',
|
|
778
|
+
VALIDATE_ERROR: {
|
|
779
|
+
target: 'idle',
|
|
780
|
+
actions: (ctx, event) => ({ ...ctx, errors: event.errors }),
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
submitting: {
|
|
785
|
+
on: {
|
|
786
|
+
SUBMIT_SUCCESS: {
|
|
787
|
+
target: 'success',
|
|
788
|
+
actions: (ctx, event) => ({ ...ctx, response: event.response }),
|
|
789
|
+
},
|
|
790
|
+
SUBMIT_ERROR: {
|
|
791
|
+
target: 'error',
|
|
792
|
+
actions: (ctx, event) => ({ ...ctx, submitError: event.error }),
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
success: {
|
|
797
|
+
on: {
|
|
798
|
+
RESET: {
|
|
799
|
+
target: 'idle',
|
|
800
|
+
actions: () => ({
|
|
801
|
+
data: null,
|
|
802
|
+
errors: {},
|
|
803
|
+
response: null,
|
|
804
|
+
submitError: null,
|
|
805
|
+
}),
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
error: {
|
|
810
|
+
on: {
|
|
811
|
+
SUBMIT: {
|
|
812
|
+
target: 'validating',
|
|
813
|
+
actions: (ctx, event) => ({
|
|
814
|
+
...ctx,
|
|
815
|
+
data: event.data,
|
|
816
|
+
submitError: null,
|
|
817
|
+
}),
|
|
818
|
+
},
|
|
819
|
+
RESET: {
|
|
820
|
+
target: 'idle',
|
|
821
|
+
actions: () => ({
|
|
822
|
+
data: null,
|
|
823
|
+
errors: {},
|
|
824
|
+
response: null,
|
|
825
|
+
submitError: null,
|
|
826
|
+
}),
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
function FormWithStateMachine() {
|
|
834
|
+
const { state, context, send, matches } = useStateMachine(formMachine);
|
|
835
|
+
|
|
836
|
+
const handleSubmit = async (formData: FormData) => {
|
|
837
|
+
send({ type: 'SUBMIT', data: formData });
|
|
838
|
+
|
|
839
|
+
// Validate
|
|
840
|
+
const errors = validate(formData);
|
|
841
|
+
if (Object.keys(errors).length > 0) {
|
|
842
|
+
send({ type: 'VALIDATE_ERROR', errors });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
send({ type: 'VALIDATE_SUCCESS' });
|
|
846
|
+
|
|
847
|
+
// Submit
|
|
848
|
+
try {
|
|
849
|
+
const response = await submitForm(formData);
|
|
850
|
+
send({ type: 'SUBMIT_SUCCESS', response });
|
|
851
|
+
} catch (error) {
|
|
852
|
+
send({ type: 'SUBMIT_ERROR', error: error.message });
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
return (
|
|
857
|
+
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.target)); }}>
|
|
858
|
+
{matches('success') && <SuccessMessage response={context.response} />}
|
|
859
|
+
{matches('error') && <ErrorMessage error={context.submitError} />}
|
|
860
|
+
{Object.entries(context.errors).map(([field, error]) => (
|
|
861
|
+
<FieldError key={field} field={field} error={error} />
|
|
862
|
+
))}
|
|
863
|
+
<button type="submit" disabled={matches('submitting') || matches('validating')}>
|
|
864
|
+
{matches('submitting') ? 'Submitting...' : 'Submit'}
|
|
865
|
+
</button>
|
|
866
|
+
</form>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### 5. Higher-Order Components (HOC)
|
|
872
|
+
|
|
873
|
+
```tsx
|
|
874
|
+
// hocs/withAuth.tsx
|
|
875
|
+
import { useRouter } from 'next/router';
|
|
876
|
+
import { ComponentType, useEffect } from 'react';
|
|
877
|
+
import { useAuth } from '@/hooks/useAuth';
|
|
878
|
+
|
|
879
|
+
interface WithAuthOptions {
|
|
880
|
+
redirectTo?: string;
|
|
881
|
+
requiredRole?: string;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function withAuth<P extends object>(
|
|
885
|
+
WrappedComponent: ComponentType<P>,
|
|
886
|
+
options: WithAuthOptions = {}
|
|
887
|
+
) {
|
|
888
|
+
const { redirectTo = '/login', requiredRole } = options;
|
|
889
|
+
|
|
890
|
+
function AuthenticatedComponent(props: P) {
|
|
891
|
+
const router = useRouter();
|
|
892
|
+
const { user, loading, isAuthenticated } = useAuth();
|
|
893
|
+
|
|
894
|
+
useEffect(() => {
|
|
895
|
+
if (!loading && !isAuthenticated) {
|
|
896
|
+
router.push(`${redirectTo}?returnUrl=${encodeURIComponent(router.asPath)}`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!loading && requiredRole && user?.role !== requiredRole) {
|
|
900
|
+
router.push('/unauthorized');
|
|
901
|
+
}
|
|
902
|
+
}, [loading, isAuthenticated, user, router]);
|
|
903
|
+
|
|
904
|
+
if (loading) {
|
|
905
|
+
return <LoadingSpinner />;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (!isAuthenticated) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (requiredRole && user?.role !== requiredRole) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return <WrappedComponent {...props} />;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
AuthenticatedComponent.displayName = `withAuth(${
|
|
920
|
+
WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
|
921
|
+
})`;
|
|
922
|
+
|
|
923
|
+
return AuthenticatedComponent;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// hocs/withErrorBoundary.tsx
|
|
927
|
+
interface WithErrorBoundaryOptions {
|
|
928
|
+
fallback?: ReactNode;
|
|
929
|
+
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export function withErrorBoundary<P extends object>(
|
|
933
|
+
WrappedComponent: ComponentType<P>,
|
|
934
|
+
options: WithErrorBoundaryOptions = {}
|
|
935
|
+
) {
|
|
936
|
+
const { fallback, onError } = options;
|
|
937
|
+
|
|
938
|
+
class ErrorBoundaryHOC extends React.Component<
|
|
939
|
+
P,
|
|
940
|
+
{ hasError: boolean; error: Error | null }
|
|
941
|
+
> {
|
|
942
|
+
static displayName = `withErrorBoundary(${
|
|
943
|
+
WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
|
944
|
+
})`;
|
|
945
|
+
|
|
946
|
+
constructor(props: P) {
|
|
947
|
+
super(props);
|
|
948
|
+
this.state = { hasError: false, error: null };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
static getDerivedStateFromError(error: Error) {
|
|
952
|
+
return { hasError: true, error };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
956
|
+
onError?.(error, errorInfo);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
render() {
|
|
960
|
+
if (this.state.hasError) {
|
|
961
|
+
return fallback || <DefaultErrorFallback error={this.state.error} />;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return <WrappedComponent {...this.props} />;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return ErrorBoundaryHOC;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// hocs/withLoading.tsx
|
|
972
|
+
interface WithLoadingProps {
|
|
973
|
+
isLoading?: boolean;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function withLoading<P extends object>(
|
|
977
|
+
WrappedComponent: ComponentType<P>,
|
|
978
|
+
LoadingComponent: ComponentType = DefaultSpinner
|
|
979
|
+
) {
|
|
980
|
+
function LoadingWrapper(props: P & WithLoadingProps) {
|
|
981
|
+
const { isLoading, ...rest } = props;
|
|
982
|
+
|
|
983
|
+
if (isLoading) {
|
|
984
|
+
return <LoadingComponent />;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return <WrappedComponent {...(rest as P)} />;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
LoadingWrapper.displayName = `withLoading(${
|
|
991
|
+
WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
|
992
|
+
})`;
|
|
993
|
+
|
|
994
|
+
return LoadingWrapper;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Usage
|
|
998
|
+
const ProtectedDashboard = withAuth(Dashboard, { requiredRole: 'admin' });
|
|
999
|
+
const SafeUserProfile = withErrorBoundary(UserProfile, {
|
|
1000
|
+
fallback: <div>Something went wrong</div>,
|
|
1001
|
+
});
|
|
1002
|
+
const LoadableDataGrid = withLoading(DataGrid);
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### 6. Optimistic UI Updates
|
|
1006
|
+
|
|
1007
|
+
```tsx
|
|
1008
|
+
// hooks/useOptimistic.ts
|
|
1009
|
+
interface OptimisticState<T> {
|
|
1010
|
+
data: T;
|
|
1011
|
+
pending: boolean;
|
|
1012
|
+
error: Error | null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export function useOptimistic<T, TAction>(
|
|
1016
|
+
initialData: T,
|
|
1017
|
+
reducer: (state: T, action: TAction) => T
|
|
1018
|
+
) {
|
|
1019
|
+
const [state, setState] = useState<OptimisticState<T>>({
|
|
1020
|
+
data: initialData,
|
|
1021
|
+
pending: false,
|
|
1022
|
+
error: null,
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const previousDataRef = useRef<T>(initialData);
|
|
1026
|
+
|
|
1027
|
+
const optimisticUpdate = useCallback(
|
|
1028
|
+
async (action: TAction, asyncOperation: () => Promise<T>) => {
|
|
1029
|
+
// Store previous state for rollback
|
|
1030
|
+
previousDataRef.current = state.data;
|
|
1031
|
+
|
|
1032
|
+
// Apply optimistic update
|
|
1033
|
+
const optimisticData = reducer(state.data, action);
|
|
1034
|
+
setState({ data: optimisticData, pending: true, error: null });
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
// Perform actual async operation
|
|
1038
|
+
const result = await asyncOperation();
|
|
1039
|
+
setState({ data: result, pending: false, error: null });
|
|
1040
|
+
return result;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
// Rollback on error
|
|
1043
|
+
setState({
|
|
1044
|
+
data: previousDataRef.current,
|
|
1045
|
+
pending: false,
|
|
1046
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1047
|
+
});
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
[state.data, reducer]
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
return { ...state, optimisticUpdate };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Example: Todo list with optimistic updates
|
|
1058
|
+
interface Todo {
|
|
1059
|
+
id: string;
|
|
1060
|
+
text: string;
|
|
1061
|
+
completed: boolean;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function TodoList() {
|
|
1065
|
+
const { data: todos, pending, error, optimisticUpdate } = useOptimistic<
|
|
1066
|
+
Todo[],
|
|
1067
|
+
{ type: 'ADD' | 'TOGGLE' | 'DELETE'; payload: any }
|
|
1068
|
+
>(initialTodos, (state, action) => {
|
|
1069
|
+
switch (action.type) {
|
|
1070
|
+
case 'ADD':
|
|
1071
|
+
return [...state, action.payload];
|
|
1072
|
+
case 'TOGGLE':
|
|
1073
|
+
return state.map((todo) =>
|
|
1074
|
+
todo.id === action.payload
|
|
1075
|
+
? { ...todo, completed: !todo.completed }
|
|
1076
|
+
: todo
|
|
1077
|
+
);
|
|
1078
|
+
case 'DELETE':
|
|
1079
|
+
return state.filter((todo) => todo.id !== action.payload);
|
|
1080
|
+
default:
|
|
1081
|
+
return state;
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const addTodo = async (text: string) => {
|
|
1086
|
+
const tempId = `temp-${Date.now()}`;
|
|
1087
|
+
const newTodo = { id: tempId, text, completed: false };
|
|
1088
|
+
|
|
1089
|
+
await optimisticUpdate(
|
|
1090
|
+
{ type: 'ADD', payload: newTodo },
|
|
1091
|
+
async () => {
|
|
1092
|
+
const response = await api.createTodo({ text });
|
|
1093
|
+
return todos.map((t) => (t.id === tempId ? response : t));
|
|
1094
|
+
}
|
|
1095
|
+
);
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const toggleTodo = async (id: string) => {
|
|
1099
|
+
await optimisticUpdate(
|
|
1100
|
+
{ type: 'TOGGLE', payload: id },
|
|
1101
|
+
async () => {
|
|
1102
|
+
await api.toggleTodo(id);
|
|
1103
|
+
return todos.map((t) =>
|
|
1104
|
+
t.id === id ? { ...t, completed: !t.completed } : t
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
);
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
return (
|
|
1111
|
+
<div className={pending ? 'opacity-70' : ''}>
|
|
1112
|
+
{error && <ErrorToast message={error.message} />}
|
|
1113
|
+
<TodoForm onSubmit={addTodo} disabled={pending} />
|
|
1114
|
+
{todos.map((todo) => (
|
|
1115
|
+
<TodoItem
|
|
1116
|
+
key={todo.id}
|
|
1117
|
+
todo={todo}
|
|
1118
|
+
onToggle={() => toggleTodo(todo.id)}
|
|
1119
|
+
/>
|
|
1120
|
+
))}
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
## Use Cases
|
|
1127
|
+
|
|
1128
|
+
### Component Library Architecture
|
|
1129
|
+
|
|
1130
|
+
```tsx
|
|
1131
|
+
// lib/components/index.ts - Barrel exports
|
|
1132
|
+
export { Button, type ButtonProps } from './Button';
|
|
1133
|
+
export { Input, type InputProps } from './Input';
|
|
1134
|
+
export { Select, SelectTrigger, SelectContent, SelectItem } from './Select';
|
|
1135
|
+
|
|
1136
|
+
// lib/components/Button/Button.tsx
|
|
1137
|
+
import { forwardRef, ButtonHTMLAttributes } from 'react';
|
|
1138
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
1139
|
+
import { cn } from '@/lib/utils';
|
|
1140
|
+
|
|
1141
|
+
const buttonVariants = cva(
|
|
1142
|
+
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
|
1143
|
+
{
|
|
1144
|
+
variants: {
|
|
1145
|
+
variant: {
|
|
1146
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
1147
|
+
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
1148
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
1149
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
1150
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
1151
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
1152
|
+
},
|
|
1153
|
+
size: {
|
|
1154
|
+
default: 'h-10 px-4 py-2',
|
|
1155
|
+
sm: 'h-9 rounded-md px-3',
|
|
1156
|
+
lg: 'h-11 rounded-md px-8',
|
|
1157
|
+
icon: 'h-10 w-10',
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
defaultVariants: {
|
|
1161
|
+
variant: 'default',
|
|
1162
|
+
size: 'default',
|
|
1163
|
+
},
|
|
1164
|
+
}
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
export interface ButtonProps
|
|
1168
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
1169
|
+
VariantProps<typeof buttonVariants> {
|
|
1170
|
+
loading?: boolean;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
1174
|
+
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
|
|
1175
|
+
return (
|
|
1176
|
+
<button
|
|
1177
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
1178
|
+
ref={ref}
|
|
1179
|
+
disabled={disabled || loading}
|
|
1180
|
+
{...props}
|
|
1181
|
+
>
|
|
1182
|
+
{loading && <Spinner className="mr-2 h-4 w-4" />}
|
|
1183
|
+
{children}
|
|
1184
|
+
</button>
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
Button.displayName = 'Button';
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
### Form Architecture Pattern
|
|
1193
|
+
|
|
1194
|
+
```tsx
|
|
1195
|
+
// hooks/useForm.ts
|
|
1196
|
+
interface UseFormConfig<T> {
|
|
1197
|
+
initialValues: T;
|
|
1198
|
+
validate: (values: T) => Partial<Record<keyof T, string>>;
|
|
1199
|
+
onSubmit: (values: T) => Promise<void>;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export function useForm<T extends Record<string, any>>({
|
|
1203
|
+
initialValues,
|
|
1204
|
+
validate,
|
|
1205
|
+
onSubmit,
|
|
1206
|
+
}: UseFormConfig<T>) {
|
|
1207
|
+
const [values, setValues] = useState<T>(initialValues);
|
|
1208
|
+
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
1209
|
+
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
|
|
1210
|
+
const [submitting, setSubmitting] = useState(false);
|
|
1211
|
+
|
|
1212
|
+
const handleChange = useCallback(
|
|
1213
|
+
(field: keyof T) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
1214
|
+
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
|
1215
|
+
setValues((prev) => ({ ...prev, [field]: value }));
|
|
1216
|
+
},
|
|
1217
|
+
[]
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
const handleBlur = useCallback(
|
|
1221
|
+
(field: keyof T) => () => {
|
|
1222
|
+
setTouched((prev) => ({ ...prev, [field]: true }));
|
|
1223
|
+
const fieldErrors = validate(values);
|
|
1224
|
+
if (fieldErrors[field]) {
|
|
1225
|
+
setErrors((prev) => ({ ...prev, [field]: fieldErrors[field] }));
|
|
1226
|
+
} else {
|
|
1227
|
+
setErrors((prev) => {
|
|
1228
|
+
const { [field]: _, ...rest } = prev;
|
|
1229
|
+
return rest as Partial<Record<keyof T, string>>;
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
[values, validate]
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
const handleSubmit = useCallback(
|
|
1237
|
+
async (e: React.FormEvent) => {
|
|
1238
|
+
e.preventDefault();
|
|
1239
|
+
|
|
1240
|
+
const validationErrors = validate(values);
|
|
1241
|
+
setErrors(validationErrors);
|
|
1242
|
+
setTouched(
|
|
1243
|
+
Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {})
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
if (Object.keys(validationErrors).length > 0) return;
|
|
1247
|
+
|
|
1248
|
+
setSubmitting(true);
|
|
1249
|
+
try {
|
|
1250
|
+
await onSubmit(values);
|
|
1251
|
+
} finally {
|
|
1252
|
+
setSubmitting(false);
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
[values, validate, onSubmit]
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
const reset = useCallback(() => {
|
|
1259
|
+
setValues(initialValues);
|
|
1260
|
+
setErrors({});
|
|
1261
|
+
setTouched({});
|
|
1262
|
+
}, [initialValues]);
|
|
1263
|
+
|
|
1264
|
+
const getFieldProps = useCallback(
|
|
1265
|
+
(field: keyof T) => ({
|
|
1266
|
+
value: values[field],
|
|
1267
|
+
onChange: handleChange(field),
|
|
1268
|
+
onBlur: handleBlur(field),
|
|
1269
|
+
error: touched[field] ? errors[field] : undefined,
|
|
1270
|
+
}),
|
|
1271
|
+
[values, errors, touched, handleChange, handleBlur]
|
|
1272
|
+
);
|
|
1273
|
+
|
|
1274
|
+
return {
|
|
1275
|
+
values,
|
|
1276
|
+
errors,
|
|
1277
|
+
touched,
|
|
1278
|
+
submitting,
|
|
1279
|
+
handleChange,
|
|
1280
|
+
handleBlur,
|
|
1281
|
+
handleSubmit,
|
|
1282
|
+
reset,
|
|
1283
|
+
getFieldProps,
|
|
1284
|
+
setValues,
|
|
1285
|
+
setFieldValue: (field: keyof T, value: T[keyof T]) =>
|
|
1286
|
+
setValues((prev) => ({ ...prev, [field]: value })),
|
|
1287
|
+
};
|
|
40
1288
|
}
|
|
41
1289
|
```
|
|
42
1290
|
|
|
43
1291
|
## Best Practices
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- Use
|
|
1292
|
+
|
|
1293
|
+
### Do's
|
|
1294
|
+
|
|
1295
|
+
- Use compound components for complex UI with shared state
|
|
1296
|
+
- Create custom hooks to encapsulate reusable logic
|
|
1297
|
+
- Implement state machines for complex state transitions
|
|
1298
|
+
- Use TypeScript for type-safe component APIs
|
|
1299
|
+
- Prefer composition over inheritance
|
|
1300
|
+
- Keep components focused with single responsibility
|
|
1301
|
+
- Colocate related code and styles
|
|
1302
|
+
- Use forwardRef for component library primitives
|
|
1303
|
+
- Implement proper error boundaries
|
|
1304
|
+
- Use render props for maximum flexibility
|
|
1305
|
+
|
|
1306
|
+
### Don'ts
|
|
1307
|
+
|
|
1308
|
+
- Don't overuse HOCs (prefer hooks)
|
|
1309
|
+
- Don't mutate state directly
|
|
1310
|
+
- Don't create deeply nested component hierarchies
|
|
1311
|
+
- Don't pass too many props (use context or composition)
|
|
1312
|
+
- Don't ignore TypeScript errors
|
|
1313
|
+
- Don't create components with side effects in render
|
|
1314
|
+
- Don't forget to memoize expensive computations
|
|
1315
|
+
- Don't skip accessibility in component design
|
|
1316
|
+
- Don't use prop drilling for deeply nested data
|
|
1317
|
+
- Don't forget cleanup in useEffect
|
|
1318
|
+
|
|
1319
|
+
## References
|
|
1320
|
+
|
|
1321
|
+
- [React Patterns](https://reactpatterns.com/)
|
|
1322
|
+
- [Patterns.dev](https://www.patterns.dev/)
|
|
1323
|
+
- [Kent C. Dodds - Advanced React Patterns](https://kentcdodds.com/blog/advanced-react-patterns)
|
|
1324
|
+
- [XState Documentation](https://xstate.js.org/docs/)
|
|
1325
|
+
- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/)
|