Lesson 15 / 20
Frame Rate and Profiling
Measures that an update which produces the tree three times can give zero commands. A re-render and a command are told apart; memoization lowers the counter, not the command.
Published Updated
In this lesson
- Reads the re-render count and the command count as two separate measures
- Measures that a tree produced three times from the same state gives zero commands
- Shows that memoization lowers the re-render count but does not change the command
- Explains that the cost of a stutter sits outside the rule, in the work on the JS side
Contents
The previous lesson showed that an item added at the front can bring the command count down from four to one. There the tree was changing, and the question was how the change was distributed among the rows. In this lesson the tree does not change: it is produced three times from the same state. The user can still experience a stutter, because producing the tree is also work. This lesson explains that this work does not show up in the tree, but it can be measured.
The course’s question has not changed: what does this update do to the node in the view tree? The answers are kept, updated, mounted, and unmounted; the sum of everything other than kept is the commands. The rule is given below in the core file.
// fate.mjs — classifies what an update does to each node of the view tree export const FATES = ["kept", "updated", "mounted", "unmounted"]; export function el(type, props = {}, children = [], key = null) { return { type, props, children, key }; } function segment(node) { return node.key === null ? node.type : `${node.type}[${node.key}]`; } function identity(node, index) { return node.key === null ? `#${index}` : `key:${node.key}`; } function sameProps(a, b) { const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n])); } function whole(node, path, fate, ops) { const here = `${path}/${segment(node)}`; ops.push({ fate, path: here }); node.children.forEach((child) => whole(child, here, fate, ops)); } export function reconcile(prev, next, path = "", ops = []) { if (prev === null) { whole(next, path, "mounted", ops); return ops; } if (next === null) { whole(prev, path, "unmounted", ops); return ops; } if (prev.type !== next.type || prev.key !== next.key) { whole(prev, path, "unmounted", ops); whole(next, path, "mounted", ops); return ops; } const here = `${path}/${segment(next)}`; ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here }); const before = new Map(prev.children.map((child, i) => [identity(child, i), child])); const matched = new Set(); next.children.forEach((child, i) => { const id = identity(child, i); matched.add(id); reconcile(before.has(id) ? before.get(id) : null, child, here, ops); }); prev.children.forEach((child, i) => { if (!matched.has(identity(child, i))) reconcile(child, null, here, ops); }); return ops; } export function printOps(ops) { ops.forEach((op) => console.log(`${op.fate}: ${op.path}`)); } export function countFates(ops) { for (const name of FATES) { console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`); } console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`); }
The function that turns the screen into a tree lives in a separate file. The screen has a header, a chart, and a footer note. Each time the function is called, it increments a lesson-local counter; the counter counts re-renders. The counter is not in the tree and is not an input of the rule: the rule only compares two trees.
// screen.mjs — turns a screen into a view tree and counts re-renders import { el } from "./fate.mjs"; export const counter = { renders: 0 }; export function renderScreen(state) { counter.renders += 1; return el("Screen", {}, [ el("Header", { title: state.title }), el("Chart", { points: state.points }), el("Footer", { note: state.note }), ]); } export function memoRender(render) { let lastKey = null; let lastTree = null; return (state) => { const key = JSON.stringify(state); if (key !== lastKey) { lastKey = key; lastTree = render(state); } return lastTree; }; }
renderScreen turns the state into a tree. memoRender wraps a render function: if the state is the same as before, it does not produce the tree again and returns the previous one. This is memoization. The difference between a re-render and a command is measured with these two functions.
Three Re-renders
In the first measurement, the tree is produced three times from the same state. Three trees give two updates: from the first to the second, and from the second to the third. The rule accumulates the two updates in the same ops array and counts them; the re-render count is read separately from the counter.
// main.mjs import { reconcile, countFates } from "./fate.mjs"; import { renderScreen, counter } from "./screen.mjs"; const state = { title: "Sales", points: [3, 5, 8], note: "daily" }; const first = renderScreen(state); const second = renderScreen(state); const third = renderScreen(state); const ops = []; reconcile(first, second, "", ops); reconcile(second, third, "", ops); console.log(`re-renders: ${counter.renders}`); countFates(ops);
re-renders: 3 kept: 8 updated: 0 mounted: 0 unmounted: 0 commands: 0
Three re-renders, zero commands. Each of the four nodes came out kept in both updates, eight records. The tree was produced three times, but the trees produced were equal to each other, and no work came out to go to the native side.
In M10/K03, how many times a function ran was being counted. The counter here counts the same thing, but the fate does not look at that number. A re-render is not a command; three re-renders can give zero commands. A re-render is the production of the tree; a command is the work that goes to the native side after two trees are compared. The two can move in the same direction or in different directions: a re-render can give a hundred commands, or none at all.
Memoization
If the counter is three, it can be seen that the three are unnecessary: the state never changed. Memoization cuts this waste. The same three calls are made with the render function wrapped by memoRender; the counter starts from zero.
// main.mjs import { reconcile, countFates } from "./fate.mjs"; import { renderScreen, memoRender, counter } from "./screen.mjs"; const render = memoRender(renderScreen); const state = { title: "Sales", points: [3, 5, 8], note: "daily" }; const first = render(state); const second = render(state); const third = render(state); const ops = []; reconcile(first, second, "", ops); reconcile(second, third, "", ops); console.log(`re-renders: ${counter.renders}`); countFates(ops);
re-renders: 1 kept: 8 updated: 0 mounted: 0 unmounted: 0 commands: 0
The re-renders dropped from three to one; the commands were zero and stayed zero. Memoization did work, but the work has no effect on the fate in the tree: the commands were already zero. The value of memoization is not in the tree but in the work of producing the tree itself. If the tree produced is large and expensive, memoization skips that work; the rule does not see this, because it says the two trees are equal in the same way in both cases.
The point to notice here is the key of the memoization. memoRender compares the state by its contents. In real frameworks, the comparison is usually made by the identity of the props, that is, whether they are the same object. A parent screen that produces a new array or a new function on every call defeats the memoization even if the contents are the same. This difference is stated in the assumptions block; the model does not measure it.
On a real screen, what is memoized is usually a heavy subtree, for example a chart. The snippet below writes the chart as a memoized function component, and the screen recomputes the array it gives to the chart only when the rows change. The model does not run this snippet.
// Platform code: SalesScreen
const Chart = React.memo(function Chart({ points }) {
return <ChartView data={points} />;
});
function SalesScreen({ state }) {
const points = useMemo(() => state.rows.map((r) => r.total), [state.rows]);
return <Chart points={points} />;
}
React.memo wraps the chart: if its props are the same as before, the chart is not re-rendered. useMemo rebuilds the points array only when state.rows changes; if the array stays the same, the wrapped chart sees no new prop.
Changing Data
To understand what memoization does, the opposite must be measured too: what happens when the data really changes? The points of the chart change, while the header and the footer note stay the same.
// main.mjs import { reconcile, printOps } from "./fate.mjs"; import { renderScreen } from "./screen.mjs"; const before = renderScreen({ title: "Sales", points: [3, 5, 8], note: "daily" }); const after = renderScreen({ title: "Sales", points: [3, 5, 9], note: "daily" }); printOps(reconcile(before, after));
kept: /Screen kept: /Screen/Header updated: /Screen/Chart kept: /Screen/Footer
A single command went out: /Screen/Chart is updated. The header and the footer note stayed kept; the screen root has no props, so it is kept as well. When the data changes, a re-render is mandatory and a command is mandatory. The job of memoization is to skip the re-render when the data does not change; when the data changes, there is nothing for it to skip.
Profiling
A stutter comes not from the re-render count but from the time the re-render takes. For every frame, the screen has to prepare the new image within a certain time; if the work on the JS side does not fit into this time, a frame is dropped. A dropped frame is the stutter the user sees. This lesson writes no time, because time is not in the model; the measurement is made on a real device, with the profiler.
The profiler tells two things apart. The first is the work on the JS side: processing the state, producing the tree, comparing the two trees. The second is the work on the native side: applying the commands to the native views, computing the layout, drawing. A screen that gives zero commands can still stutter because of the work on the JS side; a screen that gives many commands may still be handled in time by the native side. The place called the hot path is where these two kinds of work are repeated the most from frame to frame: scrolling, typing, dragging.
The place measured by the profiler can be marked with a wrapper. The snippet below puts the chart inside a profiler wrapper and passes the time of every render to a collector. The model does not run this snippet.
// Platform code: ProfiledChart
function ProfiledChart({ points }) {
return (
<Profiler id="chart" onRender={(id, phase, duration) => collect(id, duration)}>
<Chart points={points} />
</Profiler>
);
}
The time this snippet gives is the render work of this chart on the JS side. It is not the same thing as the command count; the two are read together. If the time is high and the commands are zero, look for memoization. If the time is low and the commands are many, the tree itself is changing and the keys are reviewed.
Reading the Profiler Wrapper Line by Line
Each line of the snippet above does something.
<Profiler id="chart" ...>opens the wrapper and gives the measurement its name. There can be more than one wrapper on the same screen;idsays which one is speaking.onRender={(id, phase, duration) => collect(id, duration)}is the function called when each render finishes.phasetells whether the render is the first render, an update, or an update that started inside an update, anddurationgives the JS-side work of this render.collectis the collector; it is the application’s own function.<Chart points={points} />is the measured subtree. The wrapper measures only what is inside it; the header and the footer note do not enter this number.
It should be noted that the wrapper is not a node in the tree. The measuring apparatus does not change the view tree; this is why the model leaves the path /Screen/Chart the same. If a measuring tool adds a node to the tree, the tool is spoiling what it measures.
Common Misconceptions
The first misconception is that many re-renders mean a stutter. Since three re-renders can give zero commands, the number alone says nothing. A re-render is work processed only on the JS side; as long as nothing goes to the native side, no view on the screen changes. A stutter happens only when the work of the re-render does not fit into the frame time, and it is the profiler, not a count, that shows this.
The second misconception is that memoizing everything is safe. Memoization requires a comparison: memoRender turns the state into a key on every call and compares it with the previous one. If the state rarely stays the same, this comparison is wasted work and you still produce the tree. Memoization pays off where the state often stays the same; scattering it everywhere is not a gain but a cost.
The third misconception is that zero commands are always good. When the data really changes, zero commands would mean that the screen keeps showing the old data. In the Changing Data measurement, the chart coming out updated is not a defect but the correct behavior. What is good is the absence of unnecessary commands, not the absence of commands altogether.
The Bounding Measurement
In the last measurement, the header changes and the chart does not. Three re-renders are made, and two updates are counted. The header takes a new value in each update; the chart, the footer note, and the root stay the same. The rule shows the chart by filtering its path.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderScreen, counter } from "./screen.mjs"; const points = [3, 5, 8]; const first = renderScreen({ title: "Sales", points, note: "daily" }); const second = renderScreen({ title: "Sales (week)", points, note: "daily" }); const third = renderScreen({ title: "Sales (month)", points, note: "daily" }); const ops = []; reconcile(first, second, "", ops); reconcile(second, third, "", ops); console.log(`re-renders: ${counter.renders}`); printOps(ops.filter((op) => op.path === "/Screen/Chart")); countFates(ops);
re-renders: 3 kept: /Screen/Chart kept: /Screen/Chart kept: 6 updated: 2 mounted: 0 unmounted: 0 commands: 2
The focus node is /Screen/Chart: kept in both updates. The chart entered the tree in each of the three re-renders and was compared three times with the previous chart, but since its props were the same, nothing went to the native side. The header came out updated in two updates; both of the commands went to the header.
The limit of this measurement is the real warning of the lesson. The chart was re-rendered three times, and a re-render is work: the points of the chart may have been processed three times, a heavy computation may have run three times. The rule does not see this. The rule sees only what shows up in the tree; a node coming out kept does not say that producing that node was cheap. A stutter can live behind a kept node. Finding it takes the profiler; a tree diff cannot find it.
PP2. The model builds this fate’s rule; the model is not the framework itself. In the model, a re-render is a function producing the tree again, and the counter counts these calls; in the real framework, the state, the context, and the parent screen together determine under what condition a screen is re-rendered, and the model builds none of these. The memoRender in the model compares the state by its contents; real memoizations usually compare by object identity, and this difference is not measured. The frame time, the distribution of work between the JS side and the native side, and the dropped frame are not in the model.
Summary
- This lesson’s fate: kept. The
/Screen/Chartnode stayed kept in both updates, even though the tree was produced three times and the header changed. - The tree produced three times from the same state gave zero commands; a re-render is not a command.
- Memoization brought the re-render count from three to one, and the commands stayed zero; the value of memoization is in the work of producing the tree.
- When the points of the chart changed,
/Screen/Chartcame out updated and a single command went out; memoization skips nothing when the data changes. - The cost of a stutter sits in the work on the JS side; the rule sees only what shows up in the tree, and the profiler measures this work.
Next Step
In this lesson, the tree was always the tree of the same screen, and the screen was there from the very beginning. Loading all the screens of the application at launch stretches this assumption: loaded code is also a cost. The next lesson, Bundle Size and Startup, measures when deferring the code of a screen mounts its node.
To keep your progress and take notes, Log in
My notes
Log in to take notes.