Old-School Front-End · Frameworks / 2026 ・ No.03

Old-School Front-End: Frameworks

Part 3: History II, after React — the rebuild-and-compare school and the signals school

Published

Four browser windows in a 2×2 grid, each showing the Expense line items table after the same eight actions as part 1. Top left is version G (Vue 3.5), top right H (Svelte 5), bottom left I (Solid 1.9), bottom right J (Angular 22). All four show 3 line(s), amounts 14720 and 100 (USD), one empty row, and a total of ¥29,843
The same items table in the four that came after React, given the same actions under a strict CSP. All four get the count and total right. (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)

Part 2 measured how 2010's three schools kept the count from drifting and ran faster than React. Even so, React, released in 2013, spread, and new answers followed: Vue, Angular, Svelte, Solid.
Part 3 writes the same items table in current versions of those four and measures them beside part 1's React. At the end it touches on building screens on the server again.

1

What React brought

AraiReact was the slowest last time. So how did it win?

FuruiReact didn't sell speed. It sold clarity: once the state is fixed, there's only one possible screen.

A route map of all 15 parts (you are here: part 3)

1 Why FWs 2 jQuery→MVC 3 After React 4 First React 5 State 6 Forms 7 Fetching 8 Render cost 9 Where state 10 Routing 11 Optimistic 12 Server Comp. 13 Caching 14 Testing 15 Ship & choose You are here Why & history React basics Speed & state Pages & server Next.js Finishing

Part 2's Knockout and AngularJS tied values and screen both ways. As screens grew, it got hard to trace whether a value was changed by an input box, another calculation or some other watcher. React fixed the direction: one way. State changes only through set entrances like setRows; the screen is rebuilt from state every time, compared with the last one, and only the differences are written (part 1's version C). The copy of the screen's shape used for comparing is called the virtual DOM.

After React (timeline)

After React (timeline) 2013 2015 2017 2019 2021 2023 2025 React 2013.5 hooks 19, RSC Vue 1.0 3.0 Angular 2.0 signals zoneless Svelte 3 5 runes Solid 1.0 Next.js 1.0 App Router Rebuild-and-compare from ~2016; signals and the server in the 2020s

The idea caught on, and the schools that followed all build on one-way, state to screen. What differs is how they decide what to rewrite. I wrote all four with part 1's items table. Each is built into JavaScript before shipping, and the server keeps Frontend part 15's strict CSP.

2

Vue: templates and watched values

