Component and type design for TypeScript + React code...
Component and type design for TypeScript + React applications. Use when planning new features or identifying need for new abstractions during refactoring.
Design clean, well-composed components and types that:
Default: Match existing codebase architecture (consistency is key).
Scan codebase structure:
src/{components,hooks,contexts,types}/... - Group by technical layersrc/{components,hooks}/... + src/pages/... - Shared layers + page-specific codesrc/features/[feature]/{components,hooks,types} - Group by featureDecision Flow:
src/features/[new-feature]/Layer-Based Structure (Recommended for most codebases):
src/
components/ # Reusable components only
Button.tsx
Input.tsx
Modal.tsx
Card.tsx
pages/ # Top-level views/pages (use components)
LoginPage.tsx
DashboardPage.tsx
UserProfilePage.tsx
hooks/ # Reusable hooks
useAuth.ts
useDebounce.ts
useLocalStorage.ts
contexts/ # Shared context providers
AuthContext.tsx
ThemeContext.tsx
types/ # Shared type definitions
auth.ts
user.ts
api/ # API client
authApi.ts
userApi.ts
Key Distinction:
components/ = Reusable UI components (Button, Input, Modal)pages/ or views/ = Top-level page components that compose reusable componentsHybrid Structure (Common in practice):
src/
components/ # Truly shared UI components
Button.tsx
Input.tsx
hooks/ # Truly shared hooks
useDebounce.ts
pages/ # Pages with co-located feature-specific code
auth/
LoginPage.tsx
components/LoginForm.tsx
hooks/useLoginForm.ts
dashboard/
DashboardPage.tsx
components/StatsWidget.tsx
Key Principle: Consistency over dogma. Match the existing structure unless there's a compelling reason to change.
See reference.md section #2 for detailed patterns.
Ask for each concept:
For primitives with validation (Email, UserId, Port):
Option A: Zod Schemas (Recommended)
import { z } from 'zod'
// Schema definition with validation
export const EmailSchema = z.string().email().min(1)
export const UserIdSchema = z.string().uuid()
// Extract type from schema
export type Email = z.infer<typeof EmailSchema>
export type UserId = z.infer<typeof UserIdSchema>
// Validation function
export function validateEmail(value: unknown): Email {
return EmailSchema.parse(value) // Throws on invalid
}
Option B: Branded Types (TypeScript)
// Brand for nominal typing
declare const __brand: unique symbol
type Brand<T, TBrand> = T & { [__brand]: TBrand }
export type Email = Brand<string, 'Email'>
export type UserId = Brand<string, 'UserId'>
// Validating constructor
export function createEmail(value: string): Email {
if (!value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
throw new Error('Invalid email format')
}
return value as Email
}
export function createUserId(value: string): UserId {
if (!value || value.length === 0) {
throw new Error('UserId cannot be empty')
}
return value as UserId
}
When to use which:
Composed types trust their parts ā never re-validate validated types:
// ā Re-validates after Zod parse
function createUser(email: Email, id: UserId) {
if (!email.includes('@')) { ... } // EmailSchema already validated this
}
// ā
Trusts validated types ā only adds own concerns
function createUser(email: Email, id: UserId) {
// Email and UserId are already validated ā no re-validation needed
return { email, id, createdAt: new Date() }
}
Component Types:
A. Presentational Components (Pure UI)
interface ButtonProps {
readonly label: string
readonly onClick: () => void
readonly disabled?: boolean
readonly variant?: 'primary' | 'secondary'
}
export function Button({
label,
onClick,
variant = 'primary',
disabled = false
}: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
)
}
B. Container Components (Logic + State)
import { EMPTY_STRING } from 'consts'
export function LoginContainer() {
const { login, isLoading, error } = useAuth()
const [email, setEmail] = useState(EMPTY_STRING)
const [password, setPassword] = useState(EMPTY_STRING)
const handleSubmit = async () => {
try {
const validEmail = EmailSchema.parse(email)
await login(validEmail, password)
} catch (error) {
// Handle error
}
}
return (
<LoginForm
email={email}
error={error}
isLoading={isLoading}
password={password}
onEmailChange={setEmail}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
/>
)
}
Extract reusable logic into custom hooks:
// Single responsibility: Form state management
export function useFormState<T>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const setValue = <K extends keyof T>(key: K, value: T[K]) => {
setValues(prev => ({ ...prev, [key]: value }))
setErrors(prev => ({ ...prev, [key]: undefined }))
}
const reset = () => {
setValues(initialValues)
setErrors({})
}
return { values, errors, setValue, setErrors, reset }
}
// Single responsibility: Data fetching
export function useUsers() {
const [users, setUsers] = useState<User[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true)
try {
const data = await api.getUsers()
setUsers(data)
} catch (err) {
setError(err as Error)
} finally {
setIsLoading(false)
}
}
fetchUsers()
}, [])
return { users, isLoading, error }
}
When state is needed across 3+ component levels:
interface AuthContextValue {
user: User | null
login: (email: Email, password: string) => Promise<void>
logout: () => Promise<void>
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: Email, password: string) => {
const user = await api.login(email, password)
setUser(user)
}
const logout = async () => {
await api.logout()
setUser(null)
}
const value = useMemo(
() => ({ user, login, logout, isAuthenticated: !!user }),
[user]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
Layer-based structure (most common):
src/
āāā components/
ā āāā LoginForm.tsx
ā āāā LoginForm.test.tsx
ā āāā RegisterForm.tsx
ā āāā RegisterForm.test.tsx
āāā hooks/
ā āāā useAuth.ts
ā āāā useAuth.test.ts
ā āāā useFormValidation.ts
ā āāā useFormValidation.test.ts
āāā contexts/
ā āāā AuthContext.tsx
ā āāā AuthContext.test.tsx
āāā types/
ā āāā auth.ts # Email, UserId, etc.
āāā api/
āāā authApi.ts # API calls
Hybrid structure (pages + shared layers):
src/
āāā components/ # Shared components
ā āāā Button.tsx
ā āāā Input.tsx
āāā hooks/ # Shared hooks
ā āāā useDebounce.ts
āāā pages/
ā āāā auth/
ā āāā LoginPage.tsx
ā āāā components/
ā ā āāā LoginForm.tsx # Page-specific
ā āāā hooks/
ā āāā useLoginForm.ts # Page-specific
āāā types/
āāā auth.ts
Feature-based structure (alternative):
src/features/auth/
āāā components/
ā āāā LoginForm.tsx
ā āāā RegisterForm.tsx
āāā hooks/
ā āāā useAuth.ts
ā āāā useFormValidation.ts
āāā context/
ā āāā AuthContext.tsx
āāā types.ts
āāā api.ts
Choose the structure that matches your existing codebase.
Check design against (see reference.md):
After design phase:
šØ DESIGN PLAN
Feature: User Authentication
Core Domain Types:
ā
Email (Zod schema) - RFC 5322 validation, used in login/register
ā
UserId (branded type) - Non-empty string, prevents invalid IDs
ā
User (interface) - { id: UserId, email: Email, name: string }
Components:
ā
LoginForm (Presentational)
Props: { email, password, onSubmit, isLoading, error }
Responsibility: UI only, no state
ā
LoginContainer (Container)
Responsibility: State management, form handling, validation
Uses: LoginForm, useAuth hook
ā
RegisterForm (Presentational)
Props: { formData, onSubmit, isLoading, errors }
Responsibility: UI only, no state
Custom Hooks:
ā
useAuth
Returns: { user, login, logout, isAuthenticated, isLoading }
Responsibility: Auth operations and state
ā
useFormValidation
Returns: { values, errors, setValue, validate, reset }
Responsibility: Form state and validation logic
Context:
ā
AuthContext
Provides: { user, login, logout, isAuthenticated }
Used by: Protected routes, user menu, profile pages
Reason: Auth state needed across entire app
Feature Structure:
š src/
āāā components/ # Reusable components
ā āāā LoginForm.tsx
ā āāā LoginForm.test.tsx
ā āāā RegisterForm.tsx
ā āāā RegisterForm.test.tsx
āāā pages/ # Top-level pages
ā āāā LoginPage.tsx
ā āāā RegisterPage.tsx
āāā hooks/ # Reusable hooks
ā āāā useAuth.ts
ā āāā useAuth.test.ts
ā āāā useFormValidation.ts
ā āāā useFormValidation.test.ts
āāā contexts/ # Shared contexts
ā āāā AuthContext.tsx
ā āāā AuthContext.test.tsx
āāā types/ # Type definitions
ā āāā auth.ts
āāā api/ # API client
āāā authApi.ts
Design Decisions:
- Email and UserId as validated types prevent runtime errors
- Zod for Email (form validation), branded type for UserId (type safety)
- LoginForm is reusable component, LoginPage composes it
- useAuth hook encapsulates auth logic for reuse across components
- AuthContext provides auth state to avoid prop drilling
- Layer-based structure: components/ for reusable, pages/ for top-level views
Integration Points:
- Consumed by: App routes, protected route wrapper, user menu
- Depends on: API client, token storage
- Events: User login/logout events for analytics
Next Steps:
1. Create types with validation (Zod schemas + branded types)
2. Write tests for types and hooks (React Testing Library)
3. Implement presentational components (LoginForm)
4. Implement container components (LoginContainer)
5. Add context provider (AuthContext)
6. Integration tests for full flows
Ready to implement? Use @testing skill for test structure (works with Jest, Vitest, etc.).
See reference.md for detailed principles:
Before writing code, ask:
Only after satisfactory answers, proceed to implementation.
All criteria must be met before design is considered complete.
Architecture Consistency
Type Safety
any types in designComponent Structure
readonly where appropriateCustom Hooks
Design Documentation
ā
COMPONENT DESIGN ACCEPTANCE CRITERIA
Architecture:
[ ] Existing pattern identified (layer/feature/hybrid)
[ ] New code follows existing pattern
[ ] File structure planned consistently
Type Safety:
[ ] Domain types defined (no primitive obsession)
[ ] Zod schemas for validation
[ ] Branded types for IDs
[ ] No any types
Components:
[ ] Presentational vs container distinction clear
[ ] Single responsibility
[ ] Props interfaces defined
[ ] No prop drilling planned
Hooks:
[ ] Reusable logic identified for extraction
[ ] Single responsibility per hook
[ ] Return types defined
Documentation:
[ ] Design plan complete
[ ] Integration points documented
[ ] Ready for implementation
Design complete: All boxes checked ā
The following will BLOCK design completion:
Design must include: