tu-huynh
tuhuynh
.com
Blog

React Rendering under the hood

Tú @ Grokking Newsletter wrote

Lưu ý: Bài viết này đã được cập nhật (vào tháng 3 năm 2025) để phản ánh những thay đổi trong các phiên bản React gần đây (bao gồm React 18) về Concurrent Features và Automatic Batching và React Compiler (của React 19)

Bài viết mô tả chi tiết cách React render hoạt động under-the-hood, cách ưu hóa re-render và giải thích việc sử dụng Context và Redux ảnh hưởng thế nào tới quá trình Render của React.

“Render” là gì

Rendering is the process of React asking your components to describe what they want their section of the UI to look like, now, based on the current combination of props and state.

Trong React, “render” là quá trình mà React yêu cầu các component mô tả giao diện của chúng. Mỗi component sẽ trả về một bản mô tả chi tiết về cách nó muốn hiển thị trên màn hình, dựa trên hai yếu tố chính: props (dữ liệu được truyền từ parent component) và state (internal state của component).

Tổng quan quá trình Render

Trong quá trình render, React sẽ bắt đầu với root component tree và lặp dần xuống dưới các component con để tìm ra những component đã được đã đánh dấu là cần cập nhật. Với mỗi component được đánh dấu này, React sẽ chạy classComponentInstance.render() (đối với các class-component) hoặc là chạy FunctionComponent() (đối với các functional-component) để lấy được output của quá trình render.

Render output của 1 component thường được viết bằng JSX, trong quá trình build (compile), JSX sẽ được convert thành các hàm React.createElement(). createElement trả về React elements (hay còn được biết đến với tên “Virtual DOM”), dưới dạng plain JS Object - cung cấp mô tả về cấu trúc của UI Component. Ví dụ:

// Đây là JSX:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>

// Khi build xong sẽ được convert thành:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")

// Và khi trình duyệt execute compiled code, nó sẽ tạo ra React element object như sau:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

Sau khi thu thập đủ render output từ component tree (kết quả là 1 React element object), React sẽ so sánh (diff) virtual DOM mới và virtual DOM hiện tại, thu được một tập hợp các thay đổi thực sự cần được cập nhật vào real DOM, quá trình so sánh và tính toán này được gọi là ”reconciliation“.

React sau đó áp dụng tất cả các thay đổi đã được tính toán ở trên lên cây DOM thật trong một thứ tự đồng bộ (Render Phase và Commit Phases).

Render Phase và Commit Phases

React team chia quá trình cập nhật giao diện thành 2 giai đoạn chính:

  • Render Phase: Trong giai đoạn này, React gọi hàm render của các component cần cập nhật (bao gồm cả class render() và functional component body), tạo ra một cây Virtual DOM mới mô tả giao diện mong muốn, và so sánh (diffing) nó với cây Virtual DOM trước đó để xác định những thay đổi cần thiết. Giai đoạn này có thể được React tạm dừng, hủy bỏ hoặc thực hiện lại trong Concurrent Mode để ưu tiên các tác vụ quan trọng hơn.
  • Commit Phase: Sau khi tính toán xong các thay đổi ở Render Phase, React sẽ áp dụng những thay đổi đó lên cây DOM thật của trình duyệt. Giai đoạn này diễn ra đồng bộ và không thể bị ngắt quãng. Ngay sau khi DOM được cập nhật, React sẽ chạy các componentDidMount, componentDidUpdate (đối với class components) và useLayoutEffect hooks.

Sau khi Commit Phase hoàn tất và trình duyệt đã sơn (paint) các thay đổi lên màn hình, React sẽ chạy các useEffect hooks. Giai đoạn chạy useEffect này đôi khi được gọi là “Passive Effects” phase. Việc trì hoãn chạy useEffect cho đến sau khi trình duyệt paint giúp đảm bảo rằng các logic bên trong hook (ví dụ: fetch dữ liệu) không làm chặn quá trình hiển thị giao diện ban đầu, giúp ứng dụng cảm thấy mượt mà hơn.

Bạn có thể xem visualization của các class lifecycle methods tại đây.

Trong các phiên bản React gần đây với Concurrent Features, React có khả năng tạm dừng công việc trong “Render Phase” để cho phép trình duyệt xử lý các sự kiện. React sẽ tiếp tục, hủy bỏ hoặc tính toán lại công việc đó sau tùy thuộc vào tình huống. Một khi quá trình render hoàn tất, React vẫn sẽ chạy “Commit Phase” một cách đồng bộ trong một bước duy nhất.

Trọng tâm của phần này là hiểu rằng “rendering” không phải là “updating the DOM”, một component có thể được render mà không thay đổi gì trên DOM thật. Khi React render component:

  • Nếu component trả về render output giống với lần render trước đó, sẽ không có thay đổi nào cần được áp dụng (lên DOM thật) -> không commit gì cả.
  • In Concurrent Mode, React might end up rendering a component multiple times, but throw away the render output each time if other updates invalidate the current work being done

Làm thế nào React handle Renders?

Queuing Renders

Sau khi lần render đầu tiên (initial) được hoàn thành, có một vài cách để kích hoạt React render trên một vài component (đánh dấu là component đó cần update và React sẽ thực hiện quá trình re-render sau đó):

  • Class components:
    • this.setState()
    • this.forceUpdate()
  • Functional components:
    • useState setters
    • useReducer dispatches
  • Khác:
    • Gọi ReactDOM.render(<App/>) lại lần nữa, tương đương với việc gọi forceUpdate() tại component root.
    • Sử dụng flushSync để buộc React thực hiện cập nhật state đồng bộ, thoát khỏi cơ chế batching tự động.

