Đừng lạm dụng useMemo nữa! Đây mới là Best Practice chuẩn xác dành cho bạn

Đừng lạm dụng useMemo nữa! Đây mới là Best Practice chuẩn xác dành cho bạn
useMemo là một React Hook giúp chúng ta ghi nhớ kết quả tính toán giữa các lần render để tối ưu hiệu năng trong một số trường hợp. Tuy nhiên, có một thực trạng đang diễn ra: người không biết thì chẳng bao giờ dùng, còn người đã biết thì lại lạm dụng nó ở mọi lúc, mọi nơi. Bài viết này sẽ giúp bạn hiểu rõ thực sự thì trong những trường hợp nào chúng ta mới nên sử dụng useMemo.

Cú pháp tổng quát:

useMemo hoạt động như thế nào?

Ở lần render đầu tiên, useMemo sẽ thực thi hàm calculateValue (không truyền tham số) và trả về kết quả.

Ở các lần render tiếp theo, React so sánh mảng dependencies với lần trước. Nếu các dependency không thay đổi, useMemo trả về giá trị đã memoize. Nếu có thay đổi, React sẽ chạy lại calculateValue và trả về kết quả mới.

Bạn cần truyền cho useMemo hai thành phần:

  1. Một hàm calculation không nhận tham số (thường viết dưới dạng () => ...) và trả về giá trị cần tính.
  2. Một dependency array chứa đầy đủ các giá trị (state, props, biến trong component) được sử dụng bên trong calculation.

Cách sử dụng

Mặc định, React sẽ chạy lại toàn bộ component mỗi khi re-render. Ví dụ, nếu TodoList cập nhật state hoặc nhận props mới từ component cha, hàm filterTodos sẽ bị gọi lại.

Nếu tốc độ tính toán diễn ra rất nhanh, điều này sẽ không gây ra vấn đề gì. Tuy nhiên, khi bạn đang lọc hoặc chuyển đổi một mảng dữ liệu lớn, hoặc thực hiện phép tính tốn kém trong khi dữ liệu đầu vào không đổi, bạn chắc chắn sẽ muốn bỏ qua những lần tính toán lặp lại vô ích này.

Khi todostab không đổi giữa các lần render, bọc phép tính trong useMemo giúp tái sử dụng kết quả visibleTodos đã tính trước đó.

Làm thế nào để đánh giá xem quá trình tính toán có thực sự "tốn kém" hay không?

Trong nhiều trường hợp, chi phí tính toán nhỏ đến mức không đáng để tối ưu. Bạn chỉ nên cân nhắc useMemo khi có dấu hiệu thực tế như: UI lag, thao tác chậm, re-render lặp lại nhiều lần, hoặc dữ liệu đầu vào lớn. Một cách đơn giản là đo thời gian chạy đoạn code bằng console.time / console.timeEnd trong quá trình kiểm tra.

Dựa vào thời gian đo được, bạn có thể quyết định xem có dùng useMemo không, tuy nhiên phải phụ thuộc vào ngữ cảnh thực tế: môi trường dev/prod, kích thước dữ liệu, tần suất render, thiết bị người dùng và mục tiêu trải nghiệm. Hãy ưu tiên đo trong tình huống thật (đặc biệt ở build production) và dùng React DevTools Profiler để xác định nút thắt hiệu năng.

Để đối chiếu, bạn có thể bọc (wrap) quá trình tính toán đó vào trong useMemo để kiểm chứng xem tổng thời gian log ra có thực sự giảm đi hay không:

Có một điểm cực kỳ đáng lưu ý ở đây: useMemo sẽ không làm cho lần render đầu tiên chạy nhanh hơn. Vì vậy, bạn sẽ thấy ở lần render đầu tiên, thời gian thực thi đoạn code này vẫn xấp xỉ với ví dụ bên trên. Thế nhưng, trong những lần re-render tiếp theo, hãy quan sát xem thời gian in ra có giảm đi một cách rõ rệt hay không nhé.

Khi nào thì nên sử dụng useMemo?

1. Phép tính chậm và dependency ít thay đổi

Nếu mỗi lần cập nhật, giá trị của các dependency đều thay đổi, thì việc sử dụng useMemo trong trường hợp này sẽ không mang lại bất kỳ lợi ích hiệu năng rõ rệt nào (thậm chí còn tốn thêm chi phí overhead để cache).

2. Truyền kết quả tính toán dưới dạng props xuống component con, và bạn không muốn component con bị re-render khi dependencies chưa thay đổi

