skip to content
usubeni fantasy logo Usubeni Fantasy

React's Design Philosophy

/ 16 min read

This Post is Available In: CN EN ES JA

React’s Rendering Mechanism

Have you ever experienced typing in a web page where each keystroke causes a half-second lag? Or clicking a button and having the entire page become completely unresponsive, like a static image that ignores all your clicks?

In the past, React’s rendering had a fatal flaw: when setState was called, the entire component tree would run its render functions. Even with the virtual DOM, this wasted considerable time. Components whose props hadn’t changed, or even components that didn’t depend on any props at all, would still be re-rendered and diffed by default. For large web applications, this flaw often caused noticeable stuttering.

In contrast, Vue inherently knows what values a component depends on, allowing it to precisely re-render at the component level.

The Root Cause of Stuttering

As you probably know, browser rendering and JavaScript execution compete for the same thread.

When the required frame isn’t ready during refresh (most commonly due to JavaScript long tasks blocking), that frame gets dropped, creating a stuttering sensation. To maintain basic smoothness, rendering-blocking tasks shouldn’t exceed 30ms, with 16ms being even better. Using requestAnimationFrame can effectively align with refresh timing, giving JavaScript more generous execution time.

In React development scenarios, if developers haven’t deliberately optimized, the higher up the component tree a change occurs, the longer the rendering time. This is because the old diff algorithm (stack reconciler) was uninterruptible—once rendering started, the old and new virtual DOM trees would begin synchronous recursive comparison and DOM operations. This recursive operation lacked exit mechanisms, making it easy for large components to exceed 16ms and cause stuttering.

So how does React actually compress large component rendering time to under 16ms?

Fiber to the Rescue

Starting with React 16, React introduced React Fiber and updated the Fiber Reconciler. Its core algorithm treats each component in the component tree as a unit, running these units in batches. When React detects insufficient runtime, it yields the thread back to rendering. After rendering completes, it continues from where it was interrupted. So Fiber provides React rendering with the ability to pause, abort, and adjust priorities.

Additionally, the new rendering process is divided into two phases: Render and Commit.

Let’s first look at what Fiber actually is. It’s simply a JavaScript object. In this context, we only need to focus on three properties:

export type Fiber = {
// ...
return: Fiber | null;
child: Fiber | null;
sibling: Fiber | null;
// ...
};

Render is a single-threaded process of traversing Fiber, and this process is interruptible—something the old algorithm’s recursive mechanism couldn’t provide. The basic algorithm is as follows, achieving the effect of prioritizing child nodes, then sibling nodes when no children exist, returning to the parent level after processing siblings, continuing with parent-level siblings, until returning to the root node marks completion.

let root = fiber;
let node = fiber;
while (true) {
// Do something with node
if (node.child) {
node = node.child;
continue;
}
if (node === root) {
return;
}
while (!node.sibling) {
if (!node.return || node.return === root) {
return;
}
node = node.return;
}
node = node.sibling;
}
Node Traversal

React schedules the Fiber Reconciler through a Work Loop. When time runs short, React needs to yield the JavaScript thread to respond to user interactions and page rendering. This scheduling algorithm was initially intended to rely on the requestIdleCallback interface, but due to persistent browser compatibility issues, React implemented its own solution.

React Workloop

This brings an insight: the time-slicing approach of Work Loop can also be applied to long list rendering in Vue.

Commit occurs after a complete Render phase, collecting a series of operations on the real DOM and executing them all at once. This step is executed synchronously. Separating these two phases prevents UI inconsistencies that would occur if the Render phase was interrupted mid-execution. Because these two steps are separated and the rendering step is interruptible, methods like componentWillUpdate used to be called twice, which is why they were later deprecated.

This demo shows a performance comparison between the old and new rendering mechanisms, with a dramatic difference.

Stack performance analysis:

Stack Performance Analysis

Fiber performance analysis:

Fiber Performance Analysis

Fiber combined with the Reconciliation algorithm dramatically improves system responsiveness, with only a slight increase in overall rendering time—a trade-off that seems quite worthwhile.

