Skip to content

Why React & the Model

Consider a counter. The imperative version tells the browser how to update, step by step:

// Imperative: you manage the DOM and keep it in sync by hand.
let count = 0;
const label = document.querySelector("#count");
document.querySelector("#inc").addEventListener("click", () => {
count += 1;
label.textContent = count; // you must remember to do this, everywhere
});

The bug surface is the “remember to update” part: every place that changes count must also update every piece of UI that depends on it. Miss one and the screen lies.

The declarative version describes what the UI is for a given state, and never touches the DOM:

// Declarative: describe the UI for the current state; React syncs the DOM.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

There is no “update the label” step. You change count, and because the UI is described in terms of count, React re-renders and the screen is correct by construction. The class of “I forgot to update X” bugs disappears.

A React component is a function that takes props (inputs from its parent) and uses state (its own memory), and returns a description of UI.

// props flow in; state lives inside; the return describes the UI.
function Greeting({ name }) { // name is a prop
const [count, setCount] = useState(0); // count is state
return (
<div>
<p>Hello, {name}</p>
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
</div>
);
}
flowchart LR
  props["props (from parent)"] --> comp["Component function"]
  state["state (own memory)"] --> comp
  comp --> ui["returns: UI description
(elements)"]
  ui --> react["React renders it"]
A component maps inputs to a UI description

Two rules fall out of “component is a function”:

  • Props are read-only. A component must never modify its own props — they belong to the parent. Data flows down.
  • Same inputs, same output. Given the same props and state, a component should render the same thing. That’s the purity requirement (a whole lesson later), and it’s what lets React re-run components freely.

Data only ever flows in one direction: down, from parent to child, as props. There is no equivalent of two-way binding where a child silently pushes a value back into its parent’s variable. A child that wants to change something the parent owns must ask — by calling a function the parent passed down as a prop.

flowchart TB
  parent["Parent: owns the state"] -->|"props (data down)"| child["Child: reads props, cannot write them"]
  child -->|"calls onX(...) (event up)"| handler["Parent's handler runs"]
  handler -->|setState| parent
Data flows down as props; changes flow up as event calls
// Parent owns `count`; Child only ever RECEIVES it and a callback.
function Parent() {
const [count, setCount] = useState(0);
return <Child count={count} onIncrement={() => setCount((c) => c + 1)} />;
}
// Child never touches `count` directly — it can only call the function
// the parent handed it. The parent decides what "increment" means.
function Child({ count, onIncrement }) {
return <button onClick={onIncrement}>{count}</button>;
}

This is why passing a callback prop down (onIncrement) is not a violation of one-way flow — the data (count) still only ever travels down; the child is simply invoking a function the parent authored, the same way clicking a <button> invokes a handler you wrote. The state itself never moves up.

The equation isn’t just a slogan — it means the exact same component function, called with different state, deterministically produces different UI, with zero manual DOM code written anywhere:

src/components/Counter.tsx
function Counter({ count }: { count: number }) {
return <p>{count}</p>;
}
export default Counter;
src/__tests__/ui-is-f-of-state.test.tsx
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import Counter from '../components/Counter';
afterEach(cleanup);
test('the same function, called with different state, produces different UI automatically', () => {
const { rerender, container } = render(<Counter count={0} />);
expect(container.textContent).toBe('0');
// No DOM write anywhere in this test or in Counter — just a new input.
rerender(<Counter count={5} />);
expect(container.textContent).toBe('5');
});
✓ the same function, called with different state, produces different UI automatically
Test Files 1 passed (1)
Tests 1 passed (1)

Everything else in React is downstream of UI = f(state):

  • Hooks exist to give function components state and lifecycle without breaking the “just a function” model.
  • Reconciliation exists to turn “here’s the new UI description” into “here’s the minimal DOM change.” (The full render/commit split and the diffing rules are the next lesson, Rendering & Reconciliation.)
  • Concurrent features (transitions, Suspense) exist because a pure f(state) can be computed, paused, and restarted safely.

You are not learning a pile of APIs; you are learning one equation and its consequences.

ChoiceBenefitCost
Declarative (UI = f(state))Whole classes of “forgot to update X” bugs are impossible by constructionYou give up direct, fine-grained control over individual DOM mutations
Imperative DOM codeFull control, no framework to learnEvery dependent update site must be found and kept in sync by hand — scales badly
One-way data flowState has exactly one owner; easy to reason about who changed whatA deeply nested child that needs to affect a distant ancestor needs a callback prop threaded through every level (or Context, a later lesson)
  • “Declarative means React re-renders the whole DOM every time.” It re-runs your component function, which is pure computation — the DOM is only touched for what actually changed, in the commit phase (next lesson).
  • “One-way data flow means a child can never affect its parent.” It can, but only by calling a function the parent handed it as a prop — the data still only flows down; the child never writes into the parent’s state directly.
  • “Passing a callback prop down breaks one-way flow, since the parent is ‘re-notified’.” It doesn’t — the child is invoking a function, not mutating a variable it was handed. No different from a <button> invoking your onClick.
  • “Declarative code is slower because it describes things instead of doing them directly.” The description (render phase) is cheap; React still computes and applies only the minimal real DOM change, same as hand-written imperative code would.

When a generated component “syncs” the UI, check for these before trusting it:

  • Does the component ever write to the DOM directly (.textContent =, .style.x =, document.title =) instead of returning that value from render? Direct writes get silently overwritten the next time React re-renders that subtree for any reason.
  • Does a child component try to change a value it received as a prop, instead of calling a callback prop the parent provided?
  • Is there any implicit two-way binding — a child mutating an object/array it was passed by reference, expecting the parent to “just see” the change? Data must flow back up through an explicit function call, never through mutation.
  • Does the same piece of state get computed in two places (once in the parent, redundantly re-derived in a child) instead of living in one owner and flowing down as a prop?
  • If the code claims “the UI updates automatically” — is that actually driven by a state change, or is it relying on a manual DOM write that will only work until the next unrelated re-render?

🐛 Spot the bug

function Counter() {
const [count, setCount] = useState(0);
const labelRef = useRef(null);
function handleFakeUpdate() {
// Bypasses React entirely — writes straight to the DOM.
labelRef.current.textContent = '999';
}
return (
<div>
<span ref={labelRef}>{count}</span>
<button onClick={handleFakeUpdate}>fake update</button>
<button onClick={() => setCount((c) => c + 1)}>real increment</button>
</div>
);
}
Show the answer

The “fake update” button writes '999' straight into the DOM, bypassing state — and it looks like it worked. But the moment any real state change causes React to re-render this component (clicking “real increment”), React re-describes the <span> from count (which was never touched, still 0) and patches the DOM back to match its own model. Proven in the probe: after “fake update” then “real increment”, the span reads '1' — not '1000' and not '999' — the manual write is silently gone. The fix is to route the value through useState/setCount so React’s model and the DOM never disagree.

What is the main problem with imperative DOM updates that React solves?
What are the inputs to a React component?
What is true about props?
A child component wants to change a value its parent owns. What can it correctly do?
Rendering this with count={0} then re-rendering with count={5}, with no other code, what does the container show?
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}
A button calls this instead of setCount. After clicking it, then clicking a DIFFERENT button that calls setCount(c => c + 1), what does the label show?
function handleFakeUpdate() {
  labelRef.current.textContent = '999';
}