Skip to content
academia.sh

Lesson 01 / 20

What Is React Native

In a bridge-based framework, shared code does not draw the screen; it describes a view tree. The lesson classifies what an update does to each node of the tree with four fates: kept, updated, mounted, unmounted.

Published Updated

In this lesson

  • Connects the concepts of declarative description, view tree, native view, and bridge crossing
  • Classifies what an update does to each node in the tree as kept, updated, mounted, or unmounted
  • Measures that the command count consists only of updated, mounted, and unmounted nodes
  • Explains why changing a value that does not enter the tree produces no command
Contents

The previous course classified the four paths of cross-platform development and introduced the bridge-based approach: shared code maps a UI node to the platform’s own view and reaches the native side through a bridge crossing. This course opens up that approach in a single framework that works with a declarative component model. The framework’s name appears only in this lesson’s title and in a few places; the lesson is about a class of concepts, not about the framework.

Shared code does not draw the screen directly. On every update it describes how the screen should look as a view tree. The framework compares the new tree with the previous one and sends commands to the native side only for the nodes that changed. The course’s question comes from here: what does this update do to the node in the view tree?

The answer takes four forms, and their names do not change throughout the course. If the node stands in the same place in both trees with the same type and the same key, and its props are equal, the update leaves the node kept, and no command goes to the native side. If the node stands in the same place with the same type but at least one prop has changed, the node is updated: it stays where it is and its native view is updated. If the node has no counterpart in the previous tree, it is mounted and its native view is created from scratch. If the node has no counterpart in the next tree, it is unmounted, and the state inside it disappears along with its native view. The shared name for the four answers is fate. In the output they are written in ASCII: kept, updated, mounted, unmounted.

Describing the Screen

In a declarative UI, code does not say “create this view, then update that one”; given a state, it returns the screen as it should look in that state. The snippet below shows the shape of such functions. The snippet is not measured; the model does not run the framework’s code.

// Platform code: Orders
function Orders({ orders, loading }) {
  return (
    <View>
      <Text>{orders.length} orders</Text>
      {loading ? <ActivityIndicator /> : null}
    </View>
  );
}

The same description runs on two platforms; each platform turns the nodes in the description into its own native views. On iOS and on Android, what appears on the screen is the platform’s own control, not a picture drawn by shared code. All shared code holds is the description.

What the function returns is not a view but a description: a container, a text inside it, and depending on the state a loading indicator. The framework takes this description, places it side by side with the previous one, and passes the difference to the native side as commands. A command is a unit of work that crosses the bridge: “mount this view”, “change this prop of that view”, “unmount this view”. Regenerating the tree is a render, but it is not a command by itself. If the new tree comes out equal to the previous one, nothing crosses the bridge.

A note on vocabulary. The word prop here means a value a node receives from outside, for example the text of a text node. Throughout the course, “prop” stays in this sense; when a capability of the product is meant, it is called an “app feature”.

The Core

The rule gives the fate, not a label. In the core below, el builds a node: a type, props, children, and an optional key. reconcile compares two trees and produces a { fate, path } record for each node. printOps prints the records, countFates counts the four fates in a fixed order and gives the command total on the last line. The rule asks in order: if there is no previous node, the node and its subtree are mounted; if there is no next node, it is unmounted; if the type or key differs, the old subtree is unmounted, the new one is mounted, and the props are not examined; if the type and key are the same, the props are compared by content, and if they are equal the node is kept, otherwise updated. Then the children are matched: a child with a key by its key, a child without a key by its position.

// 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}`);
}

Eight Nodes of One Update

The first measurement is a single update of an orders screen. The fourth order arrives and the loading indicator goes away. In this measurement the tree is built directly with el.

// main.mjs
import { el, reconcile, printOps, countFates } from "./fate.mjs";

const before = el("Screen", {}, [
  el("Header", { title: "Orders" }),
  el("Text", { value: "3 orders" }),
  el("List", {}, [
    el("Row", { name: "Coffee" }, [], "a"),
    el("Row", { name: "Tea" }, [], "b"),
  ]),
  el("Spinner", { visible: true }),
]);

