Lesson 03 / 20
Core Components
The core components are view, text, image, and input field; each is a node in the view tree. The lesson measures what a type change does to the tree in the update where a placeholder text is replaced by an image.
Published Updated
In this lesson
- Recognizes the view, text, image, and input field as typed nodes in the tree
- Measures that a prop change on the same type leaves the node updated
- Shows that when the type changes, the old node comes out unmounted and the new one mounted, and the props are not examined
- Separates the path of data through the tree from the fate at the node the data reaches
Contents
The previous lesson asked where the first tree comes from and found the home screen mounted with its whole subtree. This lesson looks inside that home screen. A screen is built from a few core types the framework offers ready-made: a container view, text, an image, and a field where text is entered. Each is a node in the tree, and the node’s type is the third question of the fate rule: if a node of a different type arrives in the same place, the old one is unmounted and the new one is mounted.
The snippet below is the description of a profile card. The snippet is not measured; the model does not run the framework’s code.
// Platform code: Profile
function Profile({ name, photo }) {
return (
<View>
{photo ? <Image source={photo} /> : <Text>Loading</Text>}
<Text>{name}</Text>
<TextInput value={name} />
</View>
);
}
View is a container and shows nothing on its own; it holds its children together. Text shows text. Image is drawn from an image source. TextInput is the field where the user enters text. The function returns an Image if the image is ready and a placeholder Text if not; the name arrives as a Text in both cases. These four types are the core components covered in this lesson; later lessons add new ones with touch and platform selection.
Four Types, One Rule
Each of the core types corresponds to a view on the native side. When shared code describes a Text node, the platform’s own text view is created on iOS and on Android. The native view depends on the node’s type; if the type changes, the native view must change too. A text view cannot turn into an image view in place. That is why the rule’s third question looks at the type, and if the type differs it does not look at the props: the old subtree is unmounted and the new subtree is mounted.
The core is needed first for the measurement. The comparison core is given again in every lesson in the same form.
// 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}`); }
This lesson’s helper file produces a view tree from the profile state. The card is a container; its first child is an Image if the image is ready and a placeholder Text if not. The key of the text that carries the name is name, so its place is not affected by changes among its siblings. The input field carries the biography. The second function is an alternative description that makes the placeholder an image too; it is used below for comparison. The image’s accessibility label carries the name; for this reason the name can appear in two places on the card.
// profile.mjs — produces a view tree from the profile state import { el } from "./fate.mjs"; export function renderProfile(state) { const slot = state.photo === null ? el("Text", { value: "Loading" }) : el("Image", { source: state.photo, label: state.name }); return el("Card", {}, [ slot, el("Text", { value: state.name }, [], "name"), el("Input", { value: state.bio }), ]); } export function renderProfileImage(state) { return el("Card", {}, [ el("Image", { source: state.photo, label: state.name }), el("Text", { value: state.name }, [], "name"), el("Input", { value: state.bio }), ]); }
Same Type, Changed Prop
In the first measurement the user changes the name. The image has not loaded yet; the placeholder stands. Because the type and key stay the same on every node, the rule looks at the props.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderProfile } from "./profile.mjs"; const before = renderProfile({ name: "Ayse", photo: null, bio: "Loves coffee" }); const after = renderProfile({ name: "Ayse Yilmaz", photo: null, bio: "Loves coffee" }); const ops = reconcile(before, after); printOps(ops); countFates(ops);
kept: /Card kept: /Card/Text updated: /Card/Text[name] kept: /Card/Input kept: 3 updated: 1 mounted: 0 unmounted: 0 commands: 1
Of four nodes, three came out kept and one updated; the commands are one. /Card/Text[name] stood in the same place, with the same type and the same key, but its value prop changed, so it became updated. The placeholder Text and the input field did not change, and no command went to the native side.
Where the Data Arrives
We make the same name change while the image is loaded. The name now reaches two places: the text node and the image’s label.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderProfile } from "./profile.mjs"; const before = renderProfile({ name: "Ayse", photo: "profile.png", bio: "Loves coffee" }); const after = renderProfile({ name: "Ayse Yilmaz", photo: "profile.png", bio: "Loves coffee" }); const ops = reconcile(before, after); printOps(ops); countFates(ops);
kept: /Card updated: /Card/Image updated: /Card/Text[name] kept: /Card/Input kept: 2 updated: 2 mounted: 0 unmounted: 0 commands: 2
In the first measurement the same change updated one node, here two. The data is the same, a single state field. Its path through the tree is also the same: from the state held at the root down to the card’s children. What changes is what happens where the data arrives. When there was a placeholder, the name reached only the text node; when there is an image, it reaches the image’s label too.
This is separate from the question we asked in M31/K03, “in which direction is this data going in the UI tree”. There, the data’s direction in the tree was classified: down or up. This course classifies not the data’s path but what happens at the node it reaches. Two pieces of data with the same direction may update a node or may not affect any; two pieces of data with different directions may also give the same fate.
CS4. The model counts the input as controlled: the input field’s value is a prop in the tree. A real input field also keeps the typed text on the native side; if the text and the value in the tree drift apart, the rule does not see it. This is an application of the limit from the first lesson: what is not visible in the tree receives no fate.
When the Type Changes
Now the image loads and takes the placeholder’s place. The card’s first child stands in the same place, but its type goes from Text to Image. The rule stops at the third question: the type is different, the props are not examined.
Reading the Snippet Line by Line
We return once more to the profile card snippet given at the start. The Profile function takes two inputs: name and photo. The root of the tree it returns is View; its children are, in order, an image or a placeholder, a text, and an input field. The ternary operator selects Image if photo is set and Text if not. The function contains a condition, but the tree does not carry the condition: only the result of the selection is in the tree. The difference between one description and the next is read according to which branch of this condition was selected. The value prop given to TextInput is the input’s content; what the user typed and what stands in the tree need not be the same, and this is stated in the assumption above.
Here is a misconception to clear up: “When the image loads, the placeholder and the image are the same node, only its content changes.” In the reader’s eye the two stand in the same corner of the card and represent the same thing. In the rule’s eye, a Text and an Image are two separate things: on the native side one is a text view and the other an image view, and it is not possible to turn one into the other. Even if the node’s position in the tree is the same, its identity comes with its type.
If the Placeholder Were an Image Too
For a counterexample, we change the description: the placeholder becomes an Image too, only with an empty source. When the image arrives, the type stays the same and only the source changes.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderProfileImage } from "./profile.mjs"; const before = renderProfileImage({ name: "Ayse", photo: null, bio: "Loves coffee" }); const after = renderProfileImage({ name: "Ayse", photo: "profile.png", bio: "Loves coffee" }); const ops = reconcile(before, after); printOps(ops); countFates(ops);
kept: /Card updated: /Card/Image kept: /Card/Text[name] kept: /Card/Input kept: 3 updated: 1 mounted: 0 unmounted: 0 commands: 1
The same change for the same user, with this description, is a single command. The placeholder’s native view was not unmounted; the same image view received the new source. The rule does not know the intent inside the description, it looks only at the type and the prop; if the person writing the description wants to see which type changes can be avoided, the measurement tells them. It cannot be said that every placeholder can be an image: turning a loading text into an image changes the meaning. But knowing that a type change has a cost makes it possible to make a choice while writing the description.
The Bounding Measurement
The focus node is the old child /Card/Text. When the image loads, the photo value in the card’s state goes from null to a source, and the first child becomes Image in place of the placeholder Text.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderProfile } from "./profile.mjs"; const before = renderProfile({ name: "Ayse", photo: null, bio: "Loves coffee" }); const after = renderProfile({ name: "Ayse", photo: "profile.png", bio: "Loves coffee" }); const ops = reconcile(before, after); printOps(ops); countFates(ops);
kept: /Card unmounted: /Card/Text mounted: /Card/Image kept: /Card/Text[name] kept: /Card/Input kept: 3 updated: 0 mounted: 1 unmounted: 1 commands: 2
/Card/Text came out unmounted and /Card/Image mounted; the commands are two. The two nodes are in the same position, but because their types differ, the rule did not count them as two states of the same node. No comparison was made between the placeholder’s props and the image’s props. The name text met its own counterpart thanks to its key and stayed kept; the type change next to it did not touch it. The input field stayed kept too.
The cost of an unmount is not only the command count. The native view of the placeholder Text and every piece of state it holds disappear; the new Image inherits nothing. The placeholder had no state, so nothing was lost here. If the same change had swapped an input field with a text view, the cursor position and selection in the field would have disappeared. This loss is not visible in the tree and is not measured in the model; it is stated in prose.
Now, for comparison, we do the opposite: the type stays the same and only the image’s source changes.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderProfile } from "./profile.mjs"; const before = renderProfile({ name: "Ayse", photo: "profile.png", bio: "Loves coffee" }); const after = renderProfile({ name: "Ayse", photo: "profile2.png", bio: "Loves coffee" }); const ops = reconcile(before, after); printOps(ops.filter((op) => op.fate !== "kept")); countFates(ops);
updated: /Card/Image kept: 3 updated: 1 mounted: 0 unmounted: 0 commands: 1
/Card/Image, standing in the same place, with the same type and the same key, came out updated; the commands are one. The image’s native view stayed in place and received the new source. The difference between a type change and a prop change is visible in the command count: the first is two commands, the second one command, and the native view’s identity lives on only in the second. If we do not want to pay for one of the two commands, we can make the placeholder an Image too. If the placeholder is an Image, when the image arrives the type stays the same and only the source prop changes. This is a design choice, and how it looks in the tree is read from these measurements.
CS5. The model makes the type comparison over the name. A real framework counts two different description functions as different and uses function identity; the model reduces this subtlety to the type name. The model builds this fate’s rule; the model is not the framework itself.
The common lesson of these measurements is this: what determines a node’s life is not how it looks on the screen but with which type and which key it is written in the description. Two different looks can be different props of the same type; two similar looks can be different types. When choosing the type, the person writing the description is really choosing this: will this node live as a text view or as an image view? Keeping what will stay the same throughout its life in a single type makes updates cheap; keeping what will live differently in separate types means knowingly accepting unmounting and mounting.
Summary
- This lesson’s fate: unmounted. In the bounding measurement, the placeholder
/Card/Textcame out unmounted without its props being examined because its type changed when the image arrived;/Card/Imagewas mounted in its place. - The core components covered in this lesson are view, text, image, and input field; each is a typed node in the tree and corresponds to a view of its type on the native side.
- A prop change on the same type leaves the node updated: when the name changed,
/Card/Text[name]was updated and the commands stayed at one. - The same data change gives a different fate depending on the node it reaches: the name updated one node with the placeholder and two with the image.
- The type change produced two commands (one unmounted, one mounted); when only the image’s source changed, one command went and the node was updated.
Next Step
In this lesson the props were plain values such as text and a source. The next lesson, The Style System, covers the style that determines a node’s look: style also enters the tree as a prop, and the lesson measures what a new style object whose content stays the same does in the tree.
To keep your progress and take notes, Log in
My notes
Log in to take notes.