Render Behavior tiêu chuẩn

Có một quy tắc cốt lõi trong React mà bạn cần nắm rõ:

Mặc định, khi một parent component re-render, React sẽ tự động re-render tất cả child components bên trong nó, bất kể props của các child components có thay đổi hay không!

Hãy xem một ví dụ cụ thể. Giả sử chúng ta có một cây component A > B > C > D (trong đó A là parent component của B, B là parent của C, và C là parent của D). Sau khi app đã render lần đầu tiên và hiển thị UI, người dùng click vào một button trong component B để tăng một biến đếm:

  • Khi ta gọi setState() trong B, React đánh dấu B là cần được cập nhật
  • React bắt đầu quá trình render từ đỉnh của component tree
  • React kiểm tra và thấy A không bị đánh dấu cần cập nhật, nên bỏ qua
  • React thấy B đã bị đánh dấu cần cập nhật, nên thực hiện render B. Kết quả render của B bao gồm component C
  • Mặc dù C không bị đánh dấu cần cập nhật, nhưng vì parent component B vừa re-render, React sẽ tự động re-render C. C render ra component D
  • Tương tự, D cũng sẽ bị re-render theo, không phải vì nó cần cập nhật mà đơn giản vì parent component C của nó đã re-render

Để nhấn mạnh lại:

Khi một component re-render, tất cả child components bên trong nó cũng sẽ re-render theo, đây là behavior mặc định của React!

Một điểm quan trọng khác cần lưu ý:

Trong quá trình render thông thường, React không quan tâm đến việc props có thay đổi hay không - nó sẽ re-render tất cả child components một cách vô điều kiện nếu parent component của chúng re-render

Điều này có nghĩa là nếu bạn gọi setState() ở root component <App>, toàn bộ components trong ứng dụng sẽ bị re-render.

Trong nhiều trường hợp, phần lớn các components sẽ trả về kết quả render giống hệt lần trước, và React sẽ không cần cập nhật gì trên DOM thật. Tuy nhiên, React vẫn phải thực hiện công việc: chạy hàm render của từng component, chờ kết quả render và so sánh với kết quả của lần render trước - tất cả những việc này đều tiêu tốn thời gian và tài nguyên CPU.

Component Types và Reconciliation

Như đã được mô tả trong “Reconciliation” docs page, logic render cuả React so sánh các element dựa trên type field đầu tiên, dùng phép so sánh ===. Nếu một element trong một vị trí thay đổi thành một type khác, như từ <div> thành <span> hay là từ <ComponentA> sang <ComponentB>, React sẽ giả định rằng toàn bộ cây con bên dưới component đó đã thay đổi hoàn toàn. Kết quả là, React sẽ xóa bỏ tất cả component render output hiện tại, gồm tất cả các DOM nodes (DOM thật), và tạo lại nó từ đầu với một component instance mới thay vì cố gắng so sánh chi tiết các cây con.

Điều này có nghĩa rằng bạn không bao giờ được tạo một component type mới trong hàm render() (hoặc trong function body của functional component), bởi vì khi bạn tạo một component type mới, nó có một reference mới (vì nó là object mà), điều này sẽ khiến React liên tục xóa và tạo lại cả component sau mỗi lần render.

Nói cách khác, đừng làm thế này:

function ParentComponent() {
  // Dòng này sẽ tạo ra một referrence của ChildComponent mỗi lần render!
  function ChildComponent() {}
  
  return <ChildComponent />
}

Thay vào đó, luôn define component tách biệt:

// Dòng này sẽ chỉ tạo ra 1 component type
function ChildComponent() {}
  
function ParentComponent() {
  return <ChildComponent />
}

Cải thiện hiệu năng Render

Như đã đề cập ở trên, quá trình render của React có thể là dư thừa và gây mất thời gian/tài nguyên ở mỗi lần chạy. Nếu render output của một component không đổi, và không có cập nhật nào cần thiết lên DOM thật, thì quá trình rendering thật sự là lãng phí và thừa thãi.

React component render output khác nhau sẽ dựa trên việc props hiện tại và component state hiện tại có bị thay đổi không. Vì thế, nếu ta biết trước rằng một component props và state sẽ không bị đổi, ta cũng sẽ biết là render ouput sau lần render của component đó sẽ y chang với lần trước, và không có thay đổi nào cần được áp dụng, và ta có thể bỏ qua việc chạy re-render trên component đó.

Khi cố gắng cải thiện hiệu năng phần mềm nói chung, sẽ có 2 cách tiếp cận cơ bản:

  • Làm hệ thống chạy một task nào đó nhanh hơn (1)
  • Làm hệ thống phải chạy ít task hơn (2)

Tối ưu hóa React Rendering chủ yếu là việc cố gắng bỏ qua các lần re-render không cần thiết (2).

Render Batching và Timing

Mặc định, mỗi lần gọi setState() khiến React bắt đầu một quá trình render mới, một cách đồng bộ, và trả về. Tuy nhiên, React cũng ứng dụng một loại tối ưu hóa tự động, được gọi là “render batching”. Render batching là việc React nhóm nhiều lần cập nhật state xảy ra gần nhau vào một lần re-render duy nhất để cải thiện hiệu năng.

