Common Patterns
Common patterns and best practices for using FoundryKit utilities
Common Patterns
Learn common patterns and techniques for using FoundryKit utilities effectively in real-world applications.
Component Styling Patterns
Conditional Styling with cn
Use cn
for dynamic component styling based on props and state.
import React from 'react'
import { cn } from '@foundrykit/utils'
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
className?: string
children: React.ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
// Size variants
{
'px-2 py-1 text-xs': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-6 py-3 text-base': size === 'lg',
},
// Color variants
{
'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500': variant === 'primary',
'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-500': variant === 'secondary',
'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500': variant === 'danger',
},
// State modifiers
{
'opacity-50 cursor-not-allowed': disabled || loading,
'cursor-wait': loading,
},
// Allow external className override
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
)
}
Theme-Based Styling
Create theme-aware components using utility functions.
import React, { createContext, useContext } from 'react'
import { cn, deepMerge } from '@foundrykit/utils'
// Theme configuration
const defaultTheme = {
colors: {
primary: 'blue',
secondary: 'gray',
danger: 'red',
success: 'green',
},
spacing: {
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
},
borderRadius: {
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
}
}
type Theme = typeof defaultTheme
const ThemeContext = createContext<Theme>(defaultTheme)
export function ThemeProvider({
theme = {},
children
}: {
theme?: Partial<Theme>
children: React.ReactNode
}) {
const mergedTheme = deepMerge(defaultTheme, theme)
return (
<ThemeContext.Provider value={mergedTheme}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// Theme-aware component
interface ThemedCardProps {
variant?: keyof Theme['colors']
size?: keyof Theme['spacing']
className?: string
children: React.ReactNode
}
export function ThemedCard({
variant = 'primary',
size = 'md',
className,
children
}: ThemedCardProps) {
const theme = useTheme()
return (
<div
className={cn(
'border rounded shadow-sm',
`border-${theme.colors[variant]}-200`,
`p-${size}`,
`rounded-${size}`,
className
)}
>
{children}
</div>
)
}
Data Processing Patterns
Search and Filter Implementation
Combine multiple utilities for powerful search functionality.
import React, { useState, useMemo } from 'react'
import { debounce, sortBy, groupBy, unique } from '@foundrykit/utils'
interface Product {
id: string
name: string
category: string
price: number
tags: string[]
description: string
}
interface ProductSearchProps {
products: Product[]
}
export function ProductSearch({ products }: ProductSearchProps) {
const [query, setQuery] = useState('')
const [sortField, setSortField] = useState<keyof Product>('name')
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000])
// Debounced search to avoid excessive filtering
const debouncedQuery = useMemo(() => {
let timeoutId: NodeJS.Timeout
return (value: string) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => setQuery(value), 300)
}
}, [])
// Get unique categories for filter dropdown
const categories = useMemo(() => {
return unique(products.map(p => p.category)).sort()
}, [products])
// Filter and sort products
const filteredProducts = useMemo(() => {
let filtered = products
// Text search
if (query) {
const searchTerms = query.toLowerCase().split(' ')
filtered = filtered.filter(product => {
const searchableText = `${product.name} ${product.description} ${product.tags.join(' ')}`.toLowerCase()
return searchTerms.every(term => searchableText.includes(term))
})
}
// Category filter
if (selectedCategory) {
filtered = filtered.filter(product => product.category === selectedCategory)
}
// Price range filter
filtered = filtered.filter(product =>
product.price >= priceRange[0] && product.price <= priceRange[1]
)
// Sort results
return sortBy(filtered, sortField)
}, [products, query, selectedCategory, priceRange, sortField])
// Group products by category for display
const groupedProducts = useMemo(() => {
return groupBy(filteredProducts, 'category')
}, [filteredProducts])
return (
<div className="space-y-6">
{/* Search Controls */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<input
type="text"
placeholder="Search products..."
onChange={(e) => debouncedQuery(e.target.value)}
className="px-3 py-2 border rounded-md"
/>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border rounded-md"
>
<option value="">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as keyof Product)}
className="px-3 py-2 border rounded-md"
>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
<option value="category">Sort by Category</option>
</select>
<div className="flex items-center space-x-2">
<span className="text-sm">Price:</span>
<input
type="range"
min="0"
max="1000"
value={priceRange[1]}
onChange={(e) => setPriceRange([priceRange[0], parseInt(e.target.value)])}
className="flex-1"
/>
<span className="text-sm">${priceRange[1]}</span>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{Object.entries(groupedProducts).map(([category, categoryProducts]) => (
<div key={category}>
<h2 className="text-xl font-semibold mb-4">{category}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryProducts.map(product => (
<div key={product.id} className="border rounded-lg p-4">
<h3 className="font-medium">{product.name}</h3>
<p className="text-gray-600 text-sm mt-1">{product.description}</p>
<div className="mt-2 flex items-center justify-between">
<span className="font-semibold">${product.price}</span>
<div className="flex flex-wrap gap-1">
{product.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-xs rounded"
>
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{filteredProducts.length === 0 && (
<div className="text-center py-8 text-gray-500">
No products found matching your criteria.
</div>
)}
</div>
)
}
Data Transformation Pipeline
Create reusable data transformation patterns.
import { pick, omit, groupBy, sortBy, chunk } from '@foundrykit/utils'
interface RawUserData {
id: string
firstName: string
lastName: string
email: string
password: string
role: string
department: string
salary: number
joinDate: string
isActive: boolean
permissions: string[]
}
interface PublicUserData {
id: string
name: string
email: string
role: string
department: string
joinDate: string
isActive: boolean
}
interface UserAnalytics {
totalUsers: number
activeUsers: number
departmentBreakdown: Record<string, number>
roleDistribution: Record<string, number>
recentJoins: PublicUserData[]
}
class UserDataProcessor {
// Transform raw user data to public format
static toPublicUser(user: RawUserData): PublicUserData {
return {
...omit(user, ['password', 'salary', 'permissions']),
name: `${user.firstName} ${user.lastName}`
}
}
// Process multiple users
static toPublicUsers(users: RawUserData[]): PublicUserData[] {
return users.map(this.toPublicUser)
}
// Create user analytics
static generateAnalytics(users: RawUserData[]): UserAnalytics {
const publicUsers = this.toPublicUsers(users)
const activeUsers = publicUsers.filter(user => user.isActive)
// Group by department and role
const departmentGroups = groupBy(publicUsers, 'department')
const roleGroups = groupBy(publicUsers, 'role')
// Convert to counts
const departmentBreakdown = Object.fromEntries(
Object.entries(departmentGroups).map(([dept, users]) => [dept, users.length])
)
const roleDistribution = Object.fromEntries(
Object.entries(roleGroups).map(([role, users]) => [role, users.length])
)
// Get recent joins (last 30 days, sorted by join date)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const recentJoins = sortBy(
publicUsers.filter(user => new Date(user.joinDate) >= thirtyDaysAgo),
'joinDate'
).reverse().slice(0, 10) // Latest 10
return {
totalUsers: publicUsers.length,
activeUsers: activeUsers.length,
departmentBreakdown,
roleDistribution,
recentJoins
}
}
// Paginate users
static paginateUsers(users: PublicUserData[], pageSize: number = 20) {
const chunks = chunk(users, pageSize)
return {
pages: chunks,
totalPages: chunks.length,
totalUsers: users.length,
pageSize
}
}
// Export users for different formats
static exportUsers(users: RawUserData[], format: 'csv' | 'admin' | 'public') {
switch (format) {
case 'csv':
return users.map(user => pick(user, [
'id', 'firstName', 'lastName', 'email', 'department', 'role'
]))
case 'admin':
return users.map(user => omit(user, ['password']))
case 'public':
return this.toPublicUsers(users)
default:
throw new Error(`Unsupported export format: ${format}`)
}
}
}
// Usage example
const rawUsers: RawUserData[] = [
// ... user data
]
// Generate analytics dashboard data
const analytics = UserDataProcessor.generateAnalytics(rawUsers)
// Create paginated user list
const paginatedUsers = UserDataProcessor.paginateUsers(
UserDataProcessor.toPublicUsers(rawUsers),
25
)
// Export data in different formats
const csvData = UserDataProcessor.exportUsers(rawUsers, 'csv')
const adminData = UserDataProcessor.exportUsers(rawUsers, 'admin')
Form Handling Patterns
Advanced Form Validation
Combine validation utilities for comprehensive form handling.
import React, { useState, useCallback } from 'react'
import { debounce, isEmail, validateSchema, deepMerge } from '@foundrykit/utils'
interface FormData {
personalInfo: {
firstName: string
lastName: string
email: string
phone: string
}
address: {
street: string
city: string
zipCode: string
country: string
}
preferences: {
newsletter: boolean
notifications: string[]
theme: 'light' | 'dark'
}
}
const formSchema = {
personalInfo: {
type: 'object',
properties: {
firstName: { type: 'string', required: true, minLength: 2 },
lastName: { type: 'string', required: true, minLength: 2 },
email: { type: 'string', required: true, validator: isEmail },
phone: { type: 'string', required: false, pattern: /^\+?[\d\s-()]+$/ }
}
},
address: {
type: 'object',
properties: {
street: { type: 'string', required: true },
city: { type: 'string', required: true },
zipCode: { type: 'string', required: true, pattern: /^\d{5}(-\d{4})?$/ },
country: { type: 'string', required: true }
}
},
preferences: {
type: 'object',
properties: {
newsletter: { type: 'boolean', required: false },
notifications: { type: 'array', required: false },
theme: { type: 'string', required: true, enum: ['light', 'dark'] }
}
}
}
export function AdvancedForm() {
const [formData, setFormData] = useState<FormData>({
personalInfo: {
firstName: '',
lastName: '',
email: '',
phone: ''
},
address: {
street: '',
city: '',
zipCode: '',
country: ''
},
preferences: {
newsletter: false,
notifications: [],
theme: 'light'
}
})
const [errors, setErrors] = useState<Record<string, any>>({})
const [isValidating, setIsValidating] = useState(false)
// Debounced validation to avoid excessive API calls
const debouncedValidation = useCallback(
debounce(async (data: FormData) => {
setIsValidating(true)
try {
const validation = validateSchema(data, formSchema)
setErrors(validation.errors)
// Additional async validation (e.g., email uniqueness)
if (validation.isValid && data.personalInfo.email) {
const emailExists = await checkEmailExists(data.personalInfo.email)
if (emailExists) {
setErrors(prev => deepMerge(prev, {
personalInfo: { email: 'Email already exists' }
}))
}
}
} finally {
setIsValidating(false)
}
}, 500),
[]
)
// Update form data and trigger validation
const updateFormData = useCallback((updates: Partial<FormData>) => {
setFormData(prev => {
const newData = deepMerge(prev, updates)
debouncedValidation(newData)
return newData
})
}, [debouncedValidation])
// Helper to update nested form fields
const updateField = (path: string, value: any) => {
const pathParts = path.split('.')
const updates = pathParts.reduceRight((acc, key) => ({ [key]: acc }), value)
updateFormData(updates)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const validation = validateSchema(formData, formSchema)
if (!validation.isValid) {
setErrors(validation.errors)
return
}
try {
await submitForm(formData)
// Handle success
} catch (error) {
// Handle error
}
}
return (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto space-y-8">
{/* Personal Information Section */}
<section>
<h2 className="text-xl font-semibold mb-4">Personal Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="First Name"
value={formData.personalInfo.firstName}
onChange={(value) => updateField('personalInfo.firstName', value)}
error={errors.personalInfo?.firstName}
/>
<FormField
label="Last Name"
value={formData.personalInfo.lastName}
onChange={(value) => updateField('personalInfo.lastName', value)}
error={errors.personalInfo?.lastName}
/>
<FormField
label="Email"
type="email"
value={formData.personalInfo.email}
onChange={(value) => updateField('personalInfo.email', value)}
error={errors.personalInfo?.email}
className="md:col-span-2"
/>
<FormField
label="Phone (Optional)"
value={formData.personalInfo.phone}
onChange={(value) => updateField('personalInfo.phone', value)}
error={errors.personalInfo?.phone}
className="md:col-span-2"
/>
</div>
</section>
{/* Address Section */}
<section>
<h2 className="text-xl font-semibold mb-4">Address</h2>
<div className="space-y-4">
<FormField
label="Street Address"
value={formData.address.street}
onChange={(value) => updateField('address.street', value)}
error={errors.address?.street}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="City"
value={formData.address.city}
onChange={(value) => updateField('address.city', value)}
error={errors.address?.city}
/>
<FormField
label="ZIP Code"
value={formData.address.zipCode}
onChange={(value) => updateField('address.zipCode', value)}
error={errors.address?.zipCode}
/>
<FormField
label="Country"
value={formData.address.country}
onChange={(value) => updateField('address.country', value)}
error={errors.address?.country}
/>
</div>
</div>
</section>
{/* Preferences Section */}
<section>
<h2 className="text-xl font-semibold mb-4">Preferences</h2>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.preferences.newsletter}
onChange={(e) => updateField('preferences.newsletter', e.target.checked)}
/>
<span>Subscribe to newsletter</span>
</label>
<div>
<label className="block text-sm font-medium mb-2">Theme</label>
<select
value={formData.preferences.theme}
onChange={(e) => updateField('preferences.theme', e.target.value)}
className="w-full px-3 py-2 border rounded-md"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</section>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
{isValidating && 'Validating...'}
</div>
<button
type="submit"
disabled={isValidating}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Submit
</button>
</div>
</form>
)
}
// Helper component for form fields
interface FormFieldProps {
label: string
value: string
onChange: (value: string) => void
error?: string
type?: string
className?: string
}
function FormField({
label,
value,
onChange,
error,
type = 'text',
className
}: FormFieldProps) {
return (
<div className={className}>
<label className="block text-sm font-medium mb-1">{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'w-full px-3 py-2 border rounded-md',
error && 'border-red-500'
)}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
)
}
// Mock functions
async function checkEmailExists(email: string): Promise<boolean> {
// Simulate API call
return new Promise(resolve => {
setTimeout(() => resolve(email === 'taken@example.com'), 500)
})
}
async function submitForm(data: FormData): Promise<void> {
// Simulate form submission
return new Promise(resolve => {
setTimeout(resolve, 1000)
})
}
Performance Optimization Patterns
Memoization and Caching
Use memoization for expensive computations.
import React, { useMemo } from 'react'
import { memoize, debounce, sortBy, groupBy } from '@foundrykit/utils'
// Expensive calculation that should be memoized
const expensiveCalculation = memoize((data: number[], operation: string) => {
console.log('Performing expensive calculation...') // Only logs when not cached
switch (operation) {
case 'sum':
return data.reduce((sum, num) => sum + num, 0)
case 'average':
return data.reduce((sum, num) => sum + num, 0) / data.length
case 'median':
const sorted = [...data].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid]
default:
return 0
}
})
// Memoized data processing
const processLargeDataset = memoize((
dataset: Array<{ id: string; value: number; category: string }>,
filters: { category?: string; minValue?: number }
) => {
let processed = dataset
// Apply filters
if (filters.category) {
processed = processed.filter(item => item.category === filters.category)
}
if (filters.minValue !== undefined) {
processed = processed.filter(item => item.value >= filters.minValue)
}
// Group and sort
const grouped = groupBy(processed, 'category')
const sorted = Object.fromEntries(
Object.entries(grouped).map(([category, items]) => [
category,
sortBy(items, 'value').reverse()
])
)
return {
processed,
grouped: sorted,
summary: {
total: processed.length,
categories: Object.keys(sorted).length,
totalValue: processed.reduce((sum, item) => sum + item.value, 0)
}
}
})
interface DataDashboardProps {
rawData: Array<{ id: string; value: number; category: string }>
}
export function DataDashboard({ rawData }: DataDashboardProps) {
const [filters, setFilters] = useState({
category: '',
minValue: 0
})
// Memoize expensive data processing
const processedData = useMemo(() => {
return processLargeDataset(rawData, filters)
}, [rawData, filters])
// Memoize calculations
const statistics = useMemo(() => {
const values = processedData.processed.map(item => item.value)
return {
sum: expensiveCalculation(values, 'sum'),
average: expensiveCalculation(values, 'average'),
median: expensiveCalculation(values, 'median')
}
}, [processedData.processed])
// Debounced filter updates
const debouncedUpdateFilters = useMemo(
() => debounce((newFilters: typeof filters) => {
setFilters(newFilters)
}, 300),
[]
)
return (
<div className="space-y-6">
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<select
value={filters.category}
onChange={(e) => debouncedUpdateFilters({ ...filters, category: e.target.value })}
className="px-3 py-2 border rounded-md"
>
<option value="">All Categories</option>
{Object.keys(processedData.grouped).map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
<input
type="number"
placeholder="Minimum value"
onChange={(e) => debouncedUpdateFilters({
...filters,
minValue: parseInt(e.target.value) || 0
})}
className="px-3 py-2 border rounded-md"
/>
</div>
{/* Summary Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-blue-900">Total Items</h3>
<p className="text-2xl font-bold text-blue-600">{processedData.summary.total}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold text-green-900">Sum</h3>
<p className="text-2xl font-bold text-green-600">{statistics.sum.toLocaleString()}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<h3 className="font-semibold text-yellow-900">Average</h3>
<p className="text-2xl font-bold text-yellow-600">{statistics.average.toFixed(2)}</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<h3 className="font-semibold text-purple-900">Median</h3>
<p className="text-2xl font-bold text-purple-600">{statistics.median.toFixed(2)}</p>
</div>
</div>
{/* Grouped Data Display */}
<div className="space-y-4">
{Object.entries(processedData.grouped).map(([category, items]) => (
<div key={category} className="border rounded-lg p-4">
<h3 className="font-semibold mb-2">{category}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{items.slice(0, 6).map(item => (
<div key={item.id} className="flex justify-between p-2 bg-gray-50 rounded">
<span className="truncate">{item.id}</span>
<span className="font-medium">{item.value}</span>
</div>
))}
</div>
{items.length > 6 && (
<p className="text-sm text-gray-500 mt-2">
+{items.length - 6} more items
</p>
)}
</div>
))}
</div>
</div>
)
}
Error Handling Patterns
Robust Error Handling with Utilities
Create comprehensive error handling patterns.
import { debounce, isEmail, validateSchema } from '@foundrykit/utils'
// Error types
interface ValidationError {
type: 'validation'
field: string
message: string
}
interface NetworkError {
type: 'network'
status?: number
message: string
}
interface UnknownError {
type: 'unknown'
message: string
originalError?: Error
}
type AppError = ValidationError | NetworkError | UnknownError
// Error handling utilities
class ErrorHandler {
private static errorLog: AppError[] = []
static logError(error: AppError) {
this.errorLog.push(error)
console.error('Application Error:', error)
}
static createValidationError(field: string, message: string): ValidationError {
return { type: 'validation', field, message }
}
static createNetworkError(status: number, message: string): NetworkError {
return { type: 'network', status, message }
}
static createUnknownError(message: string, originalError?: Error): UnknownError {
return { type: 'unknown', message, originalError }
}
static getErrorMessage(error: AppError): string {
switch (error.type) {
case 'validation':
return `${error.field}: ${error.message}`
case 'network':
return `Network error${error.status ? ` (${error.status})` : ''}: ${error.message}`
case 'unknown':
return `Unexpected error: ${error.message}`
}
}
static getErrorLog(): AppError[] {
return [...this.errorLog]
}
static clearErrorLog() {
this.errorLog = []
}
}
// Async operation with error handling
async function safeApiCall<T>(
apiCall: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<{ data?: T; error?: AppError }> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const data = await apiCall()
return { data }
} catch (error) {
if (attempt === retries) {
const appError = error instanceof Error
? ErrorHandler.createUnknownError(error.message, error)
: ErrorHandler.createUnknownError('Unknown error occurred')
ErrorHandler.logError(appError)
return { error: appError }
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay * attempt))
}
}
return { error: ErrorHandler.createUnknownError('Max retries exceeded') }
}
// Form with comprehensive error handling
interface ContactFormData {
name: string
email: string
message: string
}
const contactFormSchema = {
name: { type: 'string', required: true, minLength: 2 },
email: { type: 'string', required: true, validator: isEmail },
message: { type: 'string', required: true, minLength: 10 }
}
export function ContactForm() {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<AppError | null>(null)
const [submitSuccess, setSubmitSuccess] = useState(false)
// Debounced validation
const debouncedValidation = useMemo(
() => debounce((data: ContactFormData) => {
const validation = validateSchema(data, contactFormSchema)
const fieldErrors: Record<string, string> = {}
Object.entries(validation.errors).forEach(([field, error]) => {
if (error) {
fieldErrors[field] = error
ErrorHandler.logError(ErrorHandler.createValidationError(field, error))
}
})
setErrors(fieldErrors)
}, 300),
[]
)
const handleInputChange = (field: keyof ContactFormData, value: string) => {
const newData = { ...formData, [field]: value }
setFormData(newData)
debouncedValidation(newData)
// Clear submit error when user starts typing
if (submitError) {
setSubmitError(null)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate form
const validation = validateSchema(formData, contactFormSchema)
if (!validation.isValid) {
const fieldErrors: Record<string, string> = {}
Object.entries(validation.errors).forEach(([field, error]) => {
if (error) fieldErrors[field] = error
})
setErrors(fieldErrors)
return
}
setIsSubmitting(true)
setSubmitError(null)
setSubmitSuccess(false)
// Submit with error handling
const { data, error } = await safeApiCall(async () => {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (!response.ok) {
throw ErrorHandler.createNetworkError(
response.status,
`Failed to submit form: ${response.statusText}`
)
}
return response.json()
})
setIsSubmitting(false)
if (error) {
setSubmitError(error)
} else {
setSubmitSuccess(true)
setFormData({ name: '', email: '', message: '' })
setErrors({})
}
}
return (
<div className="max-w-md mx-auto">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={cn(
'w-full px-3 py-2 border rounded-md',
errors.name && 'border-red-500'
)}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={cn(
'w-full px-3 py-2 border rounded-md',
errors.email && 'border-red-500'
)}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Message</label>
<textarea
rows={4}
value={formData.message}
onChange={(e) => handleInputChange('message', e.target.value)}
className={cn(
'w-full px-3 py-2 border rounded-md',
errors.message && 'border-red-500'
)}
/>
{errors.message && (
<p className="mt-1 text-sm text-red-600">{errors.message}</p>
)}
</div>
{submitError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">
{ErrorHandler.getErrorMessage(submitError)}
</p>
</div>
)}
{submitSuccess && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm text-green-600">
Message sent successfully!
</p>
</div>
)}
<button
type="submit"
disabled={isSubmitting || Object.keys(errors).length > 0}
className={cn(
'w-full py-2 px-4 rounded-md font-medium',
'bg-blue-600 text-white hover:bg-blue-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
)
}
Next Steps
- Review performance tips for optimization techniques
- Check out best practices for optimal usage
- Explore the utility functions reference for detailed API documentation