Optimizing Performance with React Fiber and flushSync

Introduction

In the previous article, I provided much information related to React Fiber and Rendering Pipelines. I will further explain its operating mechanism and how to optimize performance more. At the same time, I will introduce how to use flushSync to set the priority of a task to a higher level.

Prerequisites

Please review the previous article to grasp some information about React Fiber before continuing.


Detail

For the example using useDeferredValue, you can try increasing the number of items processed or simulating a slower machine configuration to test it out.


When you input continuously, you will see cases where everything still functions, but sometimes the machine will lag completely, unable to accept further input.

Why is that? The reason lies in the principle I mentioned above, which is that React Fiber only works in the Render Phase when it can stop the current process to prioritize higher priority tasks (like user input), but once it enters the Commit Phase, it can no longer be stopped. As you can see, the number of items we need to process is 10,000, so React will also have to create the corresponding number of Virtual DOMs and the Browser also has to render the corresponding number of real DOMs; this is the factor that makes the Browser lag and unable to proceed.

Thus, even if you utilize the React Fiber mechanism, it is not a panacea for solving all performance problems; it only works up to the Commit Phase, but from the Render Phase through Browser paint, it doesn't provide any support.

So, what is the next solution? Apply virtual scrolling. Here I use the supported feature from Ant Design, you can use packages with similar functionality to reduce the number of Real DOMs the browser needs to process.

import { RocketOutlined, SearchOutlined } from "@ant-design/icons";
import type { TableColumnsType } from "antd";
import { Badge, Card, Input, Space, Table, Typography } from "antd";
import { memo, useDeferredValue, useMemo, useState } from "react";

const { Title, Text } = Typography;

const RAW_DATA = Array.from({ length: 10_000 }, (_, i) => ({
key: i,
title: `Entry #${i + 1}`,
}));

interface DataType {
key: number;
title: string;
content?: string;
}

const ListData = memo(({ query }: { query: string }) => {
const columns: TableColumnsType<DataType> = [
{
title: "Content Result",
dataIndex: "content",
render: (_, record) => (
<Text type="secondary">
Result for <span className="font-bold text-blue-500">"{query}"</span> - {record.title}
</Text>
),
},
];

const dataSource = useMemo(() => {
if (!query) return [];
return RAW_DATA.map(item => ({
...item,
content: query,
}));
}, [query]);

return (
<Table
className="border rounded-lg overflow-hidden"
columns={columns}
dataSource={dataSource}
pagination={false}
showHeader={false}
scroll={{ y: 400 }}
virtual
size="middle"
locale={{
emptyText: <div className="py-10 italic">Type to search...</div>,
}}
/>
);
});

export function UseDeferredValue() {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text);
const isStale = text !== deferredText;

return (
<div className="min-h-screen bg-slate-50 p-6 md:p-12 flex justify-center">
<Card className="w-full max-w-3xl shadow-xl rounded-xl border-0">
<Space orientation="vertical" size="large" className="w-full">
<header className="flex items-center gap-3 border-b pb-4">
<RocketOutlined className="text-blue-500 text-2xl" />
<div className="flex flex-col">
<Title level={3} className="!m-0 text-gray-800">
React Fiber
</Title>
<Text type="secondary">useDeferredValue</Text>
</div>
</header>

<section>
<div className="flex justify-between items-end mb-2">
<Text strong className="text-gray-600">
Search Input
</Text>
{isStale && <Badge status="processing" text={<Text className="text-blue-500 text-xs italic">Fiber is computing in background...</Text>} />}
</div>
<Input
size="large"
placeholder="Search items..."
prefix={<SearchOutlined className="text-gray-300" />}
value={text}
onChange={e => setText(e.target.value)}
className="rounded-lg shadow-sm border-gray-200"
allowClear
/>
</section>
<section
className="transition-opacity duration-200"
style={{
opacity: isStale ? 0.6 : 1,
filter: isStale ? "grayscale(20%)" : "none",
}}
>
<ListData query={deferredText} />
</section>
</Space>
</Card>
</div>
);
}