Theo mặc định, khi một component re-render, React sẽ đệ quy re-render toàn bộ các component con của nó. Điều này hoàn toàn ổn với những component nhẹ, không tốn nhiều công sức để vẽ lại. Nhưng nếu bạn biết chắc một component con nào đó re-render rất chậm, bạn có thể bọc nó bằng memo. Bằng cách này, nếu props truyền vào y hệt như lần render trước, nó sẽ bỏ qua lần render hiện tại:

Trong ví dụ trên, hàm filterTodos luôn tạo ra một mảng mới, giống như cách bạn gõ {} thì nó luôn tạo ra một object mới ở một vùng nhớ khác. Bình thường thì chả sao cả, nhưng điều này có nghĩa là props truyền vào List sẽ không bao giờ bằng nhau (khác tham chiếu), và sự tối ưu hóa của memo coi như "vứt đi". Đây chính là lúc useMemo phát huy tác dụng:

Còn một cách tiếp cận khác, thay vì bọc component List trong memo, bạn có thể bọc trực tiếp JSX Node vào bên trong useMemo:

Hành vi của hai cách viết này là nhất quán. Nếu visibleTodos không thay đổi, List sẽ không bị re-render. Tuy nhiên, việc tự tay bọc JSX Node vào useMemo khá là bất tiện, ví dụ như bạn không thể làm thế này bên trong các câu lệnh điều kiện (if/else). Do đó, thông thường chúng ta ưu tiên chọn giải pháp dùng memo để bọc Component thay vì dùng useMemo bọc JSX Node.

3. Giá trị truyền đi sau này sẽ được dùng làm dependency cho một Hook khác.

Ví dụ: một tính toán useMemo khác phụ thuộc vào nó, hoặc một useEffect có mảng phụ thuộc chứa giá trị này (nếu không cache, useEffect sẽ bị trigger liên tục mỗi khi re-render do tham chiếu bị đổi).

Làm thế nào để tránh lạm dụng useMemo?

1. Giảm thiểu các dependencies (danh sách phụ thuộc) không cần thiết

Dependency càng nhiều, khả năng useMemo phải tính lại càng lớn. Hãy đảm bảo calculation chỉ phụ thuộc vào những giá trị thật sự cần thiết.

2. Tránh sử dụng Effect để cập nhật state một cách vô nghĩa.

Có 2 tình huống cực kỳ phổ biến mà bạn KHÔNG CẦN (và không nên) sử dụng Effect:

Bạn không cần dùng Effect để biến đổi dữ liệu phục vụ cho việc render. Giả sử bạn có một component chứa hai biến state: firstNamelastName. Bạn muốn nối chúng lại để tính ra fullName. Hơn nữa, mỗi khi firstName hoặc lastName thay đổi, bạn muốn fullName cũng được cập nhật. Theo bản năng, có thể bạn sẽ tạo thêm một biến state tên là fullName, rồi dùng một Effect để cập nhật nó:

Thật sự không cần phải làm phức tạp như vậy. Hơn nữa, cách viết này cực kỳ kém hiệu quả: Đầu tiên, React sẽ chạy toàn bộ luồng render với giá trị cũ của fullName, sau đó Effect mới chạy và gọi setFullName, khiến React ngay lập tức phải re-render thêm một lần nữa với giá trị mới. Hãy xóa biến state thừa thãi và cái Effect đó đi:

Nếu một giá trị có thể được tính toán dựa trên các props hoặc state hiện có, đừng biến nó thành state, mà hãy tính toán trực tiếp giá trị đó ngay trong lúc render (Derived State). Điều này sẽ giúp code của bạn chạy nhanh hơn (tránh được các pha cập nhật dây chuyền - "cascade updates" vô ích), gọn gàng hơn (xóa bớt code thừa) và ít dính bug hơn.

Bạn không cần dùng Effect để xử lý các sự kiện của người dùng.

Ví dụ: Bạn muốn gửi một POST request /api/buy và hiển thị thông báo khi người dùng mua một sản phẩm. Bên trong Event Handler (hàm xử lý sự kiện click của nút Mua), bạn biết chính xác điều gì đang xảy ra. Nhưng khi một Effect chạy, bạn lại chẳng biết người dùng vừa làm cái hành động gì (ví dụ: họ click vào nút nào?). Đó là lý do tại sao bạn luôn nên xử lý các action của người dùng ngay bên trong các Event Handler tương ứng, chứ không phải vứt vào useEffect.

3. Không thực hiện "Lifting State Up" (Đẩy state lên trên) nếu không thực sự cần thiết

