Skip to main content

The Problem React Solves

What you'll learn

  • Why hand-written DOM synchronization breaks down as soon as a UI has more than one moving part
  • The "two sources of truth" trap and the drift bugs it guarantees
  • What "declarative" actually means, concretely, in code
  • The core loop of React: state changes → describe UI → React syncs the DOM
  • Why React's real job is synchronization, not components, not JSX

Every React feature you'll study in this tutorial, hooks, the fiber tree, the scheduler, exists to solve one mundane problem: keeping what's on the screen consistent with the data in your JavaScript. To feel why that's hard, let's build the same tiny app twice: once by hand with plain DOM APIs, once with React. The pain of the first version is the entire justification for the second.

The assignment: three features, one dataset

The page needs:

  1. A counter: "Completed: N".
  2. A list of tasks, each with a Done/Undo toggle.
  3. A filter input that hides tasks not matching what you type.

Three small features, all reading from the same data. Let's do it the old-fashioned way first.

Version 1: keeping the DOM in sync by hand

<!DOCTYPE html>
<html>
<body>
<h1>Tasks</h1>
<p>Completed: <span id="counter">0</span></p>
<input id="filter" placeholder="Filter tasks..." />
<ul id="list"></ul>
<button id="add">Add task</button>

<script>
// The real state: plain JavaScript data
const tasks = [];
let nextId = 1;
let filterText = '';

const counterEl = document.getElementById('counter');
const listEl = document.getElementById('list');

function rebuildList() {
listEl.innerHTML = ''; // destroy everything, rebuild from scratch
const visible = tasks.filter(t =>
t.text.toLowerCase().includes(filterText.toLowerCase())
);
for (const t of visible) {
const li = document.createElement('li');
const label = document.createElement('span');
label.textContent = t.text;
if (t.done) label.style.textDecoration = 'line-through';

const btn = document.createElement('button');
btn.textContent = t.done ? 'Undo' : 'Done';
btn.addEventListener('click', () => {
t.done = !t.done;
// ...and now WE must remember every place that depends on t.done:
label.style.textDecoration = t.done ? 'line-through' : 'none';
btn.textContent = t.done ? 'Undo' : 'Done';
counterEl.textContent = tasks.filter(x => x.done).length;
});

li.append(label, btn);
listEl.append(li);
}
}

document.getElementById('add').addEventListener('click', () => {
tasks.push({ id: nextId++, text: 'Task ' + nextId, done: false });
rebuildList(); // remember to refresh... did anything else change?
});

document.getElementById('filter').addEventListener('input', (e) => {
filterText = e.target.value;
rebuildList(); // again, our job to remember
});
</script>
</body>
</html>

It works, for now. But look closely at what "works" costs.

Every place this code can drift out of sync:

  1. The toggle handler updates three DOM spots by hand, the label style, the button text, the counter. Forget one (say, the counter) and the UI lies while the data is fine.
  2. Every new feature multiplies the sync points. Add a "remove task" button and you must remember it also changes the counter and the filtered list and any future "N items left" footer. Each feature touches every other feature's DOM.
  3. innerHTML = '' is a sledgehammer. Rebuilding the list on every keystroke destroys and recreates every node, throws away scroll positions and selections, and re-attaches listeners over and over. The btn and label captured in each toggle closure are destroyed on the next rebuild, harmless here by luck, a stale-node bug in a bigger app.
  4. Every fact is stored twice. "How many tasks are done?" lives in tasks and in the counter's textContent. Two copies of one fact is a promise to keep them in sync forever, made by the most forgetful entity in the system: you.

Point 4 has a name, and it's the heart of this chapter.

Two sources of truth = drift

Jargon: "source of truth". The one authoritative place a fact is stored. If a fact lives in two places, every update must write both, and any missed write creates drift: the copies disagree, and the UI lies.

In the vanilla version, the DOM is a second, hand-maintained copy of your JavaScript state. The counter span is a copy of a computation over tasks. Each li is a copy of a task. Each row's visibility is a copy of filterText. Every event handler is a manual sync routine, and every forgotten line is a bug that testing rarely catches, because the app mostly works.

This doesn't scale. Not because developers are careless, but because the number of sync points grows faster than the number of features. Five features that share data have dozens of places to forget an update.

Version 2: describe the UI, let React sync it

Same app, React:

import { useState } from 'react';

export default function TaskApp() {
  const [tasks, setTasks] = useState([]);
  const [filter, setFilter] = useState('');

  const visible = tasks.filter(t =>
    t.text.toLowerCase().includes(filter.toLowerCase())
  );
  const doneCount = tasks.filter(t => t.done).length;

  return (
    <div>
      <h1>Tasks</h1>
      <p>Completed: {doneCount}</p>
      <input
        placeholder="Filter tasks..."
        value={filter}
        onChange={e => setFilter(e.target.value)}
      />
      <ul>
        {visible.map(t => (
          <li key={t.id}>
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>
              {t.text}
            </span>{' '}
            <button
              onClick={() =>
                setTasks(tasks.map(x =>
                  x.id === t.id ? { ...x, done: !x.done } : x
                ))
              }
            >
              {t.done ? 'Undo' : 'Done'}
            </button>
          </li>
        ))}
      </ul>
      <button
        onClick={() =>
          setTasks([...tasks, { id: Date.now(), text: `Task ${tasks.length + 1}`, done: false }])
        }
      >
        Add task
      </button>
    </div>
  );
}

