17 min read

Debugging React: Series 1 - Những Cơn Ác Mộng Và Cách Tôi Sống Sót

Mở Đầu: Debug, Debug Nữa, Debug Mãi…

Chào các chiến hữu! 👋 Nếu bạn là một React developer, chắc hẳn bạn đã từng nếm mùi những cơn ác mộng mang tên debugging. Code chạy ngon lành cành đào trên máy mình, nhưng lên production thì toang không hiểu lý do. Hay những lỗi lúc ẩn lúc hiện, đuổi hình bắt bóng khiến bạn đau đầu, mất ăn mất ngủ. Tin mình đi, mình đã trải qua tất cả những thảm cảnh đó, nhất là vào cái thời tiền sử 1-2 năm trước, khi ChatGPT còn chưa ra đời để cứu rỗi chúng ta. 😂

Bài viết này, mở đầu cho series Debugging React, sẽ là nhật ký ghi lại những trận chiến debugging kinh điển của mình. Hy vọng nó sẽ giúp các bạn, đặc biệt là các newbie, né được những vết xe đổ của mình và sống sót qua những cơn bão bug.

Hồi 1: Những Cạm Bẫy Cần Tránh Khi Sử Dụng useEffect với Bất Đồng Bộ

1.1. Đua Ngựa Với useEffect

Trước khi có hooks, lifecycle methods trong class components là cứu cánh cho các side effects. Nhưng từ React 16, hooks lên ngôi, và useEffect trở thành ông hoàng của side effects. Tuy nhiên, lắm tài thì thường nhiều tật. useEffect với asynchronous operations (như fetching data) có thể bẫy bạn vào những lỗi khó đỡ.

Ví dụ kinh điển:

import React, { useState, useEffect } from "react";

function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    async function fetchData() {
      const response = await fetch("https://api.example.com/data");
      const result = await response.json();
      setData(result); // Vấn đề có thể ở đây nè!
    }
    fetchData();
  }, []);

  // ... render data
}

Vấn đề:

  • Race conditions: Nếu fetchData mất nhiều thời gian, component có thể bị unmount trước khi data kịp về. Gọi setData trên unmounted component sẽ gây ra lỗi “Can’t perform a React state update on an unmounted component…”.
  • Stale closure: Nếu dependency array của useEffect không được setup đúng, setData có thể “bắt” nhầm giá trị cũ của state, dẫn đến bug khó lường.

Giải pháp sống còn:

  • Cleanup function: Sử dụng cleanup function trong useEffect để “dọn dẹp” (ví dụ: cancel request) khi component unmount.
  • Dependency array: “Khai báo” chính xác các dependencies trong array để useEffect chạy đúng lúc.
  • useRef to the rescue: Trong một số trường hợp phức tạp hơn, chúng ta cần sử dụng useRef để giải quyết.
useEffect(() => {
  let isMounted = true; // Flag để kiểm tra component có đang mounted hay không

  async function fetchData() {
    const response = await fetch("https://api.example.com/data");
    const result = await response.json();
    if (isMounted) {
      setData(result); // Chỉ cập nhật state nếu component vẫn mounted
    }
  }

  fetchData();

  // Cleanup function: Đặt isMounted về false khi component unmount
  return () => {
    isMounted = false;
  };
}, []); // Dependency vẫn trống, effect sẽ chạy một lần sau khi component mount

Tổng Kết:

  • Cleanup Function: Kỹ thuật này giúp tránh các vấn đề liên quan đến việc component đã bị destroy nhưng gọi setState vẫn diễn ra.
  • Dependency Array: Đảm bảo rằng state hoặc props về sau này được cập nhật một cách chính xác.

1.2. “Mất Trí Nhớ” Với Asynchronous Updates

Cập nhật state trong React là bất đồng bộ. Điều này có nghĩa là sau khi gọi setState (hoặc hàm setter từ useState), state sẽ không thay đổi ngay lập tức.

Ví dụ đau thương:

function MyComponent() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Giá trị cũ, không phải giá trị mới!
    setCount(count + 1);
    console.log(count); // Vẫn là giá trị cũ
  }

  //... render button with onClick={handleClick}
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increase Count</button>
    </div>
  );
}

Giải pháp:

  • Sử dụng functional update trong setState (đã đề cập ở phần trước).
  • Dùng useEffect để “theo dõi” thay đổi của state, nếu bạn cần thực hiện side effect dựa trên giá trị mới.
