Hoang Nguyen
Technical
01 August 2025
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:
1// Form đăng ký tài khoản - Version "thảm họa"
2const RegisterForm = () => {
3 const schema = z.object({
4 username: z.string()
5 .min(3, "Username phải ít nhất 3 ký tự")
6 .refine(async (username) => {
7 // Gọi API check username có tồn tại chưa
8 const response = await fetch(`/api/check-username/${username}`);
9 const data = await response.json();
10 return !data.exists;
11 }, "Username đã tồn tại"),
12
13 email: z.string()
14 .email("Email không hợp lệ")
15 .refine(async (email) => {
16 // Gọi API check email có tồn tại chưa
17 const response = await fetch(`/api/check-email/${email}`);
18 const data = await response.json();
19 return !data.exists;
20 }, "Email đã tồn tại"),
21 });
22
23 const form = useForm({
24 resolver: zodResolver(schema),
25 mode: "onChange" // Validate mỗi khi user typing
26 });
27
28 // ... rest of form
29};
Nhìn qua thì code cũng ổn, nhưng khi test thực tế thì... thảm họa!
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.
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ế?"
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:
1// Version được cứu rỗi
2const RegisterForm = () => {
3 const checkUsername = useRefinement(
4 async (username, { signal }) => {
5 const response = await fetch(`/api/check-username/${username}`, {
6 signal // Có thể cancel request nếu cần
7 });
8 const data = await response.json();
9 return !data.exists;
10 },
11 { debounce: 500 } // Chờ 500ms sau khi user ngừng typing
12 );
13
14 const checkEmail = useRefinement(
15 async (email, { signal }) => {
16 const response = await fetch(`/api/check-email/${email}`, {
17 signal
18 });
19 const data = await response.json();
20 return !data.exists;
21 },
22 { debounce: 500 }
23 );
24
25 const schema = z.object({
26 username: z.string()
27 .min(3, "Username phải ít nhất 3 ký tự")
28 .refine(checkUsername, "Username đã tồn tại"),
29
30 email: z.string()
31 .email("Email không hợp lệ")
32 .refine(checkEmail, "Email đã tồn tại"),
33 });
34
35 const form = useForm({
36 resolver: zodResolver(schema),
37 mode: "onChange"
38 });
39
40 return (
41 <form onSubmit={form.handleSubmit(onSubmit)}>
42 <input
43 {...form.register("username")}
44 onChange={(e) => {
45 form.register("username").onChange(e);
46 // Invalidate cache khi user typing để cho phép validation mới
47 checkUsername.invalidate();
48 }}
49 />
50 <input
51 {...form.register("email")}
52 onChange={(e) => {
53 form.register("email").onChange(e);
54 checkEmail.invalidate();
55 }}
56 />
57 <button type="submit">Đăng ký</button>
58 </form>
59 );
60};
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
typescript
1export interface Refinement<T> {
2 (data: T): 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.
typescript
1export default function useRefinement<T>(
2 callback: RefinementCallback<T>,
3 { debounce }: { debounce?: number } = {}
4): Refinement<T> {
5 const ctxRef = useRef() as MutableRefObject<RefinementContext<T>>;
6 const refinementRef = useRef() as MutableRefObject<{
7 refine: Refinement<T>;
8 abort(): void;
9 }>;
10
11 ctxRef.current = { callback, debounce };
12
13 if (refinementRef.current == null) {
14 refinementRef.current = createRefinement(ctxRef);
15 }
16
17 useEffect(() => () => refinementRef.current.abort(), []);
18
19 return refinementRef.current.refine;
20}
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.
•createRefinement chỉ được gọi một lần duy nhất khi hook được khởi tạo.
•useEffect cleanup: Khi component unmount, abort mọi operation đang chạy.
typescript
1function createRefinement<T>(ctxRef: MutableRefObject<RefinementContext<T>>) {
2 let abortController: AbortController | null = null;
3 let result: Promise<boolean> | null = null;
4 let timeout: ReturnType<typeof setTimeout> | null = null;
Ba biến state quan trọng:
•abortController: Để cancel requests đang chạy
•result: Cache kết quả refinement (là Promise)
•timeout: Cho debounce functionality
typescript
1const start = async (data: T) => {
2 abortController = new AbortController();
3
4
Debounce implementation:
•Sử dụng setTimeout wrapped 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
typescript
1const refine = async (data: T) => {
2 if (result != null) {
3 return result;
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ào result
•Lưu ý: Cache cả Promise, không phải resolved value
typescript
1const abort = () => {
2 if (timeout != null) {
3 clearTimeout(timeout);
4 }
5 timeout = null;
6 abortController?.abort();
7 abortController = null;
8};
9
10refine.invalidate = () => {
11 abort();
12 result = null;
abort(): Cleanup mọi thứ đang chạy invalidate(): Reset cache để cho phép refinement chạy lại
Promise Caching thay vì Value Caching
1// Không cache value
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
1const result = await ctxRef.current.callback(data, {
2 signal: abortController.signal,
3});
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
1if (ctxRef.current.debounce != null) {
2 await new Promise((resolve) => {
3 timeout = setTimeout(resolve, ctxRef.current.debounce);
4 });
5}
Debounce được implement ở level hook, không cần phụ thuộc external library.
1.Performance optimization không chỉ là algorithm - Đôi khi là về việc tránh unnecessary work
2.Caching strategies rất đa dạng - Cache Promise vs Cache Value có impact khác nhau
3.User experience bị ảnh hưởng bởi technical decisions - 7 API calls vs 1 API call
4.AbortController là friend - Luôn cleanup async operations
5.Custom hooks giải quyết domain-specific problems - Không phải lúc nào cũng cần external library
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! 🚀