React docs có đề cập tới đoạn “state updates may be asyncronous”, phần lớn là do cơ chế Render Batching này. Trước React 18, React chỉ tự động batch các state updates xảy ra trong các React event handlers.

Tuy nhiên, kể từ React 18, React mặc định thực hiện Automatic Batching cho tất cả các state updates, bất kể chúng được kích hoạt từ đâu (trong Promises, setTimeout, native event handlers, hay bất kỳ nguồn nào khác). Điều này giúp giảm số lần re-render không cần thiết một cách đáng kể.

React implements render batching cho các event handlers bằng cách wrap chúng lại trong một internal function được gọi là unstable_batchedUpdates. React theo dõi tất các các state updates được gọi (gọi setState(), …) khi unstable_batchedUpdates đang chạy, và sau đó áp dụng chúng trong một lần render duy nhất.

Về mặt khái niệm, bạn có thể hình dung những gì React đang hoạt động bên trong như đoạn mã giả dưới đây:

function internalHandleEvent(e) {
  const userProvidedEventHandler = findEventHandler(e);
  
  let batchedUpdates = [];
  
  unstable_batchedUpdates(() => {
    // mọi state updates được gọi tại đây sẽ được push vào batchedUpdates
    userProvidedEventHandler(e);
  });
  
  renderWithQueuedStateUpdates(batchedUpdates);
}

Tuy nhiên, điều này có nghĩa rằng tất cả những state updates mà nằm ngoài immediate call stack của cái event handler đó sẽ không được React batch lại.

Lấy ví dụ sau (với React < 18):

const [counter, setCounter] = useState(0);

const onClick = async () => {
  setCounter(0);
  setCounter(1);
  
  const data = await fetchSomeData();
  
  setCounter(2);
  setCounter(3);
}

Trong React < 18, đoạn code này sẽ thực hiện 2 lần render. Lần đầu tiên, setCounter(0)setCounter(1) được batch vì nằm trong event handler. Lần thứ hai, setCounter(2)setCounter(3) nằm trong callback của fetchSomeData (giả sử là một Promise), chúng sẽ không được batch và mỗi lần gọi sẽ kích hoạt một re-render riêng biệt (tổng cộng 2 re-render cho phần async).

Với React 18 trở lên, nhờ Automatic Batching, cả 4 lần gọi setCounter trong ví dụ trên sẽ được nhóm lại và chỉ gây ra một lần re-render duy nhất.

Nếu có trường hợp bạn thực sự cần thoát khỏi cơ chế batching và muốn React cập nhật DOM ngay lập tức sau một state update cụ thể, bạn có thể sử dụng ReactDOM.flushSync().

import { flushSync } from 'react-dom';

// ...

const onClick = () => {
  flushSync(() => {
    setCounter(prev => prev + 1);
  });
  // Ngay sau dòng trên, DOM đã được cập nhật với counter mới
  flushSync(() => {
    setOtherState(prev => prev + 1);
  });
  // DOM lại được cập nhật ngay lập tức
}

Lưu ý: Sử dụng flushSync một cách tiết kiệm vì nó có thể ảnh hưởng đến hiệu năng.

Một lưu ý nữa là: React sẽ double-render components bên trong thẻ <StrictMode> trong development mode, nên bạn không nên dựa vào console.log() để đếm số lần re-render của một component. Thay vào đó, hãy dùng React DevTools Profiler để capture tracing, hoặc thêm 1 cái logging vào useEffect hook hoặc componentDidMount/Update lifecycle - log đó sẽ chỉ được in ra khi React thực sự hoàn thành render và commit changes vào DOM thật.

Các kĩ thuật tối ưu hóa cho Component Render

React cung cấp cho chúng ta 3 APIs để cho phép bỏ qua quá trình re-render trên một component:

  • React.Component.shouldComponentUpdate: là một optional class component lifecycle method sẽ được gọi trước khi render process diễn ra. Nếu method này trả về false, React sẽ bỏ qua việc re-render component. Một cách sử dụng phổ biến của method này là kiểm tra nếu component props và state thay đổi hay chưa.
  • React.PureComponent: đây là một Base Class thay thế cho React.Component, implement sẵn hàm shouldComponentUpdate bằng cách so sánh nông (shallow comparison) props và state mới với cũ.
  • React.memo() là một built-in “higher order component”. Nó nhận vào tham số là một component, và trả về một wrapper component. Default behavior của wrapper component này là kiểm tra props có bị đổi không (bằng shallow comparison), và nếu không thì ngăn chặn re-render. Cả functional component và class component đều có thể được wrap bởi React.memo(). React.memo chỉ so sánh props, không so sánh state.

Tất cả các cách tiếp cận trên dùng một kĩ thuật so sánh được gọi là “shallow equality” (so sánh nông). Có nghĩa là nó sẽ kiểm tra tất cả các field riêng lẻ trong 2 objects xem có cùng value không (dùng ===). Nói cách khác, obj1.a === obj2.a && object1.b === object2.b && ....

Ngoài ra còn một kĩ thuật tối ưu ít được biết đến hơn của React: nếu một React component trả về render output là element reference giống với lần trước đó, React sẽ bỏ qua việc re-render.

Cho tất cả các kĩ thuật này, bỏ qua re-render một component đồng nghĩa với việc React sẽ cũng bỏ qua render trên cả subtree element của component đó (“render children recursively” behavior).

Tối ưu hóa Props Reference

Class Components và Instance Methods