What happens:

  1. You write, once, what the UI should look like for any possible state: the counter is doneCount, each row shows its task, toggled rows are struck through, the list shows exactly visible.
  2. When something happens, you don't touch the DOM. You call a state setter: setFilter(...), setTasks(...).
  3. React re-runs your function with the new state, gets the new description, compares it to what's on screen, and applies exactly the needed changes, no more, no less.
  4. The counter, the strikethroughs, and the filtered list can never disagree, because they're all computed from the same state in the same pass.

Notice what disappeared: no getElementById, no createElement, no per-row addEventListener, no "remember to update the counter." Adding a remove button now means one setter call and one line of description, the sync problem doesn't come back.

Imperative vs declarative

Imperative (vanilla DOM)Declarative (React)
What you writeStep-by-step DOM commandsA description of UI for any state
Who updates the DOMYou, everywhere, foreverReact, in one place, always
Copies of each factTwo: JS data + DOMOne: your state
Cost of a new featureFind and extend every sync pointChange state + description
Characteristic bugDrift: UI ≠ dataLargely eliminated

Jargon: "declarative". You declare what the result should be, not how to produce it step by step. SQL is declarative ("give me rows where..."); React applies the same idea to UI.

The core loop

Everything React does fits into one loop:

Pseudocode model, not real source:

// React's entire job, conceptually:
function onStateChange(newState) {
const description = runYourComponents(newState); // what UI should be
const changes = diff(description, whatsOnScreen);
applyToDom(changes); // sync, minimal, complete
}

State changes → describe UI → React syncs the DOM. That's it. Hooks, fibers, lanes, schedulers, everything in the rest of this tutorial, exists to make that loop fast, interruptible, and correct at scale.

So here's the thesis to carry through the series: React's real job is synchronization. Not components (those are how you organize descriptions), not JSX (a spelling for descriptions), not the virtual DOM (a tool for the diff step). React is the machine that guarantees the screen is always a faithful printout of your state, so the DOM stops being a second source of truth and becomes a derived one.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: React is mainly about reusable components. Reality: components are an organizational tool; the problem being solved is keeping UI in sync with state. Pre-React libraries had components too, without the sync guarantee.
  • Misconception: Declarative means giving up control. Reality: you still decide exactly what the UI is in every state; you just stop hand-carrying every change to the screen.
  • Misconception: The virtual DOM is the point of React. Reality: it's an implementation detail of the sync step. The idea that matters is deriving UI from state; the diff machinery just makes derivation affordable.
  • Misconception: React apps contain no imperative code. Reality: event handlers are imperative, "when clicked, change this state." What's declarative is the path from state to screen.
  • Misconception: Manual DOM code is always faster. Reality: hand-sync wins only while the app stays tiny; forgotten updates and sledgehammer rebuilds usually lose to React's minimal writes as features pile up.

Why it works this way

  • Humans are bad at bookkeeping at scale. Sync points grow combinatorially with features; a derived UI has exactly one sync point, React itself.
  • One source of truth makes bugs reproducible. If the UI is wrong, either the state is wrong or the description is wrong. There's no third, hidden copy to suspect.
  • One-way data flow makes change traceable. Data always flows state → UI; events flow back as state changes. You can follow any pixel back to the data that produced it.
  • Centralizing DOM writes lets the engine optimize them. Because React owns every write, it can batch, reorder, and minimize them, impossible when writes are scattered through application code.

Try it yourself

  1. Save the vanilla version as an HTML file and open it. Add a "Remove" button to each row. Count how many separate DOM updates you must add to keep everything correct. (Answer: the list, the counter, and any future summary you add.)
  2. Now introduce drift on purpose: delete the counterEl.textContent = ... line from the toggle handler. Toggle a task and watch the counter lie while the data stays right. This is the exact bug class React eliminates.
  3. In the React version, add the same "Remove" feature. Notice you changed one line of state logic and one line of description, nothing else. That difference is the entire argument of this chapter.
  4. Add a footer to the React version: <p>{tasks.length - doneCount} left</p>. Notice it required zero synchronization work, it's derived, so it's always right.

Recap

  • Hand-synced UIs store every fact twice: once in JS, once in the DOM. Missed writes cause drift.
  • The number of manual sync points grows faster than the feature count; drift is inevitable, not a discipline problem.
  • React's answer: state is the single source of truth, and the UI is derived from it, the DOM becomes a printout, never edited by hand.
  • The core loop: state changes → describe UI → React syncs the DOM with minimal changes.
  • Everything else in React, components, hooks, the fiber tree, the scheduler, exists to make that loop fast and correct.

Next

UI Is a Function of State →