function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Count is now: ", count); // Log ra giá trị mới mỗi khi count thay đổi
  }, [count]); // Dependency array chứa count

  function handleClick() {
    setCount((prevCount) => prevCount + 1); // Sử dụng functional update
    setCount((prevCount) => prevCount + 1); // Giảm cả hai thành 1 để được +2
  }

  //... render button with onClick={handleClick}
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increase Count</button>
    </div>
  );
}

Tổng Kết:

  • Functional Update: Cách này giúp tránh các vấn đề liên quan đến việc lấy giá trị cũ của state.
  • useEffect: Đây là kỹ thuật hữu ích khi cần thực hiện side effect dựa trên các sự thay đổi của state.

Hồi 2: State Management - “Mớ Bòng Bông” Mang Tên useState

2.1. “Cập Nhật Kiểu Gì Đây?” - Nỗi Khổ Khi Update State Phức Tạp

useState rất tiện, nhưng khi state trở nên phức tạp (nested objects, arrays), việc update state có thể trở thành ác mộng.

Ví dụ:

const [user, setUser] = useState({
  name: "John",
  address: {
    street: "123 Main St",
    city: "Anytown",
  },
});

function updateCity(city) {
  // Sai: Không merge, sẽ mất các property khác của address
  // setUser({ address: { city } });
  // Đúng:
  setUser((prevUser) => ({
    ...prevUser,
    address: {
      ...prevUser.address,
      city,
    },
  }));
}

Giải pháp:

  • Luôn sử dụng functional update khi state mới phụ thuộc vào state cũ.
  • Chia nhỏ state thành các useState nhỏ hơn, dễ quản lý hơn (nếu có thể).
  • Cân nhắc sử dụng useReducer cho các state logic phức tạp.

Chia Nhỏ State:

const [name, setName] = useState("John");
const [address, setAddress] = useState({
  street: "123 Main St",
  city: "Anytown",
});

function updateCity(city) {
  setAddress((prevAddress) => ({
    ...prevAddress,
    city,
  }));
}

useReducer cho những case phức tạp:

const initialState = {
  user: {
    name: "John",
    address: {
      street: "123 Main St",
      city: "Anytown",
    },
  },
};