Với class components, bạn có một lợi thế là các instance methods luôn giữ cùng một reference. Điều này có nghĩa là bạn không cần lo lắng về việc tạo ra các callback mới mỗi khi component re-render:

class ParentComponent extends React.Component {
  // Method này sẽ luôn có cùng một reference
  handleClick = () => {
    console.log('Button clicked');
  }

  render() {
    return <ChildComponent onClick={this.handleClick} />;
  }
}

Tuy nhiên, khi bạn cần truyền thêm dữ liệu vào callback hoặc tạo callbacks riêng cho từng item trong một danh sách, bạn buộc phải tạo ra các function mới:

class ParentComponent extends React.Component {
  // Phải tạo function mới mỗi lần render vì cần truyền id
  render() {
    return (
      <ul>
        {items.map(item => (
          <ListItem 
            key={item.id}
            onClick={() => this.handleItemClick(item.id)} // New function mỗi lần render!
          />
        ))}
      </ul>
    );
  }
}

Với class components, React không cung cấp giải pháp built-in nào để tối ưu hóa những trường hợp này.

Functional Components và Hooks Tối Ưu

Đối với functional components, React cung cấp hai hooks mạnh mẽ để tối ưu hóa references: useMemouseCallback.

1. useMemo Hook

useMemo giúp “ghi nhớ” (memoize) một giá trị phức tạp, chỉ tính toán lại khi dependencies thay đổi. Đặc biệt hữu ích khi:

  • Tính toán giá trị tốn nhiều tài nguyên
  • Tạo objects/arrays để truyền xuống components con đã được tối ưu hóa

Ví dụ không tối ưu:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // Object này được tạo mới mỗi lần render
  const data = {
    title: "My Title",
    count: count,
    items: expensiveCalculation(count)
  };

  return <MemoizedChildComponent data={data} />;
}

Ví dụ đã tối ưu với useMemo:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // Object chỉ được tạo lại khi count thay đổi
  const data = useMemo(() => ({
    title: "My Title",
    count: count,
    items: expensiveCalculation(count)
  }), [count]);

  return <MemoizedChildComponent data={data} />;
}
2. useCallback Hook

useCallback chuyên dùng để “ghi nhớ” các function callbacks, tránh việc tạo function mới mỗi lần render. Đặc biệt quan trọng khi:

  • Truyền callbacks xuống các components con đã được tối ưu (React.memo)
  • Callbacks được sử dụng trong useEffect dependencies

Ví dụ không tối ưu:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // Function mới được tạo mỗi lần ParentComponent render
  const handleClick = (itemId) => {
    console.log(`Clicked item ${itemId}`);
    setCount(count + 1);
  };

  return (
    <ul>
      {items.map(item => (
        <MemoizedListItem
          key={item.id}
          onClick={() => handleClick(item.id)} // MemoizedListItem sẽ luôn re-render!
        />
      ))}
    </ul>
  );
}

Ví dụ đã tối ưu với useCallback:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // Function chỉ được tạo lại khi count thay đổi
  const handleClick = useCallback((itemId) => {
    console.log(`Clicked item ${itemId}`);
    setCount(count + 1);
  }, [count]);

  return (
    <ul>
      {items.map(item => (
        <MemoizedListItem
          key={item.id}
          onClick={() => handleClick(item.id)}
        />
      ))}
    </ul>
  );
}

Khi nào nên sử dụng useMemo và useCallback?

Không phải lúc nào cũng cần dùng useMemouseCallback. Chỉ nên sử dụng khi:

  1. Component con đã được tối ưu hóa bằng React.memo
  2. Tính toán giá trị tốn nhiều tài nguyên
  3. Reference stability quan trọng (ví dụ: được dùng trong useEffect dependencies)

Lạm dụng các hooks này có thể làm code phức tạp và thậm chí gây tác dụng ngược về mặt hiệu năng.

Tại sao React không mặc định wrap mọi component với React.memo?

Một câu hỏi hay được hỏi nhiều là “Tại sao mặc định React không wrap mọi thứ bằng React.memo?”

Dan Abramov đã nhiều lần chỉ ra rằng dù là memoization thì bạn vẫn phải tốn chi phí cho việc so sánh props: độ phức tạp O(prop count), và trong một số trường hợp thì memoization không bao giờ có thể ngăn chặn re-render vì component luôn nhận vào props mới. Lấy ví dụ, xem Twitter thread này của Dan:

Why doesn’t React put memo() around every component by default? Isn’t it faster? Should we make a benchmark to check?

Ask yourself:

Why don’t you put Lodash memoize() around every function? Wouldn’t that make all functions faster? Do we need a benchmark for this? Why not?

Immutability và State Updates

Ngoài ra, việc mặc định áp dụng React.memo() cho tất cả component sẽ dẫn tới kết quả là tạo ra bug trong những trường hợp developer cố ý/vô tình mutate object (obj.a = 'changed') thay vì cập nhật nó theo cách immutable (obj = {...obj, a: 'changed' }).

State update trong React nên được thực hiện một cách immutably. Có 2 lý do chính:

  • Đảm bảo phát hiện thay đổi: Các cơ chế tối ưu hóa như React.memo, PureComponent, và shouldComponentUpdate dựa vào việc so sánh reference (shallow equality) để quyết định có nên re-render hay không. Nếu bạn mutate trực tiếp một object/array, reference của nó không đổi, React sẽ nghĩ rằng không có gì thay đổi và bỏ qua việc re-render cần thiết, dẫn đến UI không được cập nhật đúng.
  • Dự đoán và Debug: Immutability giúp việc theo dõi và hiểu luồng dữ liệu dễ dàng hơn. Bạn biết rằng state không bị thay đổi bất ngờ ở những nơi khác trong code.

