Lesson 25 / 25
Framework Selection
Where criteria come from, writing weights explicitly, testing the ranking with sensitivity analysis, measuring the decision's reversibility at the line level, and putting the decision in writing.
Contents
All four families now have their characteristics, costs, and trade-offs laid out. One question remains, and its answer lies not in the families’ characteristics but in the project itself.
This lesson is not a comparison table — such a table is incomplete even for the month it is written in, and misleading a year later. What is built instead is a framework that writes criteria explicitly, makes weights visible, and states which assumption the decision rests on. The framework does not change; its inputs get refilled for every project.
Where Criteria Come From
Most selection debates get stuck because different kinds of information end up on the same list. Criteria come from three sources, and each has a different reliability.
Criteria derivable from the family were measured across this topic’s four lessons: update granularity, the code reaching the runtime, the compile-step requirement, manual memoization overhead, fit with dynamic structures, update divisibility, and decision-surface width. These follow from the family’s definition, known without looking at any product’s documentation.
Criteria read from the candidate are measured by looking at a specific framework: documentation quality, breaking-change history, release cadence, issue-closing time, accessibility support. These do not follow from the family and change over time — they must be measured at decision time.
Criteria read from the team belong to neither the family nor the candidate: prior experience, learning budget, ease of hiring, expected project lifespan, who will maintain it. These often weigh heaviest and get discussed least in technical debate.
The model below scores only the first group’s criteria; the second and third groups are treated not as weights but as independent inputs that feed directly into the decision.
Writing Weights Explicitly
// selection.mjs — decision framework: explicit weights and sensitivity analysis // Scores (0-3) come from characteristics measured in previous lessons; this is not a product evaluation. const UPDATE_MODEL = { "virtual-tree-based": { "fine-grained update": 1, "little runtime code": 1, "no compile step required": 3, "no manual memoization required": 0, "fits dynamic structures": 3, "update divisibility": 3, }, "runtime dependency tracking": { "fine-grained update": 3, "little runtime code": 2, "no compile step required": 2, "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 1, }, "compile-time reactive": { "fine-grained update": 3, "little runtime code": 3, "no compile step required": 0, "no manual memoization required": 3, "fits dynamic structures": 1, "update divisibility": 1, }, }; const SCOPE = { "narrow-scoped": { "small decision surface": 0, "upgrade divisibility": 3, "gradual exit": 3, "single contract": 1 }, "integrated": { "small decision surface": 3, "upgrade divisibility": 0, "gradual exit": 0, "single contract": 3 }, }; const PROJECTS = { "public station page": { "fine-grained update": 1, "little runtime code": 5, "no compile step required": 1, "no manual memoization required": 2, "fits dynamic structures": 1, "update divisibility": 1, "small decision surface": 2, "upgrade divisibility": 2, "gradual exit": 3, "single contract": 1, }, "internal observability dashboard": { "fine-grained update": 5, "little runtime code": 1, "no compile step required": 1, "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 2, "small decision surface": 4, "upgrade divisibility": 1, "gradual exit": 1, "single contract": 4, }, }; const score = (candidates, weight) => Object.entries(candidates).map(([name, traits]) => [ name, Object.entries(traits).reduce((t, [criterion, p]) => t + (weight[criterion] ?? 0) * p, 0), ]).sort((a, b) => b[1] - a[1]); for (const [project, weight] of Object.entries(PROJECTS)) { console.log(`\n=== ${project} ===`); for (const [title, candidates] of [["update model", UPDATE_MODEL], ["scope", SCOPE]]) { const ranking = score(candidates, weight); console.log(` ${title}:`); for (const [name, p] of ranking) console.log(` ${name.padEnd(38)} ${String(p).padStart(4)}`); console.log(` gap (first − second): ${ranking[0][1] - ranking[1][1]}`); } } // Sensitivity: at what weight for one criterion does the top of the ranking flip? console.log("\n=== sensitivity: update model, internal observability dashboard ==="); const baseline = { ...PROJECTS["internal observability dashboard"] }; const winner = (a) => score(UPDATE_MODEL, a)[0][0]; const baselineWinner = winner(baseline); console.log(`top rank at baseline weights: ${baselineWinner}`); console.log("criterion baseline flip weight new top rank"); for (const criterion of Object.keys(UPDATE_MODEL["virtual-tree-based"])) { let flip = null, next = null; for (let w = 0; w <= 20; w++) { const trial = { ...baseline, [criterion]: w }; if (winner(trial) !== baselineWinner) { flip = w; next = winner(trial); break; } } console.log( `${criterion.padEnd(36)} ${String(baseline[criterion]).padStart(5)} ` + `${(flip === null ? "no flip" : String(flip)).padStart(15)} ${next ?? "-"}` ); }
=== public station page ===
update model:
compile-time reactive 26
runtime dependency tracking 25
virtual-tree-based 15
gap (first − second): 1
scope:
narrow-scoped 16
integrated 9
gap (first − second): 7
=== internal observability dashboard ===
update model:
runtime dependency tracking 39
compile-time reactive 32
virtual-tree-based 24
gap (first − second): 7
scope:
integrated 24
narrow-scoped 10
gap (first − second): 14
=== sensitivity: update model, internal observability dashboard ===
top rank at baseline weights: runtime dependency tracking
criterion baseline flip weight new top rank
fine-grained update 5 no flip -
little runtime code 1 9 compile-time reactive
no compile step required 1 16 virtual-tree-based
no manual memoization required 3 no flip -
fits dynamic structures 3 no flip -
update divisibility 2 10 virtual-tree-based
The same score table produces different rankings for the two projects — the difference comes from the weights, not the families: on the public page, downloaded-code leanness carries five times the weight; on the observability dashboard, fine-grained update does.
On the scope axis, the ranking splits more sharply: long lifespan and gradual exit matter most on the public page, so narrow scope leads; a small decision surface and a single contract dominate on the internal dashboard, so the integrated profile leads. The two axes are independent and decided separately.
What Matters Is the Gap, Not the Ranking
In the first project, the gap between first and second place is 1 point — the model gives no real answer for that project. The scores rest on estimated weights, and a one-point gap disappears with the smallest change to them. The correct reading is not “first place won” but “these two options are not separated by these criteria.” The decision is left to criteria outside the model — those read from the candidate and the team.
In the second project, the gap is 7 points, and the sensitivity table shows how solid that is: raising the heaviest criterion’s weight to twenty does not change first place, but raising little-runtime-code’s weight from 1 to 9 flips the ranking. This states which assumption the decision rests on — if “downloaded code is secondary for this project” falls, so does the decision.
This is sensitivity analysis’s real function. It does not produce a number; it puts the decision’s breaking point in writing. Once the breaking point is known, whether the decision needs revisiting when project conditions change stops being debatable.
The Decision’s Reversibility
The final criterion for the choice is what happens if the choice turns out wrong. This too can be counted.
// migration.mjs — measuring how much of a decision stays reversible // Coupling: 0 = independent of the framework, 1 = via interface, 2 = directly bound. const SEPARATED = [ { name: "threshold and date validation rules", lines: 180, coupling: 0 }, { name: "measurement formatting and unit conversion", lines: 120, coupling: 0 }, { name: "option-list state machine", lines: 260, coupling: 0 }, { name: "accessibility attribute derivation", lines: 90, coupling: 0 }, { name: "data access layer", lines: 310, coupling: 1 }, { name: "presentational components (markup)", lines: 940, coupling: 1 }, { name: "containers and hooks", lines: 380, coupling: 2 }, { name: "routing and form binding", lines: 360, coupling: 2 }, ]; // The same application, without the separations: logic lives in the component body. const UNSEPARATED = [ { name: "components (logic in the body)", lines: 2210, coupling: 2 }, { name: "data access layer", lines: 310, coupling: 1 }, { name: "routing and form binding", lines: 360, coupling: 2 }, ]; function measure(name, modules) { const total = modules.reduce((t, m) => t + m.lines, 0); const group = (level) => modules.filter((m) => m.coupling === level).reduce((t, m) => t + m.lines, 0); const independent = group(0), viaInterface = group(1), direct = group(2); // Rewritten during migration: all of what is directly bound, half of what is bound via interface. const rewritten = direct + Math.round(viaInterface / 2); console.log(`\n--- ${name} ---`); console.log("module lines coupling"); for (const m of modules) console.log(`${m.name.padEnd(42)} ${String(m.lines).padStart(6)} ${m.coupling}`); console.log(`total ${total} lines | independent ${independent} (%${((independent / total) * 100).toFixed(1)})` + ` | via interface ${viaInterface} | directly bound ${direct}`); console.log(`rewritten during migration: ${rewritten} lines (%${((rewritten / total) * 100).toFixed(1)})`); return rewritten; } const a = measure("separated", SEPARATED); const b = measure("not separated", UNSEPARATED); console.log(`\nwhat the separations gained in migration: ${b - a} lines`);
--- separated --- module lines coupling threshold and date validation rules 180 0 measurement formatting and unit conversion 120 0 option-list state machine 260 0 accessibility attribute derivation 90 0 data access layer 310 1 presentational components (markup) 940 1 containers and hooks 380 2 routing and form binding 360 2 total 2640 lines | independent 650 (%24.6) | via interface 1250 | directly bound 740 rewritten during migration: 1365 lines (%51.7) --- not separated --- module lines coupling components (logic in the body) 2210 2 data access layer 310 1 routing and form binding 360 2 total 2880 lines | independent 0 (%0.0) | via interface 310 | directly bound 2570 rewritten during migration: 2725 lines (%94.6) what the separations gained in migration: 1360 lines
The same application, with two different internal layouts. With the separations made, a quarter of the code is entirely independent of the framework, and the portion to rewrite in a migration drops by half. Without them, independent code is zero and nearly the whole application gets rewritten.
The four modules that stay independent are not a coincidence: validation rules, formatting, the state machine, and accessibility attribute derivation — all products of the previous topic’s separations: presentation from container, behavior into a headless core, logic into hooks. Their value is not limited to readability; they are also what keeps the framework decision reversible.
This leads to a conclusion that softens the selection debate. When the independent-code ratio is kept high, the framework decision is a choice, not a fate. The weight of the decision is inversely proportional to its reversibility.
The Decision in Writing
The decision framework’s output is not a framework name; it is a record. The record has to carry five things.
Context: what the application is, its expected lifespan, team size and experience. Criteria and weights: which criterion outweighs which, and why, with numbers. The candidates evaluated and why each was eliminated. The assumptions the decision rests on, and the breaking points from sensitivity analysis. The review condition: which event brings the decision back up for reconsideration.
The most useful part of this record is the last one. The sentence “this will be revisited if this condition changes” makes the decision both defensible and changeable; a decision without a record loses its rationale over time and persists only as habit.
Summary
- Criteria come from three sources: those derivable from the family, those read from the candidate, and those read from the team. Only the first follows from the family’s definition.
- When weights are written explicitly, the same score table produces different rankings for different projects; the difference comes from the weights, not the families.
- The update model and scope are two independent axes and are decided separately.
- What matters is not the ranking itself but the gap between first and second; a one-point gap means “not separated,” and sensitivity analysis puts the decision’s breaking point in writing.
- The decision’s reversibility can be measured; with the separations made, the portion to be rewritten in a migration dropped from 94.6% to 51.7%.
- The output is not a framework name but a written decision record that includes the review condition.
Course Wrap-Up
This course began with the scaling problems of manual DOM management and built a one-way flow from state to view. The component was defined as a three-channel contract of props, output events, and local state, carried all the way through with lifecycle, side effects, derived values, context, and error boundaries.
In the second half, components were composed together. The choice between wrapping and slot-based composition was justified by counting prop drilling and option explosion. Logic moved into hooks and composables, families of components that work together were built, and data, behavior, and view split into three layers. The component’s outer surface — prop spreading, default merging, control ownership — was designed as a contract.
The final topic covered the update mechanisms underneath these components. The same change in the same two-hundred-row measurement table produced work ranging from sixteen hundred comparisons down to a single write, depending on the family. The four families’ characteristics, costs, and decision surfaces were laid out with numbers and turned into a decision framework that never names a single product.
There is a gap where the course leaves off. Everything so far lived inside a single component tree: state is born in a component, flows down through it, and gets consumed there. But the station application is not a single screen — the measurement table is at one address, the archive view at another; the filter preference has to persist across two screens; measurement data comes from the server, and its browser copy does not know when to refresh. None of this is solved by component composition; it belongs to the layer above components.
The next course — Application Architecture: Routing, State and Data — builds this layer: how an address maps to a view, how nested layouts share a shell, why local, shared, and server state are managed separately, caching and invalidating server data, sharing form state and validation schemas, and the options and risks of browser authentication. This course’s composition and surface-design habits hold there too — the owner of state, the boundary of the contract, and the reversibility of the decision are the same questions, only at greater scale.
To keep your progress and take notes, Log in
My notes
Log in to take notes.