---
title: 'Framework Selection'
source: 'https://academia.sh/en/courses/component-based-development/framework-selection'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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

```js
// 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.

```js
// 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.