Ví dụ về Mutation vs Immutability

Ví dụ về mutation (không nên làm):

const [todos, setTodos] = useState(someTodosArray);

const onClick = () => {
  todos[3].completed = true;  // Mutation trực tiếp
  setTodos(todos);  // Cùng reference -> không re-render
}

Cách làm đúng với immutability:

const onClick = () => {
  const newTodos = todos.slice();  // Tạo array mới
  newTodos[3].completed = true;
  setTodos(newTodos);  // Reference mới -> trigger re-render
}

Sự khác biệt giữa Class và Function Components

Có một sự khác biệt quan trọng về behavior của class component this.setState và functional component useState/useReducer về mutations và re-rendering:

// Với class component - vẫn re-render dù mutate
const {todos} = this.state;
todos[3].completed = true;
this.setState({todos});  // Vẫn re-render

// Với functional component - không re-render khi mutate
const [todos, setTodos] = useState(someTodosArray);
todos[3].completed = true;
setTodos(todos);  // Không re-render vì cùng reference

Nói tóm lại: React, và hệ sinh thái của nó, được thiết kế dựa trên giả định rằng các cập nhật state là immutable. Việc mutate state có thể dẫn đến các hành vi không mong muốn, khó debug và UI không nhất quán. Hãy luôn tạo ra các object/array mới khi cập nhật state thay vì thay đổi trực tiếp cái cũ!

Context và Rendering Behavior

React’s Context API là một cơ chế để tạo ra và chia sẻ một giá trị (state) nào đó xuống cây component mà không cần phải truyền props qua từng cấp. Bất kỳ component nào nằm trong Provider của Context đều có thể “đọc” giá trị đó.

Context chủ yếu là một cơ chế phân phối state (state propagation mechanism), không phải là một giải pháp quản lý state hoàn chỉnh. Bạn vẫn cần tự quản lý việc state đó được tạo ra và cập nhật như thế nào. Thông thường, điều này được thực hiện bằng cách lưu trữ state trong một React component (sử dụng useState hoặc useReducer) và sau đó truyền state đó vào value prop của Context Provider.

Context cơ bản

Context provider nhận vào một value prop, như là <MyContext.Provider value={42}>. Các component con có thể consume context đó bằng cách render context consumer và lấy value trong context bằng một render prop, ví dụ như sau:

<MyContext.Provider>
    {(value) => <div>{value}</div>}
</MyContext.Provider>

Hoặc với functional component, có thể xài useContext hook:

const value = useContext(MyContext);

Cập nhật Context Values

React kiểm tra nếu value trong context provider bị thay đổi khi các component bọc bên ngoài context-provider re-render. Nếu value của context-provider là một reference mới, thì React sẽ biết được là value đã bị thay đổi, và những components consume context đó cần được cập nhật.

Lưu ý rằng truyền new object vào context provider sẽ khiến nó update:

function GrandchildComponent() {
  const value = useContext(MyContext);
  return <div>{value.a}</div>
}

function ChildComponent() {
  return <GrandchildComponent />
}

function ParentComponent() {
  const [a, setA] = useState(0);
  const [b, setB] = useState("text");

  const contextValue = {a, b};

  return (
    <MyContext.Provider value={contextValue}>
      <ChildComponent />
    </MyContext.Provider>
  )
}

Trong ví dụ trên, mỗi khi ParentComponent re-render, React sẽ hiểu rằng MyContext.Provider đã được thay đổi giá trị mới (mặc dù có thể thật sự là không), và tìm các components đang consume MyContext để đánh dấu là cần được cập nhật. Khi một context provider có giá trị mới (giá trị reference mới), tất cả nested component đang consume context đó sẽ bị forced re-render.

Hiện tại, không có cách nào cho phép một component đang consume một context bỏ qua việc re-render do context value cập nhật value mới.

State Update, Context và Re-Renders

Tóm tắt lại một số ý từ đầu tới giờ:

  • Gọi setState() sẽ khiến component bị re-render
  • React sẽ mặc định render lặp đệ quy xuống tất cả các nested components
  • Context provider được cung cấp value từ component render nó
  • Value đó thường đến từ parent component của component render context provider

Điều này có nghĩa rằng mặc định thì, mọi update đến parent component mà render context provider sẽ khiến cho tất cả component con của nó re-render, bất kể những components đó có đọc value từ context hay không.

Nếu nhìn lại cái ví dụ Parent/Child/Grandchild ở trên, ta có thể thấy GrandchildComponent sẽ được re-render, nhưng không phải vì context-update - mà nó re-render là do ChildComponent re-render. Trong ví dụ ở trên không có một optimization nào được thể hiện để loại bỏ “unnecessary” re-renders, nên React sẽ mặc định re-render ChildComponentGrandchildComponent mỗi khi ParentComponent re-render. Nếu ParentComponent thay đổi giá trị trong MyContext.Provider, GrandchildComponent sẽ đọc được giá trị mới khi nó re-render và dùng nó, nhưng không phải việc update context khiến cho nó re-render.

Context Updates và Tối ưu hóa Re-render

Hãy thử sửa ví dụ ở trên để thực hiện một vài tối ưu hóa cho việc re-render, chúng ta cũng sẽ thêm một GreatGrandchildComponent ở dưới cùng:

