🌞

Concurrent rendering trong React là gì?

Sửa bài viết này

Vấn đề

Chúng ta sẽ lấy một UI như bên dưới làm ví dụ

import { useState } from "react";
import { list } from "./list";
import "./style.css";

export default function App() {
  const [filter, setFilter] = useState("");

  return (
    <div className="container">
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />

      <List filter={filter} />
    </div>
  );
}

const List = ({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
};

const sleep = (ms) => {
  const start = performance.now();

  while (performance.now() - start < ms);
};

Hàm sleep để giả lập việc render một component tốn nhiều thời gian

Render <List /> tốn thời gian, toàn bộ UI sẽ bị đóng băng cho đến khi toàn bộ quá trình này kết thúc, trong thời gian đang render, khi user tiếp tục nhập vào input, chúng ta sẽ thấy nó không phản ứng

Quá trình render component trước đây của React luôn là một quá trình tuần tự, chạy tiếp sức, khi 1 component chạy xong đến đích của nó, nó sẽ truyền cờ cho component tiếp theo chạy

Giải pháp

React 18 giới thiệu cơ chế concurrent rendering để giải quyết vấn đề trên, developer sẽ chủ động khai báo những component nào có thể sẽ không cần render, có thể bỏ qua một số lần render không cần thiết.

Như ở ví dụ trên, thay vì user vừa gõ vào một ký tự, <List /> sẽ được render, <List /> chỉ cần render ở lần cuối cùng (khá giống với lodash.debounce)

export default function App() {
  const [filter, setFilter] = useState("");
  // ưu tiên thấp, không cần chạy liền
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  // cái này để kiểm tra giá trị khi chạy
  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

API

Ở thời điểm viết bài này, React giới thiệu cho chúng ta 2 API hỗ trợ concurrent rendering là useTransition (hay startTransition nếu dùng độc lập) và useDeferredValue

useTransition

Trong ví dụ ở trên, chúng ta đã diện kiến useTransition, hook này sẽ trả về cho chúng ta hàm startTransition và state isPending

const [isPending, startTransition] = useTransition()

startTransition sẽ đánh dấu những công việc có thứ tự ưu tiên thấp trong việc render, ở trên là việc đặt lại giá trị state delayFilter

startTranstion(() => {
	setDelayedFilter(e.target.value)
})

Một lưu ý quan trọng với callback truyền cho startTransition(callback) là nó phải là một sync function, nghĩa là sử dụng như bên dưới sẽ không cho kết quả như mong đợi

startTransition(() => {
  setTimeout(() => {
    setCount((count) => count + 1);
  }, 1000);
});startTransition(async () => {
  await asyncWork();
  setCount((count) => count + 1);
});startTransition(() => {
  asyncWork().then(() => {
    // Different call stack
    setCount((count) => count + 1);
  });
});

Chúng ta cần sửa lại thành

setTimeout(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
}, 1000);

await asyncWork();

startTransition(() => {
  setCount((count) => count + 1);
});

asyncWork().then(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
});

Một lưu ý khác, chỉ dùng với state, không dùng được với ref

const delayedFilterRef = useRef(filter);

startTransition(() => {
  delayedFilterRef.current = e.target.value;
});

useDeferredValue

Vẫn là bài toán ban đầu, chúng ta sẽ giải quyết nó bằng useDeferredValue

export default function App() {
  const [filter, setFilter] = useState("");
  const deferredFilter = useDeferredValue(filter);

  useDebug({ filter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
        }}
      />

      <List filter={deferredFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

Khác với useTransition chúng ta phải thêm delayFilter state và chủ động gán giá trị này, useDeferredValue sẽ làm dùm cho chúng ta công việc cơ bắp đó, chúng ta truyền vào cho useDeferredValue giá trị "nếu có thay đổi, thì mày khoan hả làm gì cả"

Tip

Tip nhỏ nếu anh em nào muốn hiển thị loading khi fetch dữ liệu lần đầu, ở những lần tiếp theo chúng ta sẽ hiển thị dữ liệu cũ trong lúc đang chờ dữ liệu mới thay vì loading. Tới thời điểm hiện tại, chúng ta sẽ chưa đủ đồ chơi đề làm việc này

Đây là lúc mang thêm <Suspense /> vào

export default function App() {
  const [page, setPage] = useState(1);
  const deferredPage = useDeferredValue(page);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setPage((page) => page - 1);
          }}
        >
          Previous Page
        </button>
        <button
          onClick={() => {
            setPage((page) => page + 1);
          }}
        >
          Next Page
        </button>
        Page: {page}
      </div>

      <Suspense fallback="Loading...">
        <Component page={deferredPage} />
      </Suspense>
    </div>
  );
}

const Component = ({ page }) => {
  const data = suspenseFetchData(page);

  return (
    <>
      {data
        ? data.map((entry) => <div key={entry.id}>{entry.name}</div>)
        : "Loading ..."}
    </>
  );
};

Kết

Concurrent Rendering là một là một phương pháp mới để giải quyết những vấn đề gặp hoài với anh em làm Frontend, cân nhắc sử dụng nó để đem tới những trãi nghiệm sử dụng ứng dụng mượt mà cho người dùng nhé anh em.

Initializing...