12 KiB
12 KiB
ADR-013: Form Handling & Validation Strategy
Status: ✅ Accepted Date: 2025-12-01 Decision Makers: Frontend Team Related Documents: Frontend Guidelines
Context and Problem Statement
ระบบ LCBP3-DMS มี Forms จำนวนมาก (Create/Edit Correspondence, RFA, Drawings) ต้องการวิธีจัดการ Forms ที่มี Performance ดี Validation ชัดเจน และ Developer Experience สูง
ปัญหาที่ต้องแก้:
- Form State Management: จัดการ Form state อย่างไร
- Validation: Validate client-side และ server-side อย่างไร
- Error Handling: แสดง Error messages อย่างไร
- Performance: Forms ขนาดใหญ่ไม่ช้า
- Type Safety: Type-safe forms with TypeScript
Decision Drivers
- ✅ Type Safety: TypeScript support เต็มรูปแบบ
- ⚡ Performance: Re-render minimal
- 🎯 DX: Developer Experience ดี
- 📝 Validation: Schema-based validation
- 🔄 Reusability: Reuse validation schema
- 🎨 Flexibility: ปรับแต่งได้ง่าย
Considered Options
Option 1: Formik
Pros:
- ✅ Popular และ Mature
- ✅ Documentation ดี
- ✅ Yup validation
Cons:
- ❌ Performance issues (re-renders)
- ❌ Bundle size ใหญ่
- ❌ TypeScript support ไม่ดีมาก
- ❌ Not actively maintained
Option 2: Plain React State
const [formData, setFormData] = useState({});
Pros:
- ✅ Simple
- ✅ No dependencies
Cons:
- ❌ Boilerplate code มาก
- ❌ ต้องจัดการ Validation เอง
- ❌ Error handling ซับซ้อน
- ❌ Performance issues
Option 3: React Hook Form + Zod
Pros:
- ✅ Performance: Uncontrolled components (minimal re-renders)
- ✅ TypeScript First: Full type safety
- ✅ Small Bundle: ~8.5kb
- ✅ Schema Validation: Zod integration
- ✅ DX: Clean API
- ✅ Actively Maintained
Cons:
- ❌ Learning curve (uncontrolled approach)
- ❌ Complex forms ต้องใช้ Controller
Decision Outcome
Chosen Option: Option 3 - React Hook Form + Zod
Rationale
- Performance: Uncontrolled components = minimal re-renders
- Type Safety: Zod schemas → TypeScript types → Runtime validation
- Bundle Size: เล็กมาก (8.5kb)
- Developer Experience: API สะอาด ใช้งานง่าย
- Validation Reuse: Validation schema ใช้ร่วมกับ Backend ได้
Implementation Details
1. Install Dependencies
npm install react-hook-form zod @hookform/resolvers
2. Define Zod Schema
// File: lib/validations/correspondence.ts
import { z } from 'zod';
export const correspondenceSchema = z.object({
subject: z
.string()
.min(5, 'Subject must be at least 5 characters')
.max(255, 'Subject must not exceed 255 characters'),
description: z
.string()
.min(10, 'Description must be at least 10 characters')
.optional(),
document_type_id: z.number({
required_error: 'Document type is required',
}),
from_organization_id: z.number({
required_error: 'From organization is required',
}),
to_organization_id: z.number({
required_error: 'To organization is required',
}),
importance: z.enum(['NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
attachments: z.array(z.instanceof(File)).optional(),
});
// Export TypeScript type
export type CorrespondenceFormData = z.infer<typeof correspondenceSchema>;
3. Create Form Component
// File: components/correspondences/create-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
correspondenceSchema,
type CorrespondenceFormData,
} from '@/lib/validations/correspondence';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export function CreateCorrespondenceForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
} = useForm<CorrespondenceFormData>({
resolver: zodResolver(correspondenceSchema),
defaultValues: {
importance: 'NORMAL',
},
});
const onSubmit = async (data: CorrespondenceFormData) => {
try {
const response = await fetch('/api/correspondences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create');
// Success - redirect
window.location.href = '/correspondences';
} catch (error) {
console.error(error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Subject */}
<div>
<Label htmlFor="subject">Subject *</Label>
<Input
id="subject"
{...register('subject')}
placeholder="Enter subject"
/>
{errors.subject && (
<p className="text-sm text-red-600 mt-1">{errors.subject.message}</p>
)}
</div>
{/* Description */}
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Enter description"
rows={4}
/>
{errors.description && (
<p className="text-sm text-red-600 mt-1">
{errors.description.message}
</p>
)}
</div>
{/* Document Type (Select) */}
<div>
<Label>Document Type *</Label>
<Select
onValueChange={(value) =>
setValue('document_type_id', parseInt(value))
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Internal Letter</SelectItem>
<SelectItem value="2">External Letter</SelectItem>
</SelectContent>
</Select>
{errors.document_type_id && (
<p className="text-sm text-red-600 mt-1">
{errors.document_type_id.message}
</p>
)}
</div>
{/* Importance (Radio) */}
<div>
<Label>Importance</Label>
<div className="flex gap-4 mt-2">
<label className="flex items-center">
<input type="radio" value="NORMAL" {...register('importance')} />
<span className="ml-2">Normal</span>
</label>
<label className="flex items-center">
<input type="radio" value="HIGH" {...register('importance')} />
<span className="ml-2">High</span>
</label>
<label className="flex items-center">
<input type="radio" value="URGENT" {...register('importance')} />
<span className="ml-2">Urgent</span>
</label>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
);
}
4. Reusable Form Field Component
// File: components/ui/form-field.tsx
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { UseFormRegister, FieldError } from 'react-hook-form';
interface FormFieldProps {
label: string;
name: string;
type?: string;
register: UseFormRegister<any>;
error?: FieldError;
required?: boolean;
placeholder?: string;
}
export function FormField({
label,
name,
type = 'text',
register,
error,
required = false,
placeholder,
}: FormFieldProps) {
return (
<div>
<Label htmlFor={name}>
{label} {required && <span className="text-red-600">*</span>}
</Label>
<Input
id={name}
type={type}
{...register(name)}
placeholder={placeholder}
className={error ? 'border-red-600' : ''}
/>
{error && <p className="text-sm text-red-600 mt-1">{error.message}</p>}
</div>
);
}
5. File Upload Handling
// File: components/correspondences/file-upload.tsx
'use client';
import { useState } from 'react';
import { UseFormSetValue } from 'react-hook-form';
import { Button } from '@/components/ui/button';
interface FileUploadProps {
setValue: UseFormSetValue<any>;
fieldName: string;
}
export function FileUpload({ setValue, fieldName }: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
setFiles(selectedFiles);
setValue(fieldName, selectedFiles);
};
return (
<div>
<input
type="file"
multiple
onChange={handleFileChange}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button type="button" variant="outline" asChild>
<span>Choose Files</span>
</Button>
</label>
{files.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
{files.map((file, i) => (
<div key={i}>{file.name}</div>
))}
</div>
)}
</div>
);
}
6. Server-Side Validation
// File: app/api/correspondences/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { correspondenceSchema } from '@/lib/validations/correspondence';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate with same Zod schema
const validated = correspondenceSchema.parse(body);
// Create correspondence
// ...
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Form Patterns
Dynamic Fields
import { useFieldArray } from 'react-hook-form';
const { fields, append, remove } = useFieldArray({
control,
name: 'items', // RFA items
});
// Add item
append({ description: '', quantity: 0 });
// Remove item
remove(index);
Controlled Components
import { Controller } from 'react-hook-form';
<Controller
name="discipline_id"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
{/* Options */}
</Select>
)}
/>;
Consequences
Positive Consequences
- ✅ Performance: Minimal re-renders (uncontrolled)
- ✅ Type Safety: Full TypeScript support
- ✅ Validation Reuse: Same schema for client & server
- ✅ Small Bundle: ~8.5kb only
- ✅ Clean Code: Less boilerplate
- ✅ Error Handling: Built-in error states
Negative Consequences
- ❌ Learning Curve: Uncontrolled approach ต่างจาก Formik
- ❌ Complex Forms: ต้องใช้ Controller บางครั้ง
Mitigation Strategies
- Documentation: เขียน Form patterns และ Examples
- Reusable Components: สร้าง FormField wrapper
- Code Review: Review forms ให้ใช้ best practices
Related ADRs
References
Last Updated: 2025-12-01 Next Review: 2026-06-01