function GreatGrandchildComponent() {
  return <div>Hi</div>
}

function GrandchildComponent() {
    const value = useContext(MyContext);
    return (
      <div>
        {value.a}
        <GreatGrandchildComponent />
      </div>
}

function ChildComponent() {
    return <GrandchildComponent />
}

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
    const [a, setA] = useState(0);
    const [b, setB] = useState("text");
    
    const contextValue = {a, b};
    
    return (
      <MyContext.Provider value={contextValue}>
        <MemoizedChildComponent />
      </MyContext.Provider>
    )
}

OK bây giờ, nếu ta gọi setA(42):

  • ParentComponent sẽ re-render
  • Một contextValue reference mới sẽ được tạo
  • React thấy rằng MyContext.Provider được cập nhật value mới, nên các consumer của MyContext sẽ cần được cập nhật. (1)
  • React sẽ thử re-render MemoizedChildComponent, nhưng vì nó được wrap trong React.memo(), vì không có props nào được truyền vào, nên sẽ không có props nào đổi. React sẽ bỏ qua việc render ChildComponent.
  • Tuy nhiên, vì value của MyContext đã bị thay đổi (do 1), nên sẽ có một số component consume cần được biết.
  • React tiếp tục lặp xuống dưới và thấy rằng GrandchildComponent đang đọc value trong MyContext, nên GrandchildComponent cần được re-render. GrandchildComponent sau đó được re-render, vì context value change.
  • GrandchildComponent render, component con của nó là GreatGrandchildComponent cũng sẽ bị re-render theo.

Nói cách khác, như trong twitt của Sophie Alpert:

That React Component Right Under Your Context Provider Should Probably Use React.memo

Redux và Rendering Behavior

“Xài Redux hay Context?” có vẻ là câu hỏi được nhắc tới nhiều nhất trong các cuộc tranh luận trong cộng đồng React dạo gần đây, sau khi React Hooks trở nên phổ biến. Thực tế là Redux và Context là 2 công cụ khác nhau để làm những việc khác nhau, chi tiết sẽ được nêu ra bên dưới.

Một kết luận hay được nêu ra trong các cuộc tranh luận giữa Context và Redux là: “Redux chỉ re-render những components thật sự cần re-render, nên hiệu năng của nó tốt hơn Context” (“context makes everything render, Redux doesn’t, use Redux”).

Điều đó có phần đúng, nhưng câu trả lời thực ra còn mang nhiều sắc thái hơn thế.

Redux Subscriptions

Có nhiều người nói rằng “Redux thực ra cũng dùng Context bên dưới thôi”, thực ra cũng phần nào đúng, nhưng Redux dùng Context để truyền Redux store instance, chứ không phải là state value. Điều đó có nghĩa là Redux luôn truyền một context value không thay đổi vào <ReactReduxContext.Provider> trong suốt quá trình App chạy.

Redux store chạy tất cả subscriber notification callbacks mỗi khi có 1 action được dispatch. React Component cần sử dụng Redux luôn subscribe vào Redux store, đọc giá trị mới nhận từ subscriber callbacks, so sánh giá trị đó, và force re-render nếu giá trị mới khác với giá trị hiện tại. Hàm subscription callback được xử lí bên ngoài React app (không được managed bởi React), và React app chỉ được thông báo khi có một React Component subscribe tới một data trong Redux, mà data đó vừa bị thay đổi (dựa trên giá trị trả về của mapState hoặc useSelector).

Behavior này của React dẫn đến các đặc điểm về hiệu năng rất khác với Context. Đúng vậy, có vẻ là sẽ có ít component phải re-render hơn xuyên suốt quá trình chạy, nhưng Redux sẽ phải luôn chạy hàm mapState/useSelector trên toàn bộ component tree (nghĩa là sẽ chạy hàm đó trên mỗi component subscribe vào Redux store) mỗi khi store state được cập nhật. Hầu hết các trường hợp, chi phí chạy những hàm selector này ít hơn chi phí cho React thực hiện các phase re-render, nên nó thường mang lại nhiều lợi ích hơn về mặt hiệu năng cho app, tuy nhiên nếu những hàm selector có bao gồm những hàm tính toán phức tạp, các transformations tốn kém hoặc vô tình luôn luôn trả về giá trị mới thì mọi thứ có thể bị làm chậm đi.

Khác biệt giữa connectuseSelector

connect là 1 higher-order component, nó trả về một wrapper component thực hiện tất cả công việc từ subscribe tới store, chạy mapStatemapDispatch, và truyền combined props từ store xuống component của bạn.

Wrapper component connect luôn hoạt động tương tự PureComponent/React.memo(), nhưng với một điểm khác: connect sẽ chỉ làm component của bạn re-render nếu combined props được truyền xuống component bị thay đổi. Thông thường, combined props cuối cùng được truyền xuống thường là một sự phối hợp giữa các {...ownProps, ...stateProps, ...dispatchProps}, nên mỗi props reference mới từ parent component truyền xuống sẽ khiến component của bạn re-render, tương tự như PureComponent/React.memo(). Bên cạnh parent props, mỗi reference mới được trả về từ Mapstate cũng sẽ khiến component re-render (vì thế bạn có thể customize cách mà ownProps/stateProps/dispatchProps được merged, để thay đổi re-render behavior này).

Mặt khác, useSelector là 1 hook được gọi bên trong functional component. Vì thế, useSelector không có cách nào ngăn chặn component của bạn re-render khi parent component của nó re-render!

Đây là một khác biệt then chố về hiệu năng giữa connectuseSelector. Với connect, mọi connected component sẽ hoạt động như PureComponent.

Vì vậy, nếu bạn chỉ sử dụng các functional components và useSelector, thì có khả năng là phần lớn các components trong App sẽ re-render nhiều hơn theo các thay đổi từ Redux store hơn là khi bạn xài connect, vì sẽ không có gì chặn việc component con re-render sau khi parent component của nó re-render.

Nếu điều đó trở thành một vấn đề về hiệu năng (quá nhiều re-renders), thì bạn nên wrap các functional component bằng React.memo() để chặn bớt việc component con re-render theo parent component.

Evan You (Vue’s author) said about React Hooks & Rendering

“React hooks leaves most of the component-tree level optimization (i.e. prevent unnecessary re-renders of child components) to the developers, requiring explicit usage of useMemo in most cases. Also, whenever a React component receives the children prop, it almost always has to re-render because the children prop will always be a fresh vdom tree on each render. This means a React app using hooks will be over-re-rendering by default. What’s worse, optimizations like useMemo cannot easily be auto-applied because (1) it requires the correct deps Array and (2) blindly adding it everywhere may block updates that should happen, similar to PureComponent. Unfortunately, most developers will be lazy and will not aggressively optimize their apps everywhere, so most React apps using hooks will be doing a lot of unnecessary CPU work.”

in Issue #89

Come to Vue


Bình luận của mình:

Nhận xét của Evan You, cha đẻ của Vue.js, không chỉ là góc nhìn từ một “đối thủ” mà còn phản ánh rất chính xác những trăn trở và khó khăn mà nhiều developer gặp phải khi làm việc với React trong các dự án thực tế, đặc biệt là về hiệu năng và tối ưu hóa.

  • Gánh nặng tối ưu hóa thủ công: Điểm cốt lõi mà Evan chỉ ra là hoàn toàn đúng: React Hooks đặt phần lớn trách nhiệm tối ưu hóa re-render (qua useMemo, useCallback, React.memo) lên vai lập trình viên. Trong thực tế, điều này thường dẫn đến hai thái cực: hoặc là bỏ qua tối ưu hóa, chấp nhận tình trạng “over-rendering” (render lại nhiều hơn cần thiết) làm giảm hiệu năng, đặc biệt khi app phức tạp dần lên; hoặc là cố gắng tối ưu hóa “triệt để”, dẫn đến code bị “ô nhiễm” bởi vô số các hook useMemo, useCallback lồng nhau, làm giảm khả năng đọc hiểu và bảo trì. Việc xác định đúng dependencies array cho các hook này cũng là một nguồn lỗi phổ biến.
  • “Over-rendering” là mặc định: Mô hình Virtual DOM và cơ chế re-render đệ quy từ trên xuống của React có nghĩa là, nếu không có sự can thiệp tối ưu hóa, bất kỳ thay đổi state nào ở parent component cũng có thể kích hoạt re-render cho toàn bộ cây component con bên dưới, ngay cả khi props của chúng không hề thay đổi. Chi phí cho việc diffing VDOM và chạy lại code của các component không thực sự thay đổi là một sự lãng phí tài nguyên CPU không hề nhỏ.
  • Vue.js - Một giải pháp hiệu quả hơn? Trái ngược với React, Vue.js sử dụng một hệ thống reactivity dựa trên theo dõi dependency tự động. Vue biết chính xác thành phần UI nào phụ thuộc vào state nào và chỉ re-render những phần thực sự cần thiết khi state thay đổi. Cách tiếp cận này giúp giảm đáng kể gánh nặng tối ưu hóa thủ công cho lập trình viên, mang lại hiệu năng tốt “out-of-the-box” trong nhiều trường hợp mà React đòi hỏi phải tối ưu hóa thủ công.
  • Sự trỗi dậy của Solid.js: Nhận thấy những hạn chế trong mô hình VDOM và tối ưu hóa thủ công của React, các framework mới như Solid.js đã ra đời. Solid.js lấy cảm hứng từ cú pháp của React (đặc biệt là Hooks) nhưng loại bỏ hoàn toàn Virtual DOM. Thay vào đó, nó sử dụng “fine-grained reactivity” (tương tự Vue nhưng ở mức độ chi tiết hơn và nằm trong quá trình compile thay vì runtime) để cập nhật trực tiếp DOM khi state thay đổi. Kết quả là hiệu năng vượt trội, gần với vanilla JavaScript, và developer gần như không cần bận tâm đến useMemo hay useCallback cho việc tối ưu hóa re-render component như trong React.

Tóm lại: Mặc dù React vẫn là một thư viện mạnh mẽ và phổ biến, nhận xét của Evan You đã chỉ ra những điểm yếu cố hữu trong mô hình rendering và tối ưu hóa của nó, gây ra không ít khó khăn trong thực tế. Các framework như Vue.js và đặc biệt là Solid.js đang cung cấp những giải pháp hấp dẫn, giải quyết trực tiếp các vấn đề về hiệu năng và trải nghiệm phát triển mà React để lại, đáng để các developer cân nhắc.


(Update*) React Concurrent Mode và React Compiler trong React 19

Concurrent Mode - Giải pháp cho “over-rendering”

React 19 đã chính thức đưa Concurrent Mode từ thử nghiệm thành tính năng mặc định, đánh dấu một bước tiến quan trọng trong việc giải quyết vấn đề “over-rendering” vốn tồn tại từ lâu trong React. Concurrent Mode giới thiệu một mô hình rendering mới, cho phép React kiểm soát và ưu tiên các tác vụ rendering một cách thông minh hơn.

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

Thay vì thực hiện tất cả các tác vụ rendering một cách đồng bộ và không thể gián đoạn như trước đây, Concurrent Mode cho phép React:

  • Ngắt quãng quá trình rendering: React có thể tạm dừng, tiếp tục hoặc hủy bỏ một quá trình rendering dựa trên độ ưu tiên của các updates.
  • Rendering song song: Có thể chuẩn bị nhiều phiên bản UI cùng một lúc mà không block main thread.
  • Selective Hydration: Cho phép các phần khác nhau của ứng dụng được hydrate độc lập, ưu tiên những phần người dùng tương tác.

So sánh với các framework khác:

  1. Vue.js:

    • Vue sử dụng fine-grained reactivity system để theo dõi chính xác dependencies của từng component
    • Chỉ re-render các components thực sự bị ảnh hưởng bởi state changes
    • Không cần một mode đặc biệt như Concurrent Mode vì đã tối ưu by default
  2. Solid.js:

    • Không sử dụng Virtual DOM, thay vào đó compile components thành các reactive computations
    • Updates được áp dụng trực tiếp vào DOM, chỉ những phần thực sự thay đổi
    • Hiệu năng cao hơn cả Vue và React nhờ loại bỏ hoàn toàn overhead của diffing và reconciliation

Concurrent Mode giải quyết vấn đề over-rendering:

  1. Thông minh hơn trong việc xử lý updates:

    • Phân loại updates thành urgent và non-urgent
    • Có thể tạm hoãn non-urgent updates để ưu tiên urgent updates
    • Tránh blocking UI cho những updates không quan trọng
  2. Tối ưu hóa rendering tree:

    • Transition API cho phép đánh dấu các updates có thể được hoãn lại
    • Suspense giúp điều phối loading states và tránh cascading spinners
    • Automatic batching thông minh hơn để giảm số lần re-render
  3. Cải thiện user experience:

    • UI luôn responsive, ngay cả khi đang xử lý heavy updates
    • Loading states được hiển thị mượt mà hơn
    • Tránh tình trạng UI bị “giật” khi có nhiều updates cùng lúc

Mặc dù Concurrent Mode không hoàn toàn giải quyết được vấn đề over-rendering như cách tiếp cận của Vue hay Solid.js, nó cung cấp các công cụ và APIs mạnh mẽ để developers có thể kiểm soát tốt hơn quá trình rendering và tạo ra trải nghiệm người dùng mượt mà hơn. Kết hợp với React Compiler (được giới thiệu bên dưới), React 19 đã có những bước tiến đáng kể trong việc cải thiện hiệu năng mặc định của framework.

React Compiler trong React 19

Nhận thức được gánh nặng tối ưu hóa thủ công đặt lên vai developer như đã đề cập ở phần trước (với useMemo, useCallback, React.memo), đội ngũ React đã và đang phát triển một giải pháp mang tính đột phá: React Compiler. Đã được giới thiệu cùng React 19, React Compiler được kỳ vọng sẽ thay đổi cách chúng ta viết và tối ưu hóa ứng dụng React.

React Compiler là gì?

Về cơ bản, React Compiler là một trình biên dịch tối ưu hóa (optimizing compiler) được thiết kế để tự động phân tích code React của bạn và áp dụng các kỹ thuật memoization tương tự như useMemo, useCallback, và React.memo một cách tự động trong quá trình build. Nó hiểu ngữ nghĩa của JavaScript và các quy tắc của React Hooks để xác định những phần nào của component có thể được ghi nhớ (memoized) một cách an toàn mà không làm thay đổi hành vi của ứng dụng.

Giải quyết bài toán tối ưu hóa thủ công:

Với React Compiler, developer sẽ không còn phải “rắc” useMemouseCallback khắp nơi trong code để tối ưu hóa re-render nữa. Trình biên dịch sẽ tự động thực hiện công việc này. Điều này mang lại nhiều lợi ích:

  • Code đơn giản hơn: Code của bạn sẽ trở nên gọn gàng, dễ đọc và dễ bảo trì hơn khi loại bỏ được các hook tối ưu hóa thủ công.
  • Ít lỗi hơn: Giảm thiểu nguy cơ gây lỗi do quên thêm dependencies hoặc thêm sai dependencies vào useMemo/useCallback.
  • Hiệu năng tốt hơn “mặc định”: Các ứng dụng sẽ có hiệu năng tốt hơn ngay từ đầu mà không cần developer phải can thiệp sâu vào việc tối ưu hóa từng component.

React Compiler không phải là một ý tưởng hoàn toàn mới (lấy cảm hứng từ các trình biên dịch trong Svelte hay Solid.js), nhưng việc tích hợp nó vào một hệ sinh thái lớn và lâu đời như React là một bước tiến quan trọng. Nó thể hiện nỗ lực của đội ngũ React trong việc cải thiện trải nghiệm phát triển và giải quyết một trong những điểm yếu lớn nhất của thư viện này. Mặc dù vẫn đang trong giai đoạn phát triển và thử nghiệm (đã được sử dụng nội bộ tại Meta), React Compiler hứa hẹn sẽ là một phần quan trọng của tương lai React.


Reference