Tối ưu Zod Refinements trong React Forms: Câu chuyện về useRefinement Hook
- Category: Technical
- #react
- #nextjs
Vấn đề thực tế: Form đăng ký tài khoản “lag” không chịu nổi
Hôm qua, tôi đang review code của một junior developer trong team. Anh ấy implement một form đăng ký tài khoản với validation real-time khá đơn giản:
// Form đăng ký tài khoản - Version "thảm họa"
const RegisterForm = () => {
const schema = z.object({
username: z.string()
.min(3, "Username phải ít nhất 3 ký tự")
.refine(async (username) => {
// Gọi API check username có tồn tại chưa
const response = await fetch(`/api/check-username/${username}`);
const data = await response.json();
return !data.exists;
}, "Username đã tồn tại"),
email: z.string()
.email("Email không hợp lệ")
.refine(async (email) => {
// Gọi API check email có tồn tại chưa
const response = await fetch(`/api/check-email/${email}`);
const data = await response.json();
return !data.exists;
}, "Email đã tồn tại"),
});
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange" // Validate mỗi khi user typing
});
// ... rest of form
};
Nhìn qua thì code cũng ổn, nhưng khi test thực tế thì… thảm họa!
Thảm họa khi user typing
Khi user gõ username “john123”, điều gì xảy ra?
- Gõ “j” → API call check “j”
- Gõ “jo” → API call check “jo”
- Gõ “joh” → API call check “joh”
- Gõ “john” → API call check “john”
- Gõ “john1” → API call check “john1”
- Gõ “john12” → API call check “john12”
- Gõ “john123” → API call check “john123”
7 ký tự = 7 API calls! Network tab trông như pháo hoa 🎆
Không chỉ vậy, vì Zod chạy toàn bộ schema validation mỗi khi có thay đổi, nên khi user sửa email thì refinement của username cũng chạy lại, dù username không đổi.
Dev Tools cho thấy sự thật đau lòng
Mở Performance tab trong Chrome DevTools:
- CPU spike liên tục
- Memory usage tăng dần
- Network requests như mưa rào
- User experience: “Sao form này lag thế?”
Giải pháp: useRefinement Hook
Sau một hồi research và brainstorm, tôi quyết định viết một custom hook để giải quyết vấn đề này:
// Version được cứu rỗi
const RegisterForm = () => {
const checkUsername = useRefinement(
async (username, { signal }) => {
const response = await fetch(`/api/check-username/${username}`, {
signal // Có thể cancel request nếu cần
});
const data = await response.json();
return !data.exists;
},
{ debounce: 500 } // Chờ 500ms sau khi user ngừng typing
);
const checkEmail = useRefinement(
async (email, { signal }) => {
const response = await fetch(`/api/check-email/${email}`, {
signal
});
const data = await response.json();
return !data.exists;
},
{ debounce: 500 }
);
const schema = z.object({
username: z.string()
.min(3, "Username phải ít nhất 3 ký tự")
.refine(checkUsername, "Username đã tồn tại"),
email: z.string()
.email("Email không hợp lệ")
.refine(checkEmail, "Email đã tồn tại"),
});
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange"
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input
{...form.register("username")}
onChange={(e) => {
form.register("username").onChange(e);
// Invalidate cache khi user typing để cho phép validation mới
checkUsername.invalidate();
}}
/>
<input
{...form.register("email")}
onChange={(e) => {
form.register("email").onChange(e);
checkEmail.invalidate();
}}
/>
<button type="submit">Đăng ký</button>
</form>
);
};
Kết quả sau khi áp dụng
Trước:
- 7 ký tự = 7 API calls
- Refinement chạy lại không cần thiết
- Form lag, user experience tệ
Sau:
- User gõ “john123” → Chờ 500ms → 1 API call duy nhất
- Refinement được cache, không chạy lại nếu data không đổi
- Form mượt mà, user hài lòng
Deep Dive: Cách useRefinement Hook hoạt động
1. Interface Definitions
typescript
export interface Refinement<T> {
(data: T): boolean | Promise<boolean>;
// Hàm refinement chính
invalidate(): void;
// Method để xóa cache
}
export interface RefinementCallback<T> {
(data: T, ctx: { signal: AbortSignal }): boolean | Promise<boolean>;
}
Refinement<T> là một function với thêm method invalidate(). Đây là pattern mở rộng function trong TypeScript - function vừa có thể gọi được vừa có thêm properties.
2. Hook chính
typescript
export default function useRefinement<T>(
callback: RefinementCallback<T>,
{ debounce }: { debounce?: number } = {}
): Refinement<T> {
const ctxRef = useRef() as MutableRefObject<RefinementContext<T>>;
const refinementRef = useRef() as MutableRefObject<{
refine: Refinement<T>;
abort(): void;
}>;
ctxRef.current = { callback, debounce };
if (refinementRef.current == null) {
refinementRef.current = createRefinement(ctxRef);
}
useEffect(() => () => refinementRef.current.abort(), []);
return refinementRef.current.refine;
}
Giải thích từng dòng:
ctxRef: Lưu trữ callback và debounce options. Cập nhật mỗi render để đảm bảo luôn có reference mới nhất.refinementRef: Lưu trữ refinement function và abort function. Chỉ tạo một lần.createRefinementchỉ được gọi một lần duy nhất khi hook được khởi tạo.useEffectcleanup: Khi component unmount, abort mọi operation đang chạy.
3. Core Logic: createRefinement
typescript
function createRefinement<T>(ctxRef: MutableRefObject<RefinementContext<T>>) {
let abortController: AbortController | null = null;
let result: Promise<boolean> | null = null;
let timeout: ReturnType<typeof setTimeout> | null = null;
Ba biến state quan trọng:
abortController: Để cancel requests đang chạyresult: Cache kết quả refinement (là Promise)timeout: Cho debounce functionality
4. Start Function - Nơi magic xảy ra
typescript
const start = async (data: T) => {
abortController = new AbortController();
// Debounce logic
if (ctxRef.current.debounce != null) {
await new Promise((resolve) => {
timeout = setTimeout(resolve, ctxRef.current.debounce);
});
}
const result = await ctxRef.current.callback(data, {
signal: abortController.signal,
});
abortController = null;
return result;
};
Debounce implementation:
- Sử dụng
setTimeoutwrapped trong Promise - Await Promise này sẽ pause execution trong khoảng thời gian debounce
- Nếu có abort signal, Promise sẽ bị cancel
AbortSignal:
- Truyền vào callback để cho phép cancel request
- Reset về null sau khi hoàn thành
5. Refine Function - Caching Layer
typescript
const refine = async (data: T) => {
if (result != null) {
return result;
// Trả về cached result
}
return (result = start(data));
// Cache và trả về Promise
};
Caching strategy:
- Nếu
resultđã có giá trị (không null), trả về luôn - Nếu chưa có, gọi
start()và cache Promise vàoresult - Lưu ý: Cache cả Promise, không phải resolved value
6. Utility Functions
typescript
const abort = () => {
if (timeout != null) {
clearTimeout(timeout);
}
timeout = null;
abortController?.abort();
abortController = null;
};
refine.invalidate = () => {
abort();
result = null;
// Reset cache
};
abort(): Cleanup mọi thứ đang chạy invalidate(): Reset cache để cho phép refinement chạy lại
Những điểm hay của thiết kế
Promise Caching thay vì Value Caching
// Không cache value
let cachedValue: boolean | null = null;
// Cache Promise - Brilliant!
let result: Promise<boolean> | null = null;
Tại sao cache Promise? Vì nếu refinement được gọi nhiều lần trong khi async operation đang chạy, tất cả sẽ nhận về cùng một Promise thay vì tạo multiple requests.
AbortController Integration
const result = await ctxRef.current.callback(data, {
signal: abortController.signal,
});
Cho phép callback cancel network requests khi cần thiết. Rất hữu ích khi user typing nhanh hoặc component unmount.
Flexible Debouncing
if (ctxRef.current.debounce != null) {
await new Promise((resolve) => {
timeout = setTimeout(resolve, ctxRef.current.debounce);
});
}
Debounce được implement ở level hook, không cần phụ thuộc external library.
Lessons Learned
- Performance optimization không chỉ là algorithm - Đôi khi là về việc tránh unnecessary work
- Caching strategies rất đa dạng - Cache Promise vs Cache Value có impact khác nhau
- User experience bị ảnh hưởng bởi technical decisions - 7 API calls vs 1 API call
- AbortController là friend - Luôn cleanup async operations
- Custom hooks giải quyết domain-specific problems - Không phải lúc nào cũng cần external library
Kết luận
useRefinement hook không chỉ giải quyết vấn đề performance mà còn cải thiện đáng kể user experience. Từ một form “lag lag” thành một form responsive và mượt mà.
Đây là một ví dụ điển hình cho việc “measure first, optimize second”. Đôi khi solution không phức tạp, chỉ cần hiểu rõ vấn đề và áp dụng đúng pattern.
P/S: Junior developer trong team giờ đã thành expert về React performance optimization. Mission accomplished! 🚀