The Philosophy of Functional Components

Fiber’s core is efficiently obtaining the new virtual DOM from render, and React itself actively embraces functional programming—a perfect match. Taking advantage of the Fiber update, React introduced Hooks, fully committing to functional components.

Class component:

class Example extends React.Component {
state = { count: 0 };
componentDidMount() {
// Lifecycle method
}
render() {
return <div>{this.state.count}</div>;
}
}

Functional component:

function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
// Replaces lifecycle methods
}, []);
return <div>{count}</div>;
}

Compared to class components, functional components’ main advantages are cleaner syntax, elimination of this, and a better foundation for extracting common logic. Additionally, lifecycle functions are unified into side-effect hooks like useEffect, which incidentally solves the componentWillUpdate asymmetry problem mentioned earlier.

The characteristic of render functions is that they must be pure functions—given the same input, the output is always deterministic and unchanging, producing the same result even after running ten thousand times. This is what we call having no side effects. We can express this behavior with the formula UI = f(state).

This characteristic brings up a somewhat counterintuitive question: what will the alert show?

export default function Counter({ init }) {
const [number, setNumber] = useState(init);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
alert(number);
}}
>
+5
</button>
</>
);
}

Once you truly understand React’s underlying mechanisms and JavaScript’s characteristics, the result is clearly the old value. In the current render, due to JavaScript’s lexical scoping, number is a fixed value. After setNumber, re-rendering doesn’t happen immediately (it just enters the queue waiting for Work Loop scheduling), so even though alert comes after setNumber, it won’t immediately get the new result.

Introducing Hooks

hook

This image is an excellent metaphor—Hooks are like hooks within functional components. Compared to earlier versions, after fully embracing functional components, all the various properties of class components needed new storage solutions. The introduction of Hooks allows these render functions to still possess React’s additional capabilities.

Since pure functions cannot record state, the essence of Hooks is to transfer state elsewhere, hooking into values outside the function through these hooks.

Why Hooks Must Be at the Top Level

When React transfers state, call order is crucial. If written inside if statements or functions, the call order cannot be guaranteed.

Let’s look at Didact’s useState implementation:

let wipFiber = null;
let hookIndex = null; // Hook order storage
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]; // Get hook by index
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++; // index + 1 for next hook
return [hook.state, setState];
}

Optimizations Based on the New Paradigm

For React’s new paradigm of functional components + Fiber, optimization approaches mainly come from two angles: caching and scheduling.

Caching

Compared to class components that run the render function when updating, after switching to functional components, the entire function runs on every render. If this component contains large functions and CPU-intensive operations, it can easily cause performance issues.

As mentioned earlier, Hooks transfer state elsewhere. Besides state, it’s sometimes necessary to store large functions, dependent functions, and cache CPU-intensive operations.

This is where useMemo and useCallback become essential.

Check out this Demo:

import React, { useState, useMemo } from "react";
// A function to calculate the nth Fibonacci number (CPU-intensive operation)
const fibonacci = (n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
};
const App = () => {
const [num, setNum] = useState(10); // User input number
const [count, setCount] = useState(0); // Other unrelated state
// Use useMemo to cache calculation results
const fibValueMemo = useMemo(() => {
console.log("Calculating Fibonacci...");
return fibonacci(num);
}, [num]);
// Without caching
// const fibValue = fibonacci(num)
return (
<div style={{ padding: "20px" }}>
<h1>useMemo CPU-Intensive Operation Caching Example</h1>
<div>
<label>
Calculate the{" "}
<input
type="number"
value={num}
onChange={(e) => setNum(parseInt(e.target.value, 10) || 0)}
style={{ width: "50px" }}
/>{" "}
th Fibonacci number:
</label>
</div>
<p>Result: {fibValueMemo}</p>
<button onClick={() => setCount(count + 1)}>Click count: {count}</button>
</div>
);
};
export default App;

When the fibonacci parameter reaches above 38, the UI will start to stutter. Without useMemo, every button click will make the webpage feel unresponsive.

useCallback is used for function caching, with the sole purpose of keeping its reference unchanged.

// ...
const createOptions = useCallback(() => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...

When functions become dependencies, they must be cached first. Without caching, due to the nature of functional components, each run creates a brand new function, making dependency change detection always return true.

Don’t think that function declarations are expensive and caching saves this overhead. In fact, the example above is equivalent to:

const fn = () => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
};
const createOptions = useCallback(fn, [roomId]);

The declaration of fn absolutely cannot be avoided. useCallback simply returns the previous function declaration when dependencies haven’t changed—don’t be fooled into thinking the declaration is skipped this time 😂.

Of course, if your function doesn’t use any state or props, it’s best to define it outside the component, which truly saves overhead.

Since we can cache functions and execution results, why not think about caching possibilities from a higher dimension and cache entire components?

Based on the characteristic that render functions are pure functions UI = f(state), as long as state doesn’t change, this component’s execution result can be safely reused. React provides the React.memo function to store components, eliminating re-renders when a component receives the same parameters.

const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});

Scheduling

The following two optimizations are closely related to the new rendering mechanism:

useTransition in a nutshell: rendering for transitions. Renders triggered within startTransition can be immediately interrupted to execute higher-priority needs.

This Hook essentially leverages Fiber’s interruptible mechanism, directly interrupting current unimportant renders to give users the content they need next.

Check out this Demo:

export default function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("about");
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<Suspense fallback={<div>Loading, please wait...</div>}>
<DelayedComponent />
</Suspense>
<TabButton isActive={tab === "about"} onClick={() => selectTab("about")}>
About
</TabButton>
<TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
Posts (slow) {isPending ? "loading" : ""}
</TabButton>
<TabButton isActive={tab === "contact"} onClick={() => selectTab("contact")}>
Contact
</TabButton>
<hr />
{tab === "about" && <AboutTab />}
{tab === "posts" && <PostsTab />}
{tab === "contact" && <ContactTab />}
</>
);
}

You can feel the dramatic improvement in responsiveness by commenting out startTransition.

Another Hook, useDeferredValue, in a nutshell: React’s specialized advanced debouncing. useDeferredValue is scheduled by React—it gets prioritized when the thread isn’t busy and deferred when it is.

When React detects the system is busy, it won’t trigger useDeferredValue, so this value only updates after the busy period ends. It works wonderfully when combined with React.memo.

Check out this Demo:

import React, { useState, useDeferredValue, startTransition } from "react";
function blockMainThread(duration) {
const start = performance.now();
while (performance.now() - start < duration) {}
}
const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});
const App = () => {
const [count, setCount] = useState(0);
const deferredCouont = useDeferredValue(count);
console.log(count, deferredCouont);
return (
<>
<button onClick={() => setCount(count + 1)}>
<span role="img" aria-label="react-emoji">
⚛️
</span>{" "}
{count}
</button>
<ExpensiveItem count={deferredCouont} />
</>
);
};
export default App;
react fiber 优先级

The Special Case of Breaking Pure Functions

As pure function rendering components sometimes inevitably need to produce side effects, for example, after rendering you might need to automatically request AJAX interfaces to provide data for the page. React introduces the useEffect Hook, where you can safely place your side effects.

Common side effect operations include:

  • Data fetching (such as calling APIs)
  • Event subscriptions (such as WebSocket or DOM event listeners)
  • Manual DOM manipulation (such as directly operating on document)
  • Timer setup (such as setTimeout or setInterval)
  • Modifying global variables or external state

Taking Responsibility for Side Effects

If side effects are introduced when a component mounts, we may have the responsibility to clean up these side effects when the component unmounts.

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
return () => connection.disconnect();
}, [roomId]);