Vue (version G) was started in 2014 by a developer who had used AngularJS and wanted something lighter. The HTML-like templates and v-model resemble AngularJS, but values go in refs and Vue remembers which value is read where (close to part 2's Knockout).

<!-- Style G: Vue 3.5. Values go in refs; the template's v-model and {{ }} tie them to the screen and become JavaScript at build time -->
<script setup>
import { ref, computed, onMounted } from 'vue';

let nextId = 1;
const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
const T = ROWS_TEXT;
const { formatYen, needsReceipt } = window; // app.js functions (the template only sees what is declared here)

const rows = ref([newRow()]);
const rate = ref(null);

function yenOf(row) { … } // same as part 1 (convert to yen)
// total: recomputed only when a value it read (rows, amounts, currencies, rate) changes
const total = computed(() => calcTotal(rows.value.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));

const add = () => rows.value.push(newRow());
const remove = (row) => {
  rows.value = rows.value.filter((r) => r !== row);
  if (rows.value.length === 0) rows.value.push(newRow());
};
onMounted(() => { fetch('/api/rates').then((res) => res.json()).then((data) => { rate.value = data; }).catch(() => {}); });
</script>

<template>
  <h1>{{ T.heading }} <span id="count">{{ T.count(rows.length) }}</span></h1>
  <table>
    <tbody id="items">
      <tr v-for="(r, i) in rows" :key="r.id">
        <td><input name="item" v-model="r.item"></td>
        <td><input name="amount" v-model="r.amount"></td>
        <td><select name="currency" v-model="r.currency">…</select></td>
        <td><button @click="remove(r)">{{ T.remove }}</button></td>
      </tr>
    </tbody>
  </table>
  …
  <output id="total">{{ formatYen(total) }}</output>
</template>

The count (★) is just {{ T.count(rows.length) }} in the template. To update the screen, though, Vue does what React does: it rebuilds the component's template and compares it with last time. Watching values is fine-grained; rewriting the screen happens per component. That combination shows in section 7's numbers.

3

Angular: a rewrite, then signals

Angular (version J) is AngularJS rewritten from scratch in TypeScript; 2.0 arrived in 2016. The names are close, but it's a different thing. For years it used zone.js to recheck the whole screen whenever something might have happened. Signals arrived in 16 (2023), and from 21 (2025) new apps run without zone.js by default.

// Style J: Angular 22. Values go in signals and computed gives the total. Templates become JavaScript at build time.
// From 21, no zone.js (zoneless) is the default; the screen is rechecked only when a signal changes
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';

type Row = { id: number; item: string; amount: string; currency: string };
type Rate = { rate: number; date: string };

@Component({
  selector: 'rows-app',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>{{ T.heading }} <span id="count">{{ T.count(rows().length) }}</span></h1>
    <table>
      <tbody id="items">
        @for (r of rows(); track r.id; let i = $index) {
          <tr>
            <td><input name="item" [value]="r.item" (input)="change(r.id, 'item', $event)"></td>
            <td><input name="amount" [value]="r.amount" (input)="change(r.id, 'amount', $event)"></td>
            <td><select name="currency" [value]="r.currency" (change)="change(r.id, 'currency', $event)">…</select></td>
            <td><button (click)="remove(r.id)">{{ T.remove }}</button></td>
          </tr>
        }
      </tbody>
    </table>
    …
    <output id="total">{{ totalText() }}</output>
  `,
})
class RowsComponent {
  T = ROWS_TEXT;
  max = MAX_ROWS;
  rows = signal<Row[]>([newRow()]);
  rate = signal<Rate | null>(null);

  private yenOf(row: Row, rate: Rate | null) { … } // same as part 1 (convert to yen)
  // total: recomputed only when a signal it read (rows, rate) changes
  total = computed(() => calcTotal(this.rows().map((r) => this.yenOf(r, this.rate())).filter((y): y is number => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));
  totalText = computed(() => formatYen(this.total()));
  receipt = computed(() => needsReceipt(this.total()));

  change(id: number, name: 'item' | 'amount' | 'currency', event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.rows.update((rows) => rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  }
  add() { … }
  remove(id: number) { … }
}

signal is a container for a value; computed is a value calculated from it. The template's @for tells rows apart with track r.id. With its type declarations, it has the most lines of the four.

4

Svelte: the compiler writes it

Svelte (version H) appeared in 2016 and took the position of a compiler. Instead of comparing in the browser, it emits JavaScript at build time that says "when this value changes, rewrite this text". Since Svelte 5 (2024), values are written as $state and computed values as $derived (runes).

<!-- Style H: Svelte 5. Values in $state read and write like plain variables; the compiler decides at build time what to rewrite -->
<script>
  let nextId = 1;
  const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
  const T = ROWS_TEXT;

  let rows = $state([newRow()]);
  let rate = $state(null);

  function yenOf(row) { … } // same as part 1 (convert to yen)
  // total: recomputed only when a value it read changes
  const total = $derived(calcTotal(rows.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));

  function remove(row) {
    rows = rows.filter((r) => r !== row);
    if (rows.length === 0) rows.push(newRow());
  }
  fetch('/api/rates').then((res) => res.json()).then((data) => { rate = data; }).catch(() => {});
</script>

<h1>{T.heading} <span id="count">{T.count(rows.length)}</span></h1>
<table>
  <tbody id="items">
    {#each rows as r, i (r.id)}
      <tr>
        <td><input name="item" bind:value={r.item}></td>
        <td><input name="amount" bind:value={r.amount}></td>
        <td><select name="currency" bind:value={r.currency}>…</select></td>
        <td><button onclick={() => remove(r)}>{T.remove}</button></td>
      </tr>
    {/each}
  </tbody>
</table>
…
<output id="total">{formatYen(total)}</output>

Write rows.push(newRow()) as if it were a plain array, and the screen follows. At 44 lines it was the shortest of the four.

5

Solid: components that run once

Solid (version I) is written in JSX like React, but works the opposite way. The component function runs only once. When a signal read inside a JSX {…} changes, only that spot is rewritten.

// Style I: Solid 1.9. Values go in signals (here the rows go in a store). The component function runs only once;
// when a signal read inside a JSX {…} changes, only that spot is rewritten
import { createSignal, createMemo, For } from 'solid-js';
import { createStore } from 'solid-js/store';
import { render } from 'solid-js/web';

let nextId = 1;
const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
const T = ROWS_TEXT;

function Rows() {
  const [rows, setRows] = createStore([newRow()]);
  const [rate, setRate] = createSignal(null);

  const yenOf = (row) => { … }; // same as part 1 (convert to yen)
  // total: recomputed only when a value it read changes
  const total = createMemo(() => calcTotal(rows.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));
  const change = (i, name) => (e) => setRows(i(), name, e.target.value);
  const remove = (row) => {
    const rest = rows.filter((r) => r !== row);
    setRows(rest.length > 0 ? rest : [newRow()]);
  };

  return (
    <>
      <h1>{T.heading} <span id="count">{T.count(rows.length)}</span></h1>
      <table>
        <tbody id="items">
          <For each={rows}>{(r, i) => (
            <tr>
              <td><input name="item" value={r.item} onInput={change(i, 'item')} /></td>
              <td><input name="amount" value={r.amount} onInput={change(i, 'amount')} /></td>
              <td><select name="currency" value={r.currency} onChange={change(i, 'currency')}>…</select></td>
              <td><button onClick={() => remove(r)}>{T.remove}</button></td>
            </tr>
          )}</For>
        </tbody>
      </table>
      …
      <output id="total">{formatYen(total())}</output>
    </>
  );
}

It looks like React and behaves like Knockout's line. Together with Svelte and Angular's signals, I'll call these three the signals family.

6

The same eight actions

# The same eight actions as part 1, under Frontend part 15's strict CSP
  style           count wrong   total wrong   CSP errors    cursor after add / remove
  G Vue 3.5       0 / 8         0 / 8         0             Add a row / BODY
  H Svelte 5      0 / 8         0 / 8         0             Add a row / BODY
  I Solid 1.9     0 / 8         0 / 8         0             Add a row / BODY
  J Angular 22    0 / 8         0 / 8         0             Add a row / BODY
  C React 19      0 / 8         0 / 8         0             Add a row / BODY
# The same in all three languages. All are built before shipping, so none builds functions from text

None of the four let the count or total drift, and the strict CSP produced zero errors (the same on the Japanese and Chinese pages). Unlike part 2's Backbone template and Knockout, the templates are already JavaScript after the build, so the browser never builds functions from text. As for the cursor, none of them does anything unless you write it.

# Typing 1, 4, 7, 2, 0 into row 1's amount on a three-row table: screen changes per key (MutationObserver)
  style           records     what changed
  G Vue 3.5       1           replaces the total text
  H Svelte 5      1           only the total text
  I Solid 1.9     2           the total text and the notice's hidden
  J Angular 22    1           only the total text
  C React 19      23          21 are React re-setting name and type to the same values

Each keystroke rewrote only the total text (Solid also re-set the notice's hidden). Only React includes 21 steps that re-set input attributes to the same values. What reaches the screen is nearly the same for every version. The difference lies in how much JavaScript work it takes to get there.

7

5000 rows, two families

With 10, 1000 and 5000 rows, I timed the JavaScript work and the browser's drawing for one keystroke. Vue, Svelte and Angular postpone the screen update slightly, so part 2's method (until the input event returns) isn't enough. This time the times come from Chromium's tracing, summed over 20 keystrokes. Part 2's Knockout and AngularJS were measured again the same way.

# With 10, 1000 and 5000 rows: time per keystroke, and time to open
  style           JS per key (ms)         drawing (ms)  open (ms)
                  10     1000   5000      5000          1000   5000
  C React 19      2.0    20.4   99.6      124.3         603    2,280
  G Vue 3.5       1.7    15.1   91.5      105.5         652    2,208
  H Svelte 5      0.8    3.0    9.6       106.0         574    2,540
  I Solid 1.9     1.3    2.1    11.4      117.5         473    1,936
  J Angular 22    1.2    2.4    6.7       127.7         575    2,079
  E Knockout      0.8    1.6    5.2       107.2         759    2,793
  F AngularJS     1.7    3.1    24.4      125.1         814    3,432
# JS and drawing times come from Chromium tracing, summed over 20 keystrokes and divided; median of 3 runs. This differs from part 2's method (until input returns)
A terminal with the output of node ../keys3.js 5000. rows-react.html: JS 107.2 ms, render 130.2 ms. rows-vue.html: 100.0 and 117.6. rows-svelte.html: 10.9 and 128.3. rows-solid.html: 11.9 and 134.3. rows-angular.html: 6.6 and 147.6
One run of timing each keystroke on a 5000-row table (the figures in the text are medians of three runs). (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)

One keystroke on 5000 rows: JavaScript time

One keystroke on 5000 rows: JavaScript time (measured) C React 19 99.6 ms G Vue 3.5 91.5 ms F AngularJS (2010) 24.4 ms I Solid 1.9 11.4 ms H Svelte 5 9.6 ms J Angular 22 6.7 ms E Knockout (2010) 5.2 ms (browser drawing) ≈ 118 ms Signals: about 10 ms. Rebuild-and-compare: in the 90s Dotted bars are part 2's. Drawing takes about the same everywhere

They split cleanly in two. React and Vue take 90-odd ms; Svelte, Solid and Angular about 10 ms, on the same side as 2010's Knockout (5.2 ms).

Two families: rebuild and compare / only what's tied

Rebuild and compare (virtual DOM) state changes setRows, ref run the component again rebuild the whole shape compare, then write only the differences C React 19 99.6 ms G Vue 3.5 91.5 ms Only what's tied (signals) a value changes $state, signal, store knows where it's read tied at build time write just there nothing to compare H Svelte 5 9.6 ms I Solid 1.9 11.4 ms J Angular 22 6.7 ms E Knockout (2010) 5.2 ms Both go one way, state to screen. JS per key at 5000 rows

Even when a single amount changes, React and Vue run the component again, build the shape of all 5000 rows and compare. The signals family knows from the start where that amount is read (the total), so there's nothing to compare. You could say the 2020s' signals won back the price React paid for clarity in 2013. Drawing (a little over 100 ms), though, is the same for all. Putting a 5000-row table on screen is heavy in itself, the same conclusion as part 2.

AraiSo we drop React and switch to signals…

FuruiOn a ten-row table, every one of them finishes in about 2 ms. React has its own ways to cut this down; we'll measure them in part 8.

8

Builds and size

A terminal. node build.mjs builds with Vite 8: vue 227 ms, svelte 183 ms, solid 177 ms. ls shows rows-solid.js 18583, rows-svelte.js 38401 and rows-vue.js 68096 bytes. npx ng build: main.js 110.73 kB, estimated transfer 33.55 kB, 3.192 seconds
Vue, Svelte and Solid built with Vite, Angular with the Angular CLI. All become JavaScript before they ship. (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)
# How much was hand-written, and how much reaches the browser (app.js and the text strings are the same for all and left out)
  style           lines     delivered     gzip        build
  G Vue 3.5       47        68,096 B      26,359 B    Vite 8.3  0.2 s
  H Svelte 5      44        38,401 B      15,032 B    Vite 8.3  0.2 s
  I Solid 1.9     54        18,583 B      7,232 B     Vite 8.3  0.2 s
  J Angular 22    73        110,729 B     37,223 B    Angular CLI 22  3.2〜4.4 s
  C React 19      59        226,513 B     70,123 B    esbuild 0.28  73 ms
# Lines exclude blanks (comments included); J has 13 lines of type declarations. For reference, part 2's Knockout is 70,680 B

Delivered size ranged from Solid's 18 KB to React's 226 KB, a factor of 12. Svelte and Solid emit only the parts used, so they stay small. React, as in part 1, is mostly react-dom, the part that writes to the screen. Builds took 0.2 s with Vite and a few seconds with the Angular CLI. The build step part 1 called "what you pay in return" is now an ordinary step in every school.

9

Building screens on the server

All seven so far build the screen in the browser. The HTML that arrives first is nearly empty, and the items table doesn't appear until the JavaScript arrives and runs. So building HTML on the server and sending it came back.

  • 2016 — Next.js 1.0. React screens are turned into HTML on the server before sending (SSR)
  • 2023 — Next.js 13.4's App Router. Each part of the screen is marked to run on the server or in the browser
  • 2024 — Server Components become stable in React 19. Parts that run only on the server send no JavaScript to the browser

It sits right between the days when a server like the backend series' Spring Boot returned HTML and the days of building everything in the browser. This series measures it on Next.js in parts 12 and 13.

10

On your own PC

Every school has a command that creates a starter project. With Node.js installed, one line gives you a practice project for each.

# Vite starters (choose react, vue, svelte, solid and more)
npm create vite@latest
# Angular starter
npx @angular/cli new my-app

Write the same screen in two schools and record one keystroke in the developer tools' Performance tab, and you'll see this part's two families for yourself. Compare the length of the yellow (Scripting).

§ — On one page, and next time

  1. React (2013) brought one way: rebuild the screen from state, compare with last time, write the differences.
  2. Current Vue, Angular, Svelte and Solid: zero drift in the eight actions and zero errors under a strict CSP. All are built before shipping.
  3. One keystroke at 5000 rows: rebuild-and-compare React and Vue take 90-odd ms; signal-based Svelte, Solid and Angular about 10 ms. Drawing is a little over 100 ms for all.
  4. Delivered size runs from Solid's 18 KB to React's 226 KB.
  5. Since 2016, building screens on the server has come back (Next.js, Server Components).

AraiGot the history. So which one is this series written in?

FuruiReact. It's the most used, and with Next.js it reaches all the way to the server. Where it's slow, we measure and fix.

FuruiNext time, first steps in React. A Vite starter, JSX and components, and the start of the expense screens.

References

Recorded by the author on a cloud Linux machine on 1 November 2026. Pages were served by a practice server (Node.js 22), and driven and measured with Chromium 153.0.8010.12 under Playwright. G is Vue 3.5.44, H Svelte 5.57.3, I Solid 1.9.18, built with Vite 8.3.5; J is Angular 22.2.4, built with the Angular CLI 22.2.5. C is part 1's React 19.3.1. For timing, a value was set in the same box, an input event sent, and the next frame awaited, 20 times; from Chromium's tracing over that span, JavaScript (FunctionCall, RunMicrotasks, TimerFire and so on) and drawing work (Layout, Paint and so on) were summed without overlap and divided by 20, taking the median of three runs. Release years follow npm's records and the references. Numbers will differ on other machines.

Old-School Front-End, Frameworks part 3 — History II, after React / 2026.11

← Back to all posts