Please try again to see the performance improvement.


Higher Priority

After having applied useTransition and useDeferredValue to lower the priority of these tasks, next I will guide you to use the flushSync function to boost the priority to the highest level, meaning React will stop lower priority tasks to perform this task. Therefore, please note not to place heavy computational logic in here, as it can block the main thread.


Create the HighPriority.tsx file as follows:

import { RocketOutlined } from "@ant-design/icons";
import { Button, Card, Typography } from "antd";
import { useRef, useState } from "react";
import { flushSync } from "react-dom";

const { Title, Text } = Typography;

export default function HightPriority() {
const [items, setItems] = useState<number[]>([1, 2]);
const [measuredHeight, setMeasuredHeight] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);

const handleAddNormal = () => {
setItems(prev => [...prev, prev.length + 1]);

if (containerRef.current) {
setMeasuredHeight(containerRef.current.scrollHeight);
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}
};

const handleAddWithFlushSync = () => {
flushSync(() => {
setItems(prev => [...prev, prev.length + 1]);
});

if (containerRef.current) {
setMeasuredHeight(containerRef.current.scrollHeight);
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}
};

return (
<div className="min-h-screen bg-slate-50 p-8 flex flex-col items-center">
<Card className="w-full max-w-2xl shadow-xl rounded-2xl border-0">
<header className="text-center mb-8">
<Title level={2} className="!mb-1">
<RocketOutlined className="text-blue-500 mr-2" />
flushSync
</Title>
</header>

<div className="grid grid-cols-2 gap-4 mb-8">
<Button size="large" onClick={handleAddNormal} className="h-20 flex flex-col items-center justify-center rounded-xl border-blue-200 hover:border-blue-400 p-0">
Normal Update
</Button>

<Button size="large" type="primary" danger onClick={handleAddWithFlushSync} className="h-20 flex flex-col items-center justify-center rounded-xl shadow-md p-0">
flushSync Update
</Button>
</div>

<div className="bg-slate-900 rounded-2xl p-6 mb-8 text-center shadow-2xl border-b-4 border-slate-700">
<div className="text-blue-400 text-xs font-bold uppercase tracking-widest mb-2">Captured Scroll Height</div>
<div className="text-5xl font-mono text-white font-black">
{measuredHeight} <span className="text-lg text-slate-500">px</span>
</div>
</div>

<div ref={containerRef} className="max-h-[350px] overflow-auto bg-slate-100 rounded-xl shadow-inner border border-slate-200 p-0">
<div className="flex flex-col w-full">
{items.map(item => (
<div
key={item}
className="h-[100px] w-full bg-white border-b border-slate-200 flex flex-col items-center justify-center transition-all animate-in slide-in-from-bottom-2 duration-300 flex-shrink-0"
>
<Text strong className="text-slate-700 text-lg">
Item #{item}
</Text>
<Text type="secondary" className="text-[10px]">
FIXED HEIGHT: 100PX
</Text>
</div>
))}
</div>
</div>
</Card>
</div>
);
}

The code snippet above compares two ways to update state: handleAddNormal and handleAddWithFlushSync. In handleAddNormal, React updates state asynchronously, leading to the measurement of scrollHeight occurring before the DOM can update with the new item. Conversely, handleAddWithFlushSync uses flushSync to force React to update the DOM immediately (synchronously). As a result, measuring height and scrolling in the subsequent lines of code will be based on the actual state of the DOM after the new item has been added, ensuring accuracy for tasks that require direct interaction with the interface.


You can check the result that when pressing Normal Update, it only scrolls to the position of the second-to-last item because the state setting has not changed the real DOM yet, so it only gets the container at the current moment (not at the time after adding the new item).

But when you use flushSync, the code inside will run up to the Commit Phase, meaning the Real DOM has been updated but not to the Browser paint, so operations like getting the dimensions and scroll to will work accurately.

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

Sitemap

DevOps Practice Series