You may have heard about the issue of useEffect running twice—this is a popular problem caused by React’s recommended strict mode in development environments. Strict mode intentionally runs twice to expose whether useEffect side effects are properly cleaned up. Normally, pure functions should produce the same results, so repeated execution shouldn’t cause any problems. If problems occur, it means developers haven’t followed React’s requirement that rendering must be pure functions, or that side effects brought by useEffect haven’t been cleaned up, causing the program to behave unexpectedly.

If you immediately notice abnormalities upon opening, it’s likely caused by strict mode’s repeated execution. What we should do at this point is not to disable strict mode, but to consider whether the component has introduced unexpected side effects.

Events and Side Effects

Besides useEffect, events can also be non-pure functions because events don’t run during rendering. The difference between events and useEffect is: events are caused by user actions, while side effects triggered by useEffect are strongly associated with React rendering.

useEffect is a function that has the opportunity to trigger whenever a component renders, whether it triggers depends on whether its dependency values have changed.

The official documentation dedicates considerable space to explaining situations where beginners might use useEffect but don’t actually need it. One important concept is event priority. Here’s a simple example:

useEffect(() => {
setSearch("");
}, [currentTeam]);

When search doesn’t depend on currentTeam, this code will trigger the warning This hook specifies more dependencies than necessary: currentTeam (using Biome). This approach is indeed fabricating dependency relationships. A better handling method is to reset search in the event that triggers currentTeam itself.

Besides making these fabricated relationships difficult to understand during later code maintenance, this also affects application performance. When currentTeam changes, it already triggers a re-render, and then calling setSearch after re-rendering requires running render again. A better solution is as follows:

<button
onClick={() => {
setCurrentTeam(item.teamId);
setSearch("");
refetch();
}}
>
{item.name}
</button>

In this scenario, useEffect is somewhat similar to Vue’s watch. By the same logic, Vue shouldn’t abuse watch either.

To summarize: make good use of event-driven approaches. For problems that can be solved with events, don’t use useEffect. (This applies to Vue as well)

Even if you must use useEffect, React has additional requirements: don’t write dependencies carelessly.

Events in Side Effects

Let’s look at another example: suppose a component needs to reconnect websocket when roomId changes, but the websocket connection function also needs to use theme. If we add theme to dependencies, it will unnecessarily reconnect when changing themes.

React introduces a new Hook useEffectEvent specifically for useEffect dependency issues. Developers can use this Hook to abstract functions into “side effect events” that are dedicated to useEffect.

It has several noteworthy points:

  • Not a “reactive value” and cannot become a dependency
  • No dependencies, but always maintains the latest referenced values
  • From its name, it should only be used with useEffect

This is undoubtedly a Hook that’s very difficult for developers to understand, since it’s specifically designed to solve useEffect dependency problems—it’s somewhat like digging another hole to fill one hole.

If we must explain it, we can roughly understand it as: “events” (or even functions) were never meant to be triggers for changes—state is. But now React treats all functions as reactive values, which is a problematic design.

// before
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
showNotification("Connected!", theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>Welcome to the {roomId} room!</h1>;
}
// after
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}

That said, useEffectEvent is still in experimental stage, so this problem currently needs to be handled by ignoring linter warnings.

useEffect is your last resort

要记住,useEffect 是最后杀招,可以用事件,优先用事件!实在没有事件触发时才会考虑使用它。

React’s Future

JSX based on functional components is indeed becoming increasingly flexible, but the cost is a steep learning curve. Whether it’s understanding the various concepts of functional components, handling performance issues, or dealing with useEffect dependency problems, all these designs create mental burden for beginners.

Fortunately, the React team has recognized this problem. In future versions, forwardRef will be removed, and React compiler’s automatic caching mechanism (including fine-grained caching of component content and function execution result caching)—many of the optimization content mentioned above—will no longer require additional developer control.

From the initial Fiber for rendering performance optimization, to changing the component paradigm to functional components better suited for Fiber, to later introducing Hooks that let Fiber squeeze out performance, this evolution has indeed made things progressively better.

There should be more Hooks in the future to perfect React’s design philosophy. The functional approach will likely continue all the way—early adoption brings early benefits, late adoption brings ease.

References

评论组件加载中……