const after = el("Screen", {}, [
  el("Header", { title: "Orders" }),
  el("Text", { value: "4 orders" }),
  el("List", {}, [
    el("Row", { name: "Coffee" }, [], "a"),
    el("Row", { name: "Tea" }, [], "b"),
    el("Row", { name: "Water" }, [], "c"),
  ]),
]);

const ops = reconcile(before, after);
printOps(ops);
countFates(ops);
kept: /Screen
kept: /Screen/Header
updated: /Screen/Text
kept: /Screen/List
kept: /Screen/List/Row[a]
kept: /Screen/List/Row[b]
mounted: /Screen/List/Row[c]
unmounted: /Screen/Spinner
kept: 5
updated: 1
mounted: 1
unmounted: 1
commands: 3

Of the eight nodes, five are kept, one is updated, one is mounted, and one is unmounted; the commands are three. The screen, the header, and the list stand with the same type, the same place, and equal props. The value prop of the counter text goes from "3 orders" to "4 orders", so /Screen/Text is updated. The two rows with keys a and b match by their keys and do not change. The row with key c has no counterpart in the previous tree, so it is mounted. The Spinner in the fourth position has no counterpart in the next tree, so it is unmounted. The commands are the sum of updated, mounted, and unmounted; five of the eight nodes never touch the native side.

The path in the output is built from the root to the node with type names: /Screen/List/Row[c] is the row with key c inside the list inside the screen. The type names (Screen, Text, Row) are the model’s labels; they are not the framework’s own component names. When there are several siblings of the same type, what tells the paths apart is the key.

The key’s job shows here. Because the rows match by their keys, adding row c did not affect rows a and b. If they had no keys, they would be matched by position; that case will be measured separately in the list performance lesson of the performance and production topic.

A Re-render Is Not a Command

Not every lesson builds the tree by hand. In a real framework, code produces the tree from state, so the measurements from here on use a function that produces a tree from state. The function lives in the helper file below; it prints nothing.

// view.mjs — produces a view tree from the order state
import { el } from "./fate.mjs";

export function renderOrders(state) {
  const rows = state.orders.map((order) => el("Row", { name: order.name }, [], order.id));
  const children = [
    el("Header", { title: "Orders" }),
    el("Text", { value: `${state.orders.length} orders` }),
    el("List", {}, rows),
  ];
  if (state.loading) children.push(el("Spinner", { visible: true }));
  return el("Screen", {}, children);
}

Now we produce the tree twice from the same state. The state did not change; the two trees are two separate objects, but their contents are the same.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderOrders } from "./view.mjs";

const state = {
  orders: [
    { id: "a", name: "Coffee" },
    { id: "b", name: "Tea" },
  ],
  loading: false,
};

const before = renderOrders(state);
const after = renderOrders(state);
countFates(reconcile(before, after));
kept: 6
updated: 0
mounted: 0
unmounted: 0
commands: 0

The tree was regenerated, six of six nodes came out kept, and the commands were zero. A re-render is a piece of work, not a fate. A screen being re-rendered often does not mean commands go often to the native side; what tells this is not the number of renders but the difference between the trees. In the course’s later lessons, the number of re-renders and the number of commands will be counted separately.

CS1. The model sets up the framework’s comparison step only as a node tree. In a real framework, a node’s props can also carry function values, style objects, and fields specific to the native view; the model reduces these to values that can be compared with JSON.stringify, such as text and numbers. The model builds this fate’s rule; the model is not the framework itself. How many views a command creates on the native side, how long it takes, and how many frames it holds are not in the model.

State Unmounts or Mounts a Node

The same function describes a node leaving the tree and entering it. When loading finishes, the loading indicator drops out of the description; when loading starts again, it comes back. The measurement below runs the two directions one after the other and each time prints only the records that produce a command.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderOrders } from "./view.mjs";

