@zigrivers/scaffold 3.6.0 → 3.7.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/README.md +87 -12
- package/content/knowledge/backend/backend-api-design.md +103 -0
- package/content/knowledge/backend/backend-architecture.md +100 -0
- package/content/knowledge/backend/backend-async-patterns.md +101 -0
- package/content/knowledge/backend/backend-auth-patterns.md +100 -0
- package/content/knowledge/backend/backend-conventions.md +105 -0
- package/content/knowledge/backend/backend-data-modeling.md +102 -0
- package/content/knowledge/backend/backend-deployment.md +100 -0
- package/content/knowledge/backend/backend-dev-environment.md +102 -0
- package/content/knowledge/backend/backend-observability.md +102 -0
- package/content/knowledge/backend/backend-project-structure.md +100 -0
- package/content/knowledge/backend/backend-requirements.md +103 -0
- package/content/knowledge/backend/backend-security.md +104 -0
- package/content/knowledge/backend/backend-testing.md +101 -0
- package/content/knowledge/backend/backend-worker-patterns.md +100 -0
- package/content/knowledge/cli/cli-architecture.md +101 -0
- package/content/knowledge/cli/cli-conventions.md +117 -0
- package/content/knowledge/cli/cli-dev-environment.md +121 -0
- package/content/knowledge/cli/cli-distribution-patterns.md +106 -0
- package/content/knowledge/cli/cli-interactivity-patterns.md +116 -0
- package/content/knowledge/cli/cli-output-patterns.md +107 -0
- package/content/knowledge/cli/cli-project-structure.md +124 -0
- package/content/knowledge/cli/cli-requirements.md +101 -0
- package/content/knowledge/cli/cli-shell-integration.md +130 -0
- package/content/knowledge/cli/cli-testing.md +134 -0
- package/content/knowledge/web-app/web-app-api-patterns.md +224 -0
- package/content/knowledge/web-app/web-app-architecture.md +116 -0
- package/content/knowledge/web-app/web-app-auth-patterns.md +256 -0
- package/content/knowledge/web-app/web-app-conventions.md +121 -0
- package/content/knowledge/web-app/web-app-data-patterns.md +218 -0
- package/content/knowledge/web-app/web-app-deployment-workflow.md +143 -0
- package/content/knowledge/web-app/web-app-deployment.md +134 -0
- package/content/knowledge/web-app/web-app-design-system.md +158 -0
- package/content/knowledge/web-app/web-app-dev-environment.md +173 -0
- package/content/knowledge/web-app/web-app-observability.md +221 -0
- package/content/knowledge/web-app/web-app-project-structure.md +160 -0
- package/content/knowledge/web-app/web-app-rendering-strategies.md +133 -0
- package/content/knowledge/web-app/web-app-requirements.md +112 -0
- package/content/knowledge/web-app/web-app-security.md +193 -0
- package/content/knowledge/web-app/web-app-session-patterns.md +214 -0
- package/content/knowledge/web-app/web-app-testing.md +249 -0
- package/content/knowledge/web-app/web-app-ux-patterns.md +162 -0
- package/content/methodology/backend-overlay.yml +73 -0
- package/content/methodology/cli-overlay.yml +69 -0
- package/content/methodology/web-app-overlay.yml +79 -0
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +182 -13
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +136 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/config/schema.d.ts +800 -32
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +48 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +156 -1
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +2 -1
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +34 -0
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/e2e/game-pipeline.test.js +1 -0
- package/dist/e2e/game-pipeline.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.d.ts +15 -0
- package/dist/e2e/project-type-overlays.test.d.ts.map +1 -0
- package/dist/e2e/project-type-overlays.test.js +534 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -0
- package/dist/types/config.d.ts +13 -2
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/wizard/questions.d.ts +16 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +87 -3
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +117 -4
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +12 -0
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +16 -1
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
- package/dist/types/wizard.d.ts +0 -14
- package/dist/types/wizard.d.ts.map +0 -1
- package/dist/types/wizard.js +0 -2
- package/dist/types/wizard.js.map +0 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-app-auth-patterns
|
|
3
|
+
description: OAuth 2.0 + PKCE flows, cookie security, passkey/WebAuthn, social login, CSRF protection, and auth state management
|
|
4
|
+
topics: [web-app, auth, oauth, pkce, webauthn, passkeys, csrf, security]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Authentication in web applications is a deep domain where implementation mistakes have severe security consequences. The auth surface spans the browser, the server, and third-party identity providers — and each boundary has its own threat model. OAuth 2.0 with PKCE is now the standard for delegated authorization; WebAuthn/passkeys are rapidly becoming the standard for credential-free authentication; and session cookie security attributes are the baseline that every web app must get right. Skipping any of these correctly has historically led to breaches.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
### OAuth 2.0 + PKCE Flow
|
|
12
|
+
|
|
13
|
+
OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange) is the correct flow for user-facing web apps. It prevents authorization code interception attacks that plagued the original Authorization Code flow.
|
|
14
|
+
|
|
15
|
+
**PKCE flow:**
|
|
16
|
+
1. Client generates a cryptographically random `code_verifier` (43–128 chars)
|
|
17
|
+
2. Client derives `code_challenge = BASE64URL(SHA256(code_verifier))`
|
|
18
|
+
3. Client redirects user to authorization server with `code_challenge` and `code_challenge_method=S256`
|
|
19
|
+
4. User authenticates with the identity provider
|
|
20
|
+
5. Authorization server redirects back with `code` in the query string
|
|
21
|
+
6. Client exchanges `code` + `code_verifier` for tokens — the server verifies the challenge hash
|
|
22
|
+
7. Without the original `code_verifier`, a stolen `code` is useless
|
|
23
|
+
|
|
24
|
+
Never store the `code_verifier` in `localStorage` — use `sessionStorage` or in-memory. The code exchange must happen from your backend (BFF pattern) to avoid exposing `client_secret` in browser code.
|
|
25
|
+
|
|
26
|
+
### Cookie Security Attributes
|
|
27
|
+
|
|
28
|
+
Every session cookie and auth cookie must have all four attributes correctly configured:
|
|
29
|
+
|
|
30
|
+
- **HttpOnly** — prevents JavaScript access, blocking XSS-based token theft
|
|
31
|
+
- **Secure** — restricts transmission to HTTPS, preventing network interception
|
|
32
|
+
- **SameSite=Strict** — cookie not sent on any cross-site request, preventing CSRF
|
|
33
|
+
- **SameSite=Lax** — cookie sent on top-level navigations only; use when cross-site POST is never needed but external links should work
|
|
34
|
+
- **`__Host-` prefix** — enforces Secure + path=/ + no Domain; prevents subdomain hijacking
|
|
35
|
+
|
|
36
|
+
Use `SameSite=Strict` for session cookies. If your app needs to accept inbound cross-site navigation with the user's session (e.g., linking from a third-party site into an authenticated page), use `Lax`.
|
|
37
|
+
|
|
38
|
+
### Passkey / WebAuthn Implementation
|
|
39
|
+
|
|
40
|
+
WebAuthn is the W3C standard for hardware-backed authentication. Passkeys are synced WebAuthn credentials — the user registers once and the credential is available on all their devices via iCloud Keychain or Google Password Manager.
|
|
41
|
+
|
|
42
|
+
**Why passkeys matter:**
|
|
43
|
+
- No password to phish, leak, or forget
|
|
44
|
+
- Phishing-resistant by design — the credential is bound to the origin URL
|
|
45
|
+
- Biometric authentication without biometric data leaving the device
|
|
46
|
+
- Major platform support as of 2023 (iOS 16+, macOS Ventura+, Android 9+, Windows 11)
|
|
47
|
+
|
|
48
|
+
**Registration flow:** `navigator.credentials.create()` with a challenge from your server → user authenticates with biometric/PIN → store the public key and credential ID on your server.
|
|
49
|
+
|
|
50
|
+
**Authentication flow:** `navigator.credentials.get()` with a challenge → WebAuthn assertion → verify signature on server using stored public key.
|
|
51
|
+
|
|
52
|
+
### Social Login Integration
|
|
53
|
+
|
|
54
|
+
Social login (Google, GitHub, Apple, etc.) delegates authentication to a trusted identity provider. The implementation pattern:
|
|
55
|
+
|
|
56
|
+
1. Redirect to provider OAuth endpoint (with PKCE)
|
|
57
|
+
2. Provider redirects back with `code`
|
|
58
|
+
3. Backend exchanges `code` for `id_token` + `access_token`
|
|
59
|
+
4. Verify `id_token` signature against provider's public keys (JWKS endpoint)
|
|
60
|
+
5. Extract user identity (`sub`, `email`, `name`) from verified token
|
|
61
|
+
6. Look up or create user record; issue your app's session
|
|
62
|
+
|
|
63
|
+
**Account linking:** The same email across different providers should map to the same user account. Store `provider:sub` pairs linked to a single user record to handle this.
|
|
64
|
+
|
|
65
|
+
## Deep Guidance
|
|
66
|
+
|
|
67
|
+
### PKCE Implementation
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// PKCE utilities — run in browser before redirect
|
|
71
|
+
async function generatePKCE() {
|
|
72
|
+
// Generate cryptographically random code_verifier
|
|
73
|
+
const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
74
|
+
|
|
75
|
+
// Derive code_challenge via SHA-256
|
|
76
|
+
const codeChallenge = base64URLEncode(
|
|
77
|
+
new Uint8Array(
|
|
78
|
+
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return { codeVerifier, codeChallenge };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function base64URLEncode(buffer: Uint8Array): string {
|
|
86
|
+
return btoa(String.fromCharCode(...buffer))
|
|
87
|
+
.replace(/\+/g, '-')
|
|
88
|
+
.replace(/\//g, '_')
|
|
89
|
+
.replace(/=/g, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Initiate login
|
|
93
|
+
async function initiateOAuthLogin(provider: string) {
|
|
94
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
95
|
+
const state = base64URLEncode(crypto.getRandomValues(new Uint8Array(16)));
|
|
96
|
+
|
|
97
|
+
// Store verifier and state — sessionStorage, never localStorage
|
|
98
|
+
sessionStorage.setItem('pkce_verifier', codeVerifier);
|
|
99
|
+
sessionStorage.setItem('oauth_state', state);
|
|
100
|
+
|
|
101
|
+
const params = new URLSearchParams({
|
|
102
|
+
client_id: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
|
|
103
|
+
redirect_uri: `${window.location.origin}/auth/callback`,
|
|
104
|
+
response_type: 'code',
|
|
105
|
+
scope: 'openid email profile',
|
|
106
|
+
code_challenge: codeChallenge,
|
|
107
|
+
code_challenge_method: 'S256',
|
|
108
|
+
state,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
window.location.href = `${OAUTH_AUTHORIZATION_ENDPOINT}?${params}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle callback
|
|
115
|
+
async function handleOAuthCallback(searchParams: URLSearchParams) {
|
|
116
|
+
const code = searchParams.get('code');
|
|
117
|
+
const returnedState = searchParams.get('state');
|
|
118
|
+
|
|
119
|
+
// Verify state to prevent CSRF
|
|
120
|
+
const savedState = sessionStorage.getItem('oauth_state');
|
|
121
|
+
if (!savedState || returnedState !== savedState) {
|
|
122
|
+
throw new Error('State mismatch — possible CSRF attack');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const codeVerifier = sessionStorage.getItem('pkce_verifier');
|
|
126
|
+
sessionStorage.removeItem('pkce_verifier');
|
|
127
|
+
sessionStorage.removeItem('oauth_state');
|
|
128
|
+
|
|
129
|
+
// Exchange code + verifier for tokens (via your backend)
|
|
130
|
+
return fetch('/api/auth/callback', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
body: JSON.stringify({ code, codeVerifier }),
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### WebAuthn Passkey Registration
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Register a new passkey
|
|
142
|
+
async function registerPasskey(userId: string, username: string) {
|
|
143
|
+
// 1. Get challenge from server
|
|
144
|
+
const { challenge, rpId } = await api.getRegistrationChallenge();
|
|
145
|
+
|
|
146
|
+
// 2. Create credential
|
|
147
|
+
const credential = await navigator.credentials.create({
|
|
148
|
+
publicKey: {
|
|
149
|
+
challenge: base64URLDecode(challenge),
|
|
150
|
+
rp: { name: 'My App', id: rpId },
|
|
151
|
+
user: {
|
|
152
|
+
id: new TextEncoder().encode(userId),
|
|
153
|
+
name: username,
|
|
154
|
+
displayName: username,
|
|
155
|
+
},
|
|
156
|
+
pubKeyCredParams: [
|
|
157
|
+
{ alg: -7, type: 'public-key' }, // ES256 (most supported)
|
|
158
|
+
{ alg: -257, type: 'public-key' }, // RS256 (Windows Hello fallback)
|
|
159
|
+
],
|
|
160
|
+
authenticatorSelection: {
|
|
161
|
+
residentKey: 'required', // Required for passkeys
|
|
162
|
+
userVerification: 'required', // Biometric/PIN required
|
|
163
|
+
},
|
|
164
|
+
attestation: 'none', // No attestation for consumer apps
|
|
165
|
+
},
|
|
166
|
+
}) as PublicKeyCredential;
|
|
167
|
+
|
|
168
|
+
// 3. Send response to server for verification and storage
|
|
169
|
+
const response = credential.response as AuthenticatorAttestationResponse;
|
|
170
|
+
return api.completeRegistration({
|
|
171
|
+
credentialId: base64URLEncode(credential.rawId),
|
|
172
|
+
clientDataJSON: base64URLEncode(response.clientDataJSON),
|
|
173
|
+
attestationObject: base64URLEncode(response.attestationObject),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Use the `@simplewebauthn/server` library on the backend for verification. It handles the complex binary parsing, signature verification, and CBOR decoding correctly.
|
|
179
|
+
|
|
180
|
+
### CSRF Protection
|
|
181
|
+
|
|
182
|
+
With `SameSite=Strict` cookies, CSRF is largely mitigated for standard same-origin flows. However, for APIs that accept `application/json` content type (which browsers can't trigger via forms or `<img>` tags), the risk is already limited.
|
|
183
|
+
|
|
184
|
+
For applications that cannot use `SameSite=Strict` (e.g., cross-site embedded auth flows):
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Double-submit cookie pattern
|
|
188
|
+
// Server sets a CSRF token in a readable cookie (not HttpOnly)
|
|
189
|
+
// Client reads the cookie and sends it as a request header
|
|
190
|
+
// Server validates that header matches cookie value
|
|
191
|
+
|
|
192
|
+
// Server: set CSRF cookie on login
|
|
193
|
+
res.cookie('csrf-token', generateCSRFToken(), {
|
|
194
|
+
httpOnly: false, // Must be readable by JavaScript
|
|
195
|
+
secure: true,
|
|
196
|
+
sameSite: 'lax',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Client: inject header on every mutating request
|
|
200
|
+
apiClient.defaults.headers.common['X-CSRF-Token'] = getCookie('csrf-token');
|
|
201
|
+
|
|
202
|
+
// Server: validate on every mutation
|
|
203
|
+
app.use((req, res, next) => {
|
|
204
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
205
|
+
if (req.headers['x-csrf-token'] !== req.cookies['csrf-token']) {
|
|
206
|
+
return res.status(403).json({ error: 'CSRF token mismatch' });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
next();
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Auth State Management in React
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// Auth context — single source of truth for authentication state
|
|
217
|
+
interface AuthState {
|
|
218
|
+
user: User | null;
|
|
219
|
+
isLoading: boolean;
|
|
220
|
+
isAuthenticated: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Use React Query to manage auth state (benefits from cache, refetch on focus)
|
|
224
|
+
export function useAuth() {
|
|
225
|
+
const { data: user, isLoading } = useQuery({
|
|
226
|
+
queryKey: ['auth', 'me'],
|
|
227
|
+
queryFn: () => api.getCurrentUser(),
|
|
228
|
+
retry: false, // Don't retry 401s
|
|
229
|
+
staleTime: Infinity, // Only refetch manually or on window focus
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
user: user ?? null,
|
|
234
|
+
isLoading,
|
|
235
|
+
isAuthenticated: !!user,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Route protection
|
|
240
|
+
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
241
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
242
|
+
const router = useRouter();
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!isLoading && !isAuthenticated) {
|
|
246
|
+
router.replace(`/login?returnTo=${encodeURIComponent(router.asPath)}`);
|
|
247
|
+
}
|
|
248
|
+
}, [isAuthenticated, isLoading]);
|
|
249
|
+
|
|
250
|
+
if (isLoading) return <PageSpinner />;
|
|
251
|
+
if (!isAuthenticated) return null;
|
|
252
|
+
return <>{children}</>;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Store the return URL before redirecting to login so users land on the page they were trying to reach after authentication.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-app-conventions
|
|
3
|
+
description: Component naming, file colocation, state management patterns, custom hook conventions, and CSS methodology selection for web apps
|
|
4
|
+
topics: [web-app, conventions, components, state-management, css, hooks]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Conventions are the difference between a codebase where any engineer can find and modify any file in minutes versus one where only the original author can navigate it confidently. Establish these once, enforce them via linting and code review, and never let individual preferences override them during the project lifecycle.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Web app conventions establish consistent patterns for component naming (PascalCase, domain-specific), file colocation (feature-based over type-based), state management (local, server, global, URL tiers), custom hook conventions, and CSS methodology selection. Choose one approach per area and enforce it via linting.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Component Naming
|
|
16
|
+
|
|
17
|
+
- **PascalCase for components**: `UserProfile`, `NavigationMenu`, `CheckoutSummary`. This is universal across React, Vue (single-file components), and Svelte.
|
|
18
|
+
- **Descriptive, domain-specific names**: `ProductCard` not `Card`, `AuthModal` not `Modal`, `OrderStatusBadge` not `Badge`. Generic names cause naming conflicts as the codebase grows.
|
|
19
|
+
- **Suffix by type where ambiguous**: `UserList` (renders list), `UserListItem` (renders one item), `UserListContainer` (fetches data and passes to list). Be consistent — never mix `Container`/`Provider`/`Wrapper` conventions across the codebase.
|
|
20
|
+
- **Page components**: `LoginPage`, `DashboardPage`, `CheckoutPage` — suffix with `Page` to distinguish from reusable components.
|
|
21
|
+
|
|
22
|
+
### File Colocation
|
|
23
|
+
|
|
24
|
+
Colocate files that change together. The industry has converged on feature-based colocation over type-based grouping:
|
|
25
|
+
|
|
26
|
+
**Preferred (feature-colocated):**
|
|
27
|
+
```
|
|
28
|
+
features/
|
|
29
|
+
user-profile/
|
|
30
|
+
UserProfile.tsx
|
|
31
|
+
UserProfile.test.tsx
|
|
32
|
+
UserProfile.stories.tsx # Storybook stories
|
|
33
|
+
useUserProfile.ts # Feature-specific hook
|
|
34
|
+
user-profile.types.ts # Feature-specific types
|
|
35
|
+
index.ts # Barrel export
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Avoid (type-segregated):**
|
|
39
|
+
```
|
|
40
|
+
components/UserProfile.tsx
|
|
41
|
+
hooks/useUserProfile.ts
|
|
42
|
+
types/userProfile.ts
|
|
43
|
+
tests/UserProfile.test.tsx
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The type-segregated structure requires navigating four directories to understand one feature. It also means deleting a feature requires hunting across the entire directory tree.
|
|
47
|
+
|
|
48
|
+
**Exception**: Truly shared utilities (`lib/`, `utils/`, `hooks/`) that have no single feature owner belong in a flat shared directory, not inside any feature.
|
|
49
|
+
|
|
50
|
+
### State Management Patterns
|
|
51
|
+
|
|
52
|
+
Choose the state tier that matches the problem. Do not reach for Redux when `useState` is sufficient:
|
|
53
|
+
|
|
54
|
+
- **Local state (`useState`, `useReducer`)**: UI-only state that no sibling or parent needs (open/closed, form draft, hover state). Keep it local.
|
|
55
|
+
- **Server state (React Query, SWR, RTK Query)**: Data fetched from an API. Use a server-state library — it handles caching, deduplication, background refresh, and error states for free. Do not put API data in a global Redux store unless you have a compelling reason.
|
|
56
|
+
- **Global client state (Zustand, Jotai, Redux Toolkit)**: Shared UI state that multiple distant components need (authenticated user, theme, cart items, notification queue). Use sparingly. Every piece of global state is a coupling point.
|
|
57
|
+
- **URL state**: Sort order, filters, pagination, tab selection — anything that should survive a page reload or be shareable via link. Use `useSearchParams` or a URL state library. This is underused; most "global state" is actually URL state in disguise.
|
|
58
|
+
|
|
59
|
+
### Custom Hook Conventions
|
|
60
|
+
|
|
61
|
+
- Name all custom hooks with the `use` prefix: `useAuth`, `usePagination`, `useDebounce`.
|
|
62
|
+
- Single responsibility: one hook does one thing. `useUserProfile` fetches and returns user data. It does not also handle form state.
|
|
63
|
+
- Return stable references: memoize returned objects and callbacks to prevent unnecessary re-renders in consumers.
|
|
64
|
+
- Keep hooks pure from the component's perspective: no side effects that the hook consumer cannot control. Accept options objects for configuration rather than hardcoding behavior.
|
|
65
|
+
- Co-locate the hook with the feature that owns it. Move to `lib/hooks/` only when reused across three or more features.
|
|
66
|
+
|
|
67
|
+
### CSS Methodology Selection
|
|
68
|
+
|
|
69
|
+
Choose one methodology and enforce it. Mixing methodologies creates chaos:
|
|
70
|
+
|
|
71
|
+
- **Tailwind CSS**: Best for teams that want to move fast and avoid naming bikeshedding. Excellent with design tokens. Downsides: verbose JSX, requires purging to avoid large CSS bundles, learning curve for custom designs.
|
|
72
|
+
- **CSS Modules**: Best for teams that prefer semantic class names and encapsulation without a utility framework. No runtime, good TypeScript support via `typed-css-modules`.
|
|
73
|
+
- **CSS-in-JS (styled-components, Emotion)**: Best for highly dynamic styles or design systems with programmatic theming. Downsides: runtime cost, hydration complexity with SSR.
|
|
74
|
+
- **Vanilla CSS with custom properties**: Best for simple apps or teams who want zero abstraction. Use a consistent BEM-like naming convention.
|
|
75
|
+
|
|
76
|
+
### Enforcing Conventions with Tooling
|
|
77
|
+
|
|
78
|
+
Conventions without enforcement degrade immediately. Configure linting to automate the common cases:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
// ESLint rules for React component conventions
|
|
82
|
+
{
|
|
83
|
+
"rules": {
|
|
84
|
+
"react/jsx-pascal-case": "error", // Enforce PascalCase for JSX components
|
|
85
|
+
"import/no-default-export": "warn", // Prefer named exports (easier to find with search)
|
|
86
|
+
"@typescript-eslint/naming-convention": [
|
|
87
|
+
"error",
|
|
88
|
+
{ "selector": "function", "format": ["camelCase", "PascalCase"] },
|
|
89
|
+
{ "selector": "variable", "format": ["camelCase", "UPPER_CASE", "PascalCase"] }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Add a Storybook or component documentation requirement: every shared component must have a story before it can be merged. This enforces composability — if you cannot write a story for it in isolation, the component is too coupled.
|
|
96
|
+
|
|
97
|
+
### State Management Decision Flowchart
|
|
98
|
+
|
|
99
|
+
When choosing state location, answer in order:
|
|
100
|
+
|
|
101
|
+
1. Is this state only used by one component? → `useState`
|
|
102
|
+
2. Is this state fetched from a server? → React Query / SWR
|
|
103
|
+
3. Can this state live in the URL? → `useSearchParams` / URL state
|
|
104
|
+
4. Is this state shared across multiple distant routes? → Zustand / Jotai / Redux Toolkit
|
|
105
|
+
5. Is this state needed server-side during SSR? → Context with SSR-safe initialization
|
|
106
|
+
|
|
107
|
+
Resist the urge to centralize all state globally. Distributed state is easier to delete when features are removed and easier to reason about during debugging.
|
|
108
|
+
|
|
109
|
+
### Hook Testing Conventions
|
|
110
|
+
|
|
111
|
+
Test hooks in isolation using `renderHook` from React Testing Library. This keeps hook logic testable without mounting a component:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// Good: test hook behavior directly
|
|
115
|
+
const { result } = renderHook(() => useDebounce("search", 300));
|
|
116
|
+
expect(result.current).toBe("");
|
|
117
|
+
act(() => { jest.advanceTimersByTime(300); });
|
|
118
|
+
expect(result.current).toBe("search");
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Never test a hook only via its parent component — that couples the hook test to the component's rendering and makes failures harder to diagnose.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-app-data-patterns
|
|
3
|
+
description: Client-side caching, optimistic updates, real-time sync, pagination strategies, form state management, and file upload patterns
|
|
4
|
+
topics: [web-app, data-fetching, caching, react-query, swr, pagination, forms, file-upload]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Data management in web applications spans from simple fetch-and-display to complex real-time collaborative state. The wrong patterns here produce stale UIs, conflicting updates, degraded performance under load, and frustrated users. The right patterns make applications feel instantaneous even over slow networks — by understanding the difference between server state, client state, and ephemeral UI state, and applying the appropriate tool to each.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
### Server State vs Client State
|
|
12
|
+
|
|
13
|
+
The most important conceptual distinction in modern web data management:
|
|
14
|
+
|
|
15
|
+
- **Server state** — data owned by the server, shared across clients, and potentially stale as soon as it's fetched: user profiles, product listings, feed items. Use a data-fetching library (React Query, SWR) to manage this.
|
|
16
|
+
- **Client state** — data owned by the current user's session, not persisted to the server: currently selected tab, sidebar open/closed, in-progress form data. Use React local state or Zustand/Jotai/Redux.
|
|
17
|
+
|
|
18
|
+
Mixing these causes anti-patterns: putting server data in Redux, or making API calls from useEffect without caching. Libraries like React Query exist precisely because server state needs cache management, background refetching, deduplication, and stale-while-revalidate semantics that generic state managers don't provide.
|
|
19
|
+
|
|
20
|
+
### SWR vs React Query
|
|
21
|
+
|
|
22
|
+
Both implement stale-while-revalidate caching for server state:
|
|
23
|
+
|
|
24
|
+
| Concern | SWR | React Query (TanStack Query) |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Bundle size | ~6 KB | ~13 KB |
|
|
27
|
+
| Mutations | Manual invalidation | Built-in `useMutation` + auto-invalidation |
|
|
28
|
+
| Infinite scroll | `useSWRInfinite` | `useInfiniteQuery` |
|
|
29
|
+
| Optimistic updates | Manual | First-class via `onMutate` + rollback |
|
|
30
|
+
| DevTools | None built-in | Excellent DevTools panel |
|
|
31
|
+
| Best for | Read-heavy apps, minimal mutations | Apps with complex mutation flows |
|
|
32
|
+
|
|
33
|
+
**Rule:** Use React Query for most production applications. Use SWR for read-heavy dashboards where its simplicity is a net benefit.
|
|
34
|
+
|
|
35
|
+
### Optimistic Updates
|
|
36
|
+
|
|
37
|
+
Update the UI before the server confirms the mutation. If the server rejects, roll back.
|
|
38
|
+
|
|
39
|
+
Optimistic updates are appropriate when: the mutation has a very high success rate, the latency is noticeable, and the rollback experience is not confusing. They are not appropriate when: the server-side result is unpredictable (e.g., a bid on an auction where you may lose).
|
|
40
|
+
|
|
41
|
+
### Pagination Strategies
|
|
42
|
+
|
|
43
|
+
**Cursor-based pagination** (preferred):
|
|
44
|
+
- Each page returns a `nextCursor` opaque token; the next request passes `?cursor=<token>`
|
|
45
|
+
- Stable across concurrent inserts/deletes — no items skipped or duplicated
|
|
46
|
+
- Required for infinite scroll (offset pagination breaks on live data)
|
|
47
|
+
- Cannot jump to arbitrary page numbers
|
|
48
|
+
|
|
49
|
+
**Offset-based pagination**:
|
|
50
|
+
- `?page=3&limit=20` or `?offset=40&limit=20`
|
|
51
|
+
- Supports "jump to page N" UI
|
|
52
|
+
- Items shift when rows are inserted/deleted — users see duplicates or skipped items on live data
|
|
53
|
+
- Appropriate for admin tables with infrequent writes and explicit page navigation
|
|
54
|
+
|
|
55
|
+
### Real-Time Data Sync
|
|
56
|
+
|
|
57
|
+
Three patterns in increasing complexity:
|
|
58
|
+
1. **Polling** — simplest, least efficient: re-fetch every N seconds. Acceptable for dashboards that update every few minutes.
|
|
59
|
+
2. **Server-Sent Events (SSE)** — server pushes updates to client over a single long-lived HTTP connection. One-directional. Excellent for notifications, activity feeds, live counters. No WebSocket negotiation overhead.
|
|
60
|
+
3. **WebSocket** — bidirectional full-duplex. Required for chat, collaborative editing, live games. Higher complexity: connection management, reconnection, presence.
|
|
61
|
+
|
|
62
|
+
## Deep Guidance
|
|
63
|
+
|
|
64
|
+
### React Query Patterns
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// 1. Standard query with stale time
|
|
68
|
+
const { data: posts, isLoading, error } = useQuery({
|
|
69
|
+
queryKey: ['posts', { authorId, page }],
|
|
70
|
+
queryFn: () => fetchPosts({ authorId, page }),
|
|
71
|
+
staleTime: 5 * 60 * 1000, // Data considered fresh for 5 minutes
|
|
72
|
+
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes after unmount
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 2. Optimistic mutation with rollback
|
|
76
|
+
const queryClient = useQueryClient();
|
|
77
|
+
|
|
78
|
+
const likeMutation = useMutation({
|
|
79
|
+
mutationFn: (postId: string) => api.likePost(postId),
|
|
80
|
+
|
|
81
|
+
onMutate: async (postId) => {
|
|
82
|
+
// Cancel any in-flight refetches that would overwrite optimistic update
|
|
83
|
+
await queryClient.cancelQueries({ queryKey: ['posts'] });
|
|
84
|
+
|
|
85
|
+
// Snapshot the previous value
|
|
86
|
+
const previousPosts = queryClient.getQueryData(['posts']);
|
|
87
|
+
|
|
88
|
+
// Optimistically update
|
|
89
|
+
queryClient.setQueryData(['posts'], (old: Post[]) =>
|
|
90
|
+
old.map(p => p.id === postId ? { ...p, likeCount: p.likeCount + 1, likedByMe: true } : p)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Return context for rollback
|
|
94
|
+
return { previousPosts };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
onError: (error, postId, context) => {
|
|
98
|
+
// Roll back to snapshot on failure
|
|
99
|
+
queryClient.setQueryData(['posts'], context?.previousPosts);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
onSettled: () => {
|
|
103
|
+
// Always refetch to sync with server truth
|
|
104
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 3. Infinite scroll
|
|
109
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
|
110
|
+
queryKey: ['feed'],
|
|
111
|
+
queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam }),
|
|
112
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
113
|
+
initialPageParam: undefined as string | undefined,
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### File Upload Pattern
|
|
118
|
+
|
|
119
|
+
Uploads require a different flow from standard JSON mutations: multipart form data, progress tracking, and (for large files) direct-to-storage upload via presigned URLs.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// PATTERN: Presigned URL upload (bypasses app server, goes direct to S3/GCS)
|
|
123
|
+
async function uploadFile(file: File, onProgress: (pct: number) => void) {
|
|
124
|
+
// Step 1: Get presigned URL from app server
|
|
125
|
+
const { uploadUrl, fileKey } = await api.getUploadUrl({
|
|
126
|
+
filename: file.name,
|
|
127
|
+
contentType: file.type,
|
|
128
|
+
size: file.size,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Step 2: Upload directly to storage (no app server in the critical path)
|
|
132
|
+
await new Promise<void>((resolve, reject) => {
|
|
133
|
+
const xhr = new XMLHttpRequest();
|
|
134
|
+
xhr.open('PUT', uploadUrl);
|
|
135
|
+
xhr.setRequestHeader('Content-Type', file.type);
|
|
136
|
+
|
|
137
|
+
xhr.upload.onprogress = (event) => {
|
|
138
|
+
if (event.lengthComputable) {
|
|
139
|
+
onProgress(Math.round((event.loaded / event.total) * 100));
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
xhr.onload = () => xhr.status === 200 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
|
|
144
|
+
xhr.onerror = () => reject(new Error('Network error during upload'));
|
|
145
|
+
xhr.send(file);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Step 3: Notify app server that upload is complete
|
|
149
|
+
return api.confirmUpload({ fileKey });
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Presigned URL uploads: never proxy large files through your app server. A 100 MB upload through an app server occupies a Node.js worker for the entire transfer duration.
|
|
154
|
+
|
|
155
|
+
### Form State Management
|
|
156
|
+
|
|
157
|
+
For most forms: `react-hook-form` + Zod schema validation. This pattern avoids controlled component re-renders (critical for large forms), provides schema-driven validation, and integrates cleanly with TypeScript.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { useForm } from 'react-hook-form';
|
|
161
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
162
|
+
import { z } from 'zod';
|
|
163
|
+
|
|
164
|
+
const profileSchema = z.object({
|
|
165
|
+
displayName: z.string().min(2).max(50),
|
|
166
|
+
email: z.string().email(),
|
|
167
|
+
bio: z.string().max(500).optional(),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
type ProfileForm = z.infer<typeof profileSchema>;
|
|
171
|
+
|
|
172
|
+
function ProfileEditor() {
|
|
173
|
+
const { register, handleSubmit, formState: { errors, isDirty, isSubmitting } } = useForm<ProfileForm>({
|
|
174
|
+
resolver: zodResolver(profileSchema),
|
|
175
|
+
defaultValues: { displayName: user.displayName, email: user.email },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const onSubmit = async (data: ProfileForm) => {
|
|
179
|
+
await updateProfile(data);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
184
|
+
<input {...register('displayName')} />
|
|
185
|
+
{errors.displayName && <span>{errors.displayName.message}</span>}
|
|
186
|
+
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
|
|
187
|
+
</form>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Reserve heavier solutions (Formik, final-form) only for highly dynamic form generation requirements. For most product forms, react-hook-form is sufficient and faster.
|
|
193
|
+
|
|
194
|
+
### Cursor Pagination Implementation
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Server-side cursor pagination (PostgreSQL + Prisma)
|
|
198
|
+
async function getPaginatedPosts(cursor?: string, limit = 20) {
|
|
199
|
+
const posts = await prisma.post.findMany({
|
|
200
|
+
take: limit + 1, // Fetch one extra to determine if there's a next page
|
|
201
|
+
...(cursor && {
|
|
202
|
+
cursor: { id: cursor },
|
|
203
|
+
skip: 1, // Skip the cursor item itself
|
|
204
|
+
}),
|
|
205
|
+
orderBy: { createdAt: 'desc' },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const hasNextPage = posts.length > limit;
|
|
209
|
+
const items = hasNextPage ? posts.slice(0, -1) : posts;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
items,
|
|
213
|
+
nextCursor: hasNextPage ? items[items.length - 1].id : null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Always fetch `limit + 1` to check for next page existence without an extra COUNT query.
|