Nếu một state chỉ được sử dụng ở component hiện tại, thì tuyệt đối đừng định nghĩa cái state đó ở component cha rồi truyền ngược xuống thông qua props. Việc này sẽ khiến component cha và toàn bộ các component con khác bị vạ lây (re-render oan) mỗi khi state đó đổi.

4. Sử dụng console.log hoặc các công cụ Profiling (đo lường hiệu năng) để phán đoán xem có thực sự cần dùng useMemo để cache hay không.

Ở các trường hợp không rơi vào những kịch bản đã nêu, việc bọc quá trình tính toán vào useMemo chẳng mang lại bất kỳ lợi ích nào. Tuy nhiên, vì việc thêm useMemo cũng không gây ra lỗi gì nghiêm trọng (no major harm), nên rất nhiều người lười phân tích case-by-case (từng trường hợp cụ thể) mà cứ nhắm mắt dùng useMemo ở mọi nơi, càng nhiều càng tốt. Cách làm "phòng thủ" mù quáng này thực chất sẽ làm giảm tính dễ đọc (readability) của code đi rất nhiều, tiêu tốn thêm bộ nhớ để tạo cache, mà hiệu năng thì hoàn toàn không được cải thiện.

Các vấn đề thường gặp

1. Khi dependency là một Object, useMemo sẽ bị tính toán lại ở MỌI LẦN render

Giả sử bạn có một hàm tính toán phụ thuộc vào một object được tạo trực tiếp ngay bên trong thân component:

Việc phụ thuộc vào một object như thế này sẽ phá vỡ hoàn toàn cơ chế memoization. Mỗi khi component re-render, toàn bộ code bên trong thân component sẽ chạy lại từ đầu. Dòng code tạo ra object searchOptions cũng sẽ chạy lại ở mỗi lần re-render (tạo ra một object mới với địa chỉ bộ nhớ mới). Vì searchOptions là một dependency của useMemo, và mỗi lần nó lại là một object khác nhau (khác tham chiếu), React sẽ nhận định là dependency đã bị thay đổi, từ đó luôn luôn tính toán lại searchItems.

Để giải quyết vấn đề này, bạn có thể memoize chính bản thân object searchOptions trước khi truyền nó vào làm dependency:

Trong ví dụ trên, nếu text không đổi, object searchOptions sẽ không bị đổi (giữ nguyên tham chiếu). Tuy nhiên, cách giải quyết tốt hơn (Best Practice) là di chuyển luôn việc khai báo object searchOptions vào HẲN BÊN TRONG hàm tính toán của useMemo:

Lúc này, dependency của chúng ta là một giá trị nguyên thủy (chuỗi string text), và useMemo sẽ chỉ thực thi lại khi giá trị này thực sự thay đổi.

2. Nếu không truyền mảng dependencies, useMemo sẽ tính toán lại ở mỗi lần render

Hãy đảm bảo rằng bạn luôn truyền mảng dependencies ở tham số thứ hai! Nếu bạn quên mất mảng này, useMemo sẽ chạy lại phép tính mỗi khi component re-render (thế thì dùng useMemo làm gì nữa):

3. Tuyệt đối KHÔNG gọi useMemo bên trong vòng lặp

Giả sử component Chart đã được bọc bằng memo. Khi component ReportList re-render, bạn muốn bỏ qua việc re-render cho từng Charttrong danh sách. Tuy nhiên, bạn không thể gọi useMemo bên trong một vòng lặp (vì vi phạm Rules of Hooks):

Thay vào đó, hãy tách từng phần tử (item) ra thành một component riêng biệt, và memoize dữ liệu cho từng item đó:

Hoặc, bạn có thể xóa luôn useMemo đi và bọc bản thân component Report bằng memo. Nếu props (item) không thay đổi, Report sẽ skip quá trình re-render, kéo theo việc Chart bên trong cũng skip re-render:

Kết luận

useMemo là công cụ tối ưu hiệu năng hữu ích, nhưng không phải là mặc định nên thêm vào mọi nơi. Hãy viết code đúng và rõ ràng trước, sau đó chỉ tối ưu khi có bằng chứng cho thấy performance là vấn đề.

Tóm lại, hãy dùng useMemo khi: Phép tính đủ nặng, dependency ít đổi, bạn cần ổn định tham chiếu để phối hợp với memo / Hook khác. Trong các trường hợp còn lại, ưu tiên giữ code đơn giản, dễ đọc và dễ bảo trì.

Read more