const orders = [
  { id: "a", name: "Coffee" },
  { id: "b", name: "Tea" },
];

const loadingTree = renderOrders({ orders, loading: true });
const doneTree = renderOrders({ orders, loading: false });

console.log("loading finished");
const finished = reconcile(loadingTree, doneTree);
printOps(finished.filter((op) => op.fate !== "kept"));
countFates(finished);

console.log("loading started");
const started = reconcile(doneTree, loadingTree);
printOps(started.filter((op) => op.fate !== "kept"));
countFates(started);
loading finished
unmounted: /Screen/Spinner
kept: 6
updated: 0
mounted: 0
unmounted: 1
commands: 1
loading started
mounted: /Screen/Spinner
kept: 6
updated: 0
mounted: 1
unmounted: 0
commands: 1

When the indicator left the tree, a single node was unmounted; when it came back, the same node was mounted. In both directions the command count is one and six nodes stayed kept. The fate depends on the direction of the update: the same node is unmounted in one direction and mounted in the other. Mounting happens from scratch. Because the state of an unmounted node disappears, the indicator that comes back is not a continuation of the earlier one but a new one.

There is a distinction here. Removing the indicator from the tree and setting the visible prop to false are not the same thing. In the second, the node stays in the tree, its single prop changes, and the node is updated; its native view lives on. Which one is right depends on the screen, but the kind of command and the fate of the node’s state arise from this choice.

The Bounding Measurement

The rule sees the tree’s structure, not the native side’s work. The rule sees only what is in the tree: when a value that does not enter the tree as a prop changes, the node comes out kept. This is not a contradiction; it is the measure itself.

We increase the order counter in a local variable that does not enter the tree. The variable is a temporary value inside a function; it is not written to the state object and renderOrders does not read it. The counter increases, the tree is regenerated. The node in question is /Screen/Text.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderOrders } from "./view.mjs";

const state = {
  orders: [
    { id: "a", name: "Coffee" },
    { id: "b", name: "Tea" },
  ],
  loading: false,
};

let received = 0;
const before = renderOrders(state);
received += 1;
const after = renderOrders(state);

const ops = reconcile(before, after);
printOps(ops.filter((op) => op.path === "/Screen/Text"));
countFates(ops);
console.log(`received: ${received}`);
kept: /Screen/Text
kept: 6
updated: 0
mounted: 0
unmounted: 0
commands: 0
received: 1

The counter went up by one and received: 1 was printed, but /Screen/Text came out kept and the commands stayed at zero. The user sees the old count on the screen: because the changed value did not enter the tree as a prop, the framework does not look at it. Since the framework is unaware of the change, it sends no command to the native side; the problem is not in the rule but in the value never being written to the tree. The fix is to write the counter into the state object: then the value prop of the Text node changes and the node is updated, as in the first measurement.

This limit has a second face. The native side can keep its own state: the cursor position of an input field, the scroll offset of a scrolling list. These values are not in the tree and the rule does not see them. What the rule looks at is only the props in the description.

Summary

  • This lesson’s fate: kept. In the bounding measurement, the counter of the /Screen/Text node increased in a variable that does not enter the tree; the node came out kept and the commands stayed at zero.
  • In a bridge-based framework, shared code does not draw the screen but describes a view tree; the framework compares the new tree with the previous one and sends commands to the native side only for the nodes that changed.
  • The four fates are the same throughout the course: kept, updated, mounted, unmounted. In the first measurement, of eight nodes five came out kept, one updated, one mounted, one unmounted; the commands were three.
  • The command count is the sum of updated, mounted, and unmounted. Regenerating the tree is not a command: two trees from the same state gave six kept and zero commands.
  • The rule sees only what is in the tree; when a value that does not enter the tree changes, the node stays kept.

Next Step

In this lesson the tree was ready and was only compared. The next lesson, Environment Setup, asks where the tree comes from in the first place: when an app first opens there is no previous tree, and the whole tree is mounted.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close