function reducer(state, action) {
  switch (action.type) {
    case "UPDATE_CITY":
      return {
        ...state,
        user: {
          ...state.user,
          address: {
            ...state.user.address,
            city: action.payload,
          },
        },
      };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, initialState);

function updateCity(city) {
  dispatch({ type: "UPDATE_CITY", payload: city });
}

Tổng Kết:

  • Merging State: Đề phòng việc mất các thuộc tính khác khi cập nhật state.
  • useReducer: Kỹ thuật phù hợp cho các use case phức tạp, giúp quản lý state toàn bộ một cách tự nhiên.

2.2. Prop Drilling - “Truyền” Props “Mệt Nhỉ”

Prop drilling xảy ra khi bạn phải truyền props qua nhiều cấp components, dù các component trung gian không sử dụng props đó. Đây là một trong những vấn đề phổ biến khi ứng dụng trở nên lớn.

Giải pháp:

  • Context API: Phù hợp cho các state được sử dụng ở nhiều nơi trong ứng dụng.
  • Component Composition: Truyền components như children thay vì truyền props.
  • Render Props: Truyền một function như prop để render children, function này nhận state từ parent.

A. Context API:

Giúp chia sẻ giá trị giữa các components mà không cần truyền props qua mọi cấp.

Ví dụ:

// UserContext.js
import React, { createContext, useState } from "react";

const UserContext = createContext();

export function UserProvider({ children }) {
  const [user, setUser] = useState({ name: "John", age: 30 });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

export default UserContext;

Sử dụng:

// UserProfile.js
import React, { useContext } from "react";
import UserContext from "./UserContext";

function UserProfile() {
  const { user, setUser } = useContext(UserContext);

  return (
    <div>
      <h1>User Profile</h1>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
    </div>
  );
}

export default UserProfile;

// App.js
import React from "react";
import UserProvider from "./UserContext";
import UserProfile from "./UserProfile";

function App() {
  return (
    <UserProvider>
      <UserProfile />
    </UserProvider>
  );
}

export default App;

B. Component Composition:

Truyền components như children thay vì truyền props. Điều này giúp giảm số lượng props cần truyền qua nhiều tầng.

Ví dụ:

// Layout.js
import React from "react";

function Layout({ children }) {
  return (
    <div>
      <header>
        <h1>My Header</h1>
      </header>
      <main>{children}</main>
      <footer>
        <p>My Footer</p>
      </footer>
    </div>
  );
}

export default Layout;

Sử dụng:

// UserProfile.js
import React from "react";
import Layout from "./Layout";

function UserProfile() {
  return (
    <Layout>
      <div>
        <h1>User Profile</h1>
        <p>User Details</p>
      </div>
    </Layout>
  );
}

export default UserProfile;

C. Render Props:

Truyền một function như prop để render children, function này nhận state từ parent.

Ví dụ:

// UserProvider.js
import React, { useState } from "react";

function UserProvider({ children }) {
  const [user, setUser] = useState({ name: "John", age: 30 });

  return <div>{children({ user, setUser })}</div>;
}

export default UserProvider;

Sử dụng:

// UserProfile.js
import React from "react";
import UserProvider from "./UserProvider";

function UserProfile() {
  return (
    <UserProvider>
      {({ user, setUser }) => (
        <div>
          <h1>User Profile</h1>
          <p>Name: {user.name}</p>
          <p>Age: {user.age}</p>
          <button onClick={() => setUser({ ...user, age: user.age + 1 })}>
            Increase Age
          </button>
        </div>
      )}
    </UserProvider>
  );
}

export default UserProfile;

Hồi 3: Null vs. Undefined - “Hai Mà Một, Một Mà Hai”

JavaScript có nullundefined, và đôi khi sự khác biệt giữa chúng khiến ta đau đầu.

Lỗi thường gặp:

function MyComponent({ data }: { data: any }) {
  // Lỗi nếu data là null
  // return <div>{data.map(...)}</div>;
  // Sửa lỗi:
  return (
    <div>
      {data?.map((item) => (
        <div>{item.name}</div>
      ))}
    </div>
  );
}

Giải thích về sự khác biệt:

  • undefined: Biến chưa được gán giá trị. Thường gặp với các biến được khai báo nhưng chưa được gán giá trị, hoặc thuộc tính không tồn tại của một đối tượng.
  • null: Biến đã được gán giá trị, nhưng giá trị đó là “không có gì”. Thường được sử dụng để biểu thị một giá trị không tồn tại hoặc mất hiệu lực.

Cách xử lý:

  • Sử dụng operator chuỗi tùy chọn (?.) để kiểm tra tính tồn tại trước khi truy cập thuộc tính.
  • Sử dụng toán tử gán null hợp nhất (??) để cung cấp một giá trị mặc định nếu biến là null hoặc undefined.

Ví dụ:

function UserProfile({
  user,
}: {
  user: { name?: string; age?: number } | null;
}) {
  return (
    <div>
      <h1>User Profile</h1>
      <p>Name: {user?.name ?? "Unknown"}</p>
      <p>Age: {user?.age ?? "Unknown"}</p>
    </div>
  );
}

Tổng Kết:

  • Operator Chuỗi Tùy Chọn (?.): Giúp kiểm tra tồn tại trước khi truy cập thuộc tính.
  • Toán Tử Gán Null Hợp Nhất (??): Cung cấp giá trị mặc định thay vì null hoặc undefined.

Hồi 4: “Lạc Trôi” Token Và Bài Học Nhớ Đời

Chuyện là, trong một dự án, mình đã “ngây thơ” lưu token vào sessionStorage, vì tưởng đó là “tính năng” của next-auth. Hậu quả là token của mình “lộ thiên” và có nguy cơ bị tấn công XSS.

Kinh nghiệm của mình:

  • Đừng bao giờ lưu token (hoặc thông tin nhạy cảm) vào localStorage hay sessionStorage.
  • Tìm hiểu kỹ cách thức hoạt động của thư viện, đừng “đoán mò”.
  • Sử dụng HTTP-only cookie để lưu token an toàn hơn.

Sử dụng HTTP-only Cookie:

// Server side - Express Example
app.get("/auth", (req, res) => {
  const token = createToken(req.user);
  res.cookie("auth_token", token, {
    httpOnly: true,
    secure: true, // Khi sử dụng HTTPS
    sameSite: "strict",
    maxAge: 24 * 60 * 60 * 1000, // 24 giờ
  });
  res.redirect("/dashboard");
});

Giải thích:

  • HTTP-only: Cho phép truy cập cookie chỉ qua HTTP/HTTPS, ngăn chặn việc truy cập từ JavaScript, giảm nguy cơ XSS.
  • Secure: Chỉ gửi cookie qua HTTPS, không gửi qua HTTP.
  • Same-site: Chỉ gửi cookie trong cùng trang web, ngăn chặn tấn công CSRF.
  • maxAge: Đặt thời gian tồn tại cho cookie.

Cảnh báo:

Việc sử dụng HTTP-only cookie có nghĩa là bạn không thể truy cập từ phía client bằng JavaScript, do đó cần chủ động xử lý khi token hết hạn hoặc cần làm mới.

Hồi 5: Tối Ưu Hiệu Năng - “Chạy Đua” Với Re-render

5.1. Re-render Không Cần Thiết: “Kẻ Thù” Của Performance

Mỗi lần state hoặc props thay đổi, React sẽ re-render component. Re-render quá nhiều sẽ làm giảm hiệu năng ứng dụng.

Ví dụ:

function ParentComponent() {
  const [count, setCount] = useState(0);

  const expensiveCompute = () => {
    console.log("Computing...");
    // Đơn giản giả lập tính toán tốn thời gian
    for (let i = 0; i < 1e7; i++) {}
    return count * 2;
  };

  return (
    <div>
      <ChildComponent value={expensiveCompute()} />
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

function ChildComponent({ value }: { value: number }) {
  console.log("Rendering ChildComponent");
  return <div>Value: {value}</div>;
}

Trong ví dụ trên, mỗi khi setCount được gọi, ParentComponent sẽ re-render, do đó expensiveCompute cũng sẽ được thực hiện lại, gây giảm hiệu năng.

Giải pháp:

  • React.memo: Bọc component để ngăn re-render nếu props không thay đổi.
  • useMemo: Memoize giá trị tính toán.
  • useCallback: Memoize callback functions.

Sử dụng React.memo

const ChildComponent = React.memo(({ value }: { value: number }) => {
  console.log("Rendering ChildComponent");
  return <div>Value: {value}</div>;
});

Trong đoạn mã trên, ChildComponent chỉ re-render khi value thay đổi, tránh việc re-render dư thừa.

Sử dụng useMemo:

function ParentComponent() {
  const [count, setCount] = useState(0);

  const computedValue = useMemo(() => {
    console.log("Computing...");
    for (let i = 0; i < 1e7; i++) {}
    return count * 2;
  }, [count]); // Chỉ tính toán lại khi `count` thay đổi

  return (
    <div>
      <ChildComponent value={computedValue} />
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

const ChildComponent = ({ value }: { value: number }) => {
  console.log("Rendering ChildComponent");
  return <div>Value: {value}</div>;
};

Sử dụng useMemo giúp tránh việc thực hiện lại hàm expensiveCompute mỗi khi ParentComponent re-render, mà chỉ thực hiện khi count thay đổi.

Lưu ý: Mình biết React 19 đã tự động tối ưu điều này, chúng ta sẽ đề cập đến nó sau.

5.2. “Code Thừa” - “Thủ Phạm” Âm Thầm Làm Chậm Website

Một trong những vấn đề nhức nhối mà lại ít người nhắc tới là lượng code thừa mà bạn vẫn phải “gánh” trong bundle. Chúng có thể là:

  • Import thừa: Đây là thứ thường xảy ra nhất do các IDE ngày nay đã hỗ trợ chúng ta rất nhiều trong việc import, đôi khi chúng ta không dùng các component, các functions, các thư viện đó nhưng chúng vẫn được import vào.
  • Dependency “Phình To”: Các thư viện bạn sử dụng có thể chứa rất nhiều code mà bạn không bao giờ dùng đến. Việc sử dụng các thư viện lớn cho những tác vụ nhỏ có thể khiến bundle size phình to một cách không cần thiết. Lâu dần, những thư viện đó ngày càng được cập nhật, phiên bản sau lại to hơn phiên bản trước, hãy cẩn thận.
  • Dead Code: Code không bao giờ được thực thi (ví dụ: các hàm, biến không được sử dụng) cũng có thể “lẻn” vào bundle của bạn.

Tại sao lại quan trọng?

  • Tăng thời gian tải: Bundle lớn hơn đồng nghĩa với việc trình duyệt phải tải xuống nhiều code hơn, làm chậm thời gian tải trang.
  • Tốn băng thông: Người dùng phải tải xuống nhiều dữ liệu hơn, đặc biệt là trên các kết nối chậm hoặc thiết bị di động.
  • Giảm hiệu suất: Trình duyệt mất nhiều thời gian hơn để parse (phân tích cú pháp) và compile (biên dịch) JavaScript.

Giải pháp:

  • Tree-shaking: Webpack và các bundler hiện đại khác hỗ trợ “tree-shaking”, giúp loại bỏ code không sử dụng trong quá trình build. Hãy đảm bảo rằng bạn đã bật tính năng này. Nhưng hãy nhớ rằng, tree-shaking chỉ hiệu quả khi các thư viện được viết theo hướng hỗ trợ tree-shaking (ví dụ: sử dụng ES modules).

  • Sử dụng import() động (Dynamic Imports): Như đã đề cập trong phần code-splitting, import() động giúp bạn chỉ tải code khi cần thiết.

  • Phân tích Bundle Size: Sử dụng các công cụ như webpack-bundle-analyzer để “mổ xẻ” bundle của bạn, xem code nào đang chiếm nhiều dung lượng nhất. Từ đó, bạn có thể tìm cách tối ưu hóa hoặc thay thế các thư viện “nặng ký”.

  • Kiểm tra các dependency: Hãy cẩn thận với các thư viện “to bự”, chỉ dùng một vài tính năng nhỏ trong đó. Cân nhắc sử dụng các thư viện nhỏ hơn, chuyên biệt hơn, hoặc tự viết code thay thế nếu có thể.

  • Loại bỏ code thừa từ dependencies: Sau khi phân tích kỹ bundle size, bạn có thể tìm cách loại bỏ code thừa từ dependencies bằng cách sử dụng các plugin của webpack (ví dụ: IgnorePlugin để bỏ qua các modules không cần thiết).

  • Kiểm tra import thừa: Đây là lúc bạn cần kiểm tra lại các file code xem có import nào không sử dụng không, hãy xoá chúng nếu bạn chắc chắn không dùng đến, đừng để các import thừa trong code của bạn.

  • Ví dụ: Thay vì import toàn bộ thư viện lodash, bạn chỉ cần import những hàm cụ thể mà bạn cần:

    // Thay vì:
    // import _ from 'lodash';
    
    // Hãy import thế này:
    import debounce from "lodash/debounce"; // Chỉ import hàm debounce
    
  • Ví dụ: Sử dụng webpack-bundle-analyzer để phân tích bundle:

    # Cài đặt:
    npm install --save-dev webpack-bundle-analyzer
    
    # Thêm vào file webpack.config.js:
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    
    module.exports = {
      // ...
      plugins: [
        new BundleAnalyzerPlugin()
      ]
    };
    

    Sau đó, chạy npm run build, webpack-bundle-analyzer sẽ mở một trang web hiển thị trực quan cấu trúc bundle của bạn, giúp bạn dễ dàng xác định các “thủ phạm” gây phình to bundle size.

Câu chuyện thực tế:

Trong một dự án, mình phát hiện ra rằng bundle size của mình quá lớn, mặc dù đã áp dụng code-splitting. Sau khi phân tích bằng webpack-bundle-analyzer, mình nhận ra rằng một thư viện UI mà mình đang sử dụng chiếm phần lớn dung lượng, mặc dù mình chỉ dùng một vài component nhỏ của nó. Mình đã quyết định thay thế thư viện đó bằng một thư viện nhỏ gọn hơn, chuyên biệt hơn, và kết quả là bundle size đã giảm đáng kể, giúp cải thiện tốc độ load trang lên “không phải dạng vừa” đâu! 😎

Lời khuyên:

  • Hãy thường xuyên kiểm tra bundle size và tìm cách tối ưu hóa.
  • Cẩn thận với các thư viện “khổng lồ”.
  • Loại bỏ code thừa, dependencies không cần thiết.
  • Sử dụng các công cụ phân tích bundle size để “truy tìm” thủ phạm.

Nhớ rằng: Tối ưu hóa bundle size là một quá trình liên tục, đòi hỏi sự tỉ mỉ và kiên nhẫn. Nhưng tin mình đi, kết quả đạt được sẽ rất “đáng đồng tiền bát gạo”! 😉

Kết: “Trưởng Thành” Qua Những Lần “Vấp Ngã”

Debugging là một phần tất yếu của lập trình. Vấp ngã là chuyện bình thường, quan trọng là bạn học được gì từ những lần vấp ngã đó. Hy vọng series bài viết này sẽ giúp bạn vững tay chèo hơn trên con đường trở thành một React developer xịn sò.

Nhớ nhé:

  • Luôn cập nhật kiến thức mới (React 19 ra rồi đấy 😉).
  • Thực hành, thực hành, và thực hành.
  • Đừng ngại chia sẻ kinh nghiệm và học hỏi từ cộng đồng.

Bài viết tiếp theo:

Trong phần 2 của series “Debugging React”, chúng ta sẽ đi sâu vào các vấn đề liên quan đến render server và rendering client, đồng thời khám phá cách tối ưu hóa ứng dụng khi sử dụng React Server Components. Hẹn gặp lại các bạn vào tháng sau!

Tài Liệu Tham Khảo: