Đừng memo bừa: Hiểu re-render trong React trước đã

Re-render là một trong những thứ mà mình nghĩ các bạn làm React rất dễ “biết dùng nhưng chưa chắc hiểu sâu”.

Thường khi app bị chậm, phản xạ đầu tiên của nhiều người là:

“Bọc React.memo đi.”
“Dùng useCallback đi.”
“Memo hết lại là nhanh.”

Nghe rất quen thuộc đúng không?

Nhưng thực tế, không phải vấn đề performance nào trong React cũng nên xử lý bằng memoization. Có những case chỉ cần đặt lại state đúng chỗ là app nhẹ đi rất nhiều, code cũng dễ hiểu hơn.

Re-render là gì?

Nói đơn giản, re-render là lúc React chạy lại component function để tính xem UI mới nên trông như thế nào.

React chạy lại component function để tính xem UI mới nên trông như thế nào chúng ta đã tìm hiểu ở 2 bài là Rendering cycleReconciliation

Ví dụ:

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

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

Mỗi lần bấm button, setCount chạy. State thay đổi. React gọi lại function App. Đó là re-render.

Không có re-render thì React app của mình gần như thành HTML tĩnh. User bấm gì cũng không đổi. Vì vậy, re-render không xấu. Nó là cơ chế giúp UI phản ứng với dữ liệu mới.

Vấn đề không nằm ở việc “có re-render”, mà là “re-render quá nhiều thứ không cần thiết”.

State update là nguồn gốc chính của re-render

Trong React, khi state thay đổi, component đang giữ state đó sẽ re-render. Và quan trọng hơn: các component con bên trong nó cũng sẽ bị re-render theo.

Ví dụ:

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>
        Open modal
      </button>

      {isOpen && <Modal />}

      <VerySlowComponent />
      <AnotherHeavyComponent />
      <BigList />
    </div>
  )
}

Nhìn qua thì có vẻ bình thường. Mình chỉ thêm một cái modal mà thôi. Nhưng vấn đề là isOpen nằm trong App. Khi bấm mở modal, App re-render. Và vì VerySlowComponent, AnotherHeavyComponent, BigList nằm bên trong App, chúng cũng bị re-render theo.

Trong khi mấy component đó đâu có liên quan gì tới cái modal đâu đúng không.

Đây là kiểu bug performance rất thực tế. Task thì nhỏ: “thêm cái button mở modal”. Nhưng sau khi làm xong, app tự nhiên giật giật lag lag luôn.

Một hiểu lầm rất phổ biến: “props đổi thì component re-render”

Câu này nghe rất quen đúng không nào.

Component re-render khi props thay đổi.

Nói vậy chưa đủ chính xác hoàn toàn đâu các bạn nhé.

Trong flow bình thường, React không tự ngồi canh từng props để xem nó có đổi hay không rồi re-render component. Thứ kích hoạt re-render thường là state update ở đâu đó phía trên.

Khi component cha re-render, component con bên trong nó cũng bị gọi lại, dù props có đổi hay không.

Ví dụ:

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

  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        Increase
      </button>

      <Child name="Evondev" />
    </>
  )
}

Ở trường hợp này thì name lúc nào cũng là "Evondev". Không đổi. Nhưng mỗi lần count thay đổi, Parent re-render, và Child cũng re-render theo. Trừ khi bạn dùng React.memo, lúc đó React mới kiểm tra props để quyết định có bỏ qua re-render hay không.

Nhưng đừng vội memo mọi thứ. Memoization có chi phí riêng, có độ phức tạp riêng, và nếu dùng bừa thì code rất dễ rối. Về vấn đề Memoization mình sẽ có 1 bài riêng sau.

Cách xử lý đơn giản hơn: đưa state xuống thấp hơn(moving state down)

Quay lại ví dụ modal lúc nãy. Vấn đề là state isOpen đang nằm quá cao, ở App.

Trong khi thực tế, state này chỉ phục vụ cho button và modal. Vậy thì mình tách nó ra một component nhỏ hơn:

function ButtonWithModal() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        Open modal
      </button>

      {isOpen && (
        <Modal onClose={() => setIsOpen(false)} />
      )}
    </>
  )
}

Sau đó App chỉ render component này:

function App() {
  return (
    <div>
      <ButtonWithModal />

      <VerySlowComponent />
      <AnotherHeavyComponent />
      <BigList />
    </div>
  )
}

Bây giờ khi mở modal, state update xảy ra trong ButtonWithModal. React chỉ cần re-render ButtonWithModal và những thứ bên trong nó. Mấy component nặng ở ngoài không bị kéo vào cuộc nữa.

Đây là pattern rất quan trọng mà các bạn cần chú ý

State dùng ở đâu thì cố gắng đặt gần đó nhất có thể.

Không phải state nào cũng nhét lên component cha. Không phải cứ “đưa state lên cao cho dễ quản lý” là tốt. Đưa quá cao thì mỗi lần state đổi, cả một nhánh UI lớn bị re-render theo.

Custom hook không làm re-render biến mất

Một lỗi khác cũng khá phổ biến: thấy component nhiều logic quá thì tách ra custom hook, rồi nghĩ là performance ổn hơn. Nhưng thực chất không phải vậy, mình cũng sẽ nói về custom hooks ở một bài khác.

Ví dụ:

function useModal() {
  const [isOpen, setIsOpen] = useState(false)

  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
  }
}

Rồi dùng trong App:

function App() {
  const { isOpen, open, close } = useModal()

  return (
    <div>
      <button onClick={open}>Open modal</button>

      {isOpen && <Modal onClose={close} />}

      <VerySlowComponent />
      <AnotherHeavyComponent />
      <BigList />
    </div>
  )
}

Code nhìn sạch hơn thật. Nhưng performance thì chưa chắc tốt hơn. Vì state vẫn đang được dùng bởi App thông qua custom hook. Khi state trong hook thay đổi, component gọi hook đó vẫn re-render. Ở đây là App.

Custom hook chỉ là cách đóng gói logic. Nó không tự tạo ra một “vùng render riêng”. Nói thẳng ra: giấu state vào hook không có nghĩa là state hết ảnh hưởng tới component bên ngoài.

Muốn cô lập re-render, thường bạn cần cô lập bằng component. Tức là vẫn nên làm kiểu này:

function ButtonWithModal() {
  const { isOpen, open, close } = useModal()

  return (
    <>
      <button onClick={open}>Open modal</button>
      {isOpen && <Modal onClose={close} />}
    </>
  )
}

Rồi trong App:

function App() {
  return (
    <div>
      <ButtonWithModal />

      <VerySlowComponent />
      <AnotherHeavyComponent />
      <BigList />
    </div>
  )
}

Kết luận

Re-render không xấu. Ứng dụng React sống được là nhờ re-render. Nhưng re-render sai phạm vi thì dễ gây nên các vấn đề về performance. Khi thấy một state thay đổi làm quá nhiều component bị render lại, đừng vội nhảy vào React.memo, useMemo, useCallback.

Trước tiên hãy tự hỏi mấy câu này:

  1. State này thật sự cần nằm ở component hiện tại không?
  2. Có component con nào nhỏ hơn có thể giữ state này không?
  3. Những component bị re-render có thật sự phụ thuộc vào state đó không?
  4. Có phải mình đang giấu state trong custom hook nhưng vẫn gọi hook ở component quá lớn không?

Trong nhiều trường hợp, chỉ cần “moving state down” — đưa state xuống gần nơi dùng nó — là đủ giải quyết vấn đề.

Đây là kiểu tối ưu rất đơn giản, vì nó không chỉ làm app nhanh hơn, mà còn làm code rõ ràng hơn. Component nào chịu trách nhiệm phần nào thì giữ state phần đó. Ít magic hơn. Ít memo bừa hơn. Dễ debug hơn.

Với mình, đây là một trong những mindset quan trọng khi làm React ở level senior trở lên:

Tối ưu React không phải là memo mọi thứ.
Tối ưu React là hiểu state nằm ở đâu, re-render lan tới đâu, và component nào thật sự cần bị ảnh hưởng.

Hi vọng bài viết sẽ giúp các bạn có thêm kiến thức về React Fundamentals. Hẹn gặp lại các bạn ở những bài viết khác. Đừng quên để lại bình luận nhen.

guest

0 Comments
Inline Feedbacks
View all comments