> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
# Frontend Architecture Patterns
> Frontend has same quality bar as backend: typed, tested, structured.
> **Companion contract**: For the **visual** quality bar — how to render Section 6's layout tokens with real library primitives, how to theme the app from the plan's palette, and the four-state coverage gate — read [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md). This file covers structural patterns (types, hooks, services, error handling); the quality bar covers what each region must actually render.
---
## Core Principle
**Frontend is not second-class.** Consumes shared types package, has own test gate, follows consistent patterns for data fetching, error handling, component structure. **Layout tokens from the plan are intent, not literal `
`s** — see [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md).
---
## Rule: Consume Shared Types — No `any`
Shared package exists so frontend doesn't reinvent types. Every entity, request, response MUST use shared type.
```typescript
// ❌ BAD — defeats the purpose of the shared types package
const [user, setUser] = useState(null);
const [photos, setPhotos] = useState([]);
const [error, setError] = useState(null);
// ✅ GOOD — imports from shared package
import type { PublicUser, Photo } from 'scrapbook-shared';
const [user, setUser] = useState(null);
const [photos, setPhotos] = useState([]);
const [error, setError] = useState(null);
```
**Enforcement**: If shared package defines a type for an entity or API response, frontend MUST import and use it. No inline `any` or ad-hoc interfaces duplicating shared definitions.
---
## Rule: API Client Must Be Fully Typed
API client module MUST use shared request/response types for every endpoint. Client is contract boundary between frontend and backend.
```typescript
// api/client.ts
import type {
AuthResponse,
MeResponse,
LoginRequest,
RegisterRequest,
ListPhotosResponse,
PhotoResponse,
ErrorResponse,
} from 'app-shared';
async function request(path: string, options: RequestInit = {}): Promise {
const token = localStorage.getItem('token');
const headers: Record = {
...((options.headers as Record) || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`/api${path}`, { ...options, headers });
if (!res.ok) {
const error: ErrorResponse = await res.json();
throw new ApiError(res.status, error.error.code, error.error.message);
}
return res.json() as Promise;
}
// Every endpoint is fully typed — no `any`
export const api = {
login: (data: LoginRequest) =>
request('POST', '/auth/login', { body: JSON.stringify(data) }),
register: (data: RegisterRequest) =>
request('POST', '/auth/register', { body: JSON.stringify(data) }),
getMe: () =>
request('GET', '/auth/me'),
listPhotos: (limit = 20, offset = 0) =>
request('GET', `/photos?limit=${limit}&offset=${offset}`),
uploadPhoto: (file: File) => {
const form = new FormData();
form.append('file', file);
return request('POST', '/photos', { body: form });
},
deletePhoto: (id: string) =>
request<{ success: true }>('DELETE', `/photos/${id}`),
};
```
---
## Rule: Error Handling in Custom Hooks
Every async op in custom hook MUST catch errors and update error state. Optimistic updates MUST roll back on failure.
```typescript
// ❌ BAD — if API call fails, UI state is inconsistent
const deletePhoto = useCallback(async (id: string) => {
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic removal
await api.deletePhoto(id); // if this throws, photo is gone from UI but not from DB
}, []);
// ✅ GOOD — rollback on failure
const deletePhoto = useCallback(async (id: string) => {
const previousPhotos = photos;
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic
try {
await api.deletePhoto(id);
} catch (err) {
setPhotos(previousPhotos); // rollback
setError(err instanceof Error ? err.message : 'Failed to delete photo');
}
}, [photos]);
```
### Silent Error Swallowing is a Bug
```typescript
// ❌ BAD — catches ALL errors including network failures, 500s
useEffect(() => {
api.getCouple().then(setCouple).catch(() => {
// "Not paired yet — that's fine"
});
}, []);
// ✅ GOOD — only ignore expected "not found" errors
useEffect(() => {
api.getCouple()
.then(setCouple)
.catch((err) => {
if (err instanceof ApiError && err.status === 404) {
// Not paired yet — expected state
return;
}
setError('Failed to load couple info');
logger.error('Unexpected error loading couple', err);
});
}, []);
```
---
## Rule: No Destructive Actions Without Confirmation
Any action that permanently deletes or irreversibly modifies data MUST require user confirmation before executing.
```typescript
// ❌ BAD — one mis-click deletes a photo permanently
// ✅ GOOD — confirmation required
```
For better UX, consider custom confirmation dialog instead of `window.confirm`.
---
## Pattern: Extract Shared Form Components
When 2+ pages share >50% structure, extract shared component. Common with auth forms.
```typescript
// ❌ BAD — LoginPage and RegisterPage are 90% identical
// LoginPage.tsx: 80 lines of form, state, error handling, submit
// RegisterPage.tsx: 85 lines of nearly identical code
// ✅ GOOD — shared AuthForm component
interface AuthFormProps {
title: string;
fields: { name: string; type: string; label: string; required?: boolean }[];
onSubmit: (data: Record) => Promise;
submitLabel: string;
altLink: { text: string; to: string };
}
function AuthForm({ title, fields, onSubmit, submitLabel, altLink }: AuthFormProps) {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const formData = new FormData(e.target as HTMLFormElement);
const data = Object.fromEntries(formData) as Record;
await onSubmit(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
{title}
{error &&
{error}
}
{altLink.text}
);
}
```
---
## Pattern: File Upload Validation (Client-Side)
File uploads MUST validate client-side before sending to server. Provides immediate feedback, prevents wasted bandwidth.
```typescript
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
function validateFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return `Invalid file type: ${file.type}. Allowed: JPEG, PNG, WebP, GIF`;
}
if (file.size > MAX_FILE_SIZE) {
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`;
}
return null; // valid
}
// Usage in upload handler
const handleUpload = async (file: File) => {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
// ... proceed with upload
};
```
**Server MUST also validate** — client-side validation is for UX, not security. Backend upload handler MUST independently check file size and type.
---
## Pattern: Four-State Data Pages
Every page fetching data MUST handle exactly four states:
```typescript
function PhotoGallery() {
const { photos, loading, error } = usePhotos();
// 1. Loading
if (loading) {
return