Back to Blog
Frontend17 min readJun 2026

React Fundamentals: Components, Hooks, and Rendering

The mental model that makes React click: UI as a function of state, the render-reconcile-commit cycle, and when hooks help versus when they bite.

ReactHooksRenderingFrontend
SB

Sri Balaji

Founder

On this page

Start here: React is one idea wearing many hats

Who this is for

You have written some React, things mostly work, but you still get surprised: a component re-renders 'for no reason', an effect fires twice, a value is stale inside a callback, or `useMemo` everywhere made nothing faster. This article rebuilds your mental model from the one idea everything else hangs off: your UI is a function of state. Get that, and hooks, re-renders, and the dependency array all stop being magic.

Most React confusion comes from thinking imperatively: 'when the user clicks, go find that DOM node and change it.' React does not work that way. You never tell React *how* to update the screen. You describe *what* the screen should look like for the current state, and React figures out the rest. Internalise that inversion and 80 percent of the surprises evaporate.

We will use function components only, no classes, no lifecycle methods. That is the modern baseline, and hooks are the entire toolkit for adding state, effects, and memoization to a plain function.

The one sentence: UI = f(state)

A React component is a function that takes props and state and returns a description of UI. Same inputs, same output. Render the whole thing every time; let React diff.
The mental model in one line
A spreadsheet cell holds a formula, not a frozen numberA component is a formula: it computes UI from current props + state
You change one input cellYou call a state setter
The sheet recalculates every dependent cell automaticallyReact re-runs the component (and its children) to get new UI
You never hand-edit the result cellsYou never touch the DOM directly, React commits the changes
React is a spreadsheet, not a to-do list of DOM edits.

When you change a cell in a spreadsheet you do not walk around erasing old totals by hand, you change the input and trust recalculation. React asks for the same trust. You change state, your component function runs again and returns fresh UI, and React reconciles the difference onto the real DOM. Your job ends at 'return the right thing for this state.'

The picture: render, reconcile, commit

A state update kicks off three distinct phases. They get blurred together in casual talk as 're-rendering', but separating them is what lets you reason about cost. 'Render' is cheap-ish and happens in memory; 'commit' is what actually touches the browser.

schedulevirtual treediffafter
Event / setState

user clicks, data arrives

Render

run component fn -> new tree

Reconcile

diff new vs old tree

Commit

apply minimal DOM mutations

Browser paint

pixels on screen

Run effects

useEffect after paint

One state update flows left to right. Render and reconcile happen in memory; only commit touches the DOM.

  1. 1

    Trigger

    Something calls a state setter (a click, a timer, a fetch resolving). React marks the component as needing to re-render and schedules work.

  2. 2

    Render

    React calls your component function. It returns a new tree of React elements, plain objects describing UI. Nothing has touched the screen yet.

  3. 3

    Reconcile

    React diffs the new element tree against the previous one to find the smallest set of real changes. This is where keys matter (more later).

  4. 4

    Commit

    React applies only those changes to the real DOM. If a text node changed, it sets textContent; it does not rebuild the subtree.

  5. 5

    Effects

    After the browser paints, React runs your `useEffect` callbacks. This is why effects are for 'after the screen is updated' work, never for computing what to show.

Re-render does not mean re-DOM

A component re-rendering (its function running again) is cheap. What costs is the commit, actual DOM mutations, and expensive work *inside* render. Most 'too many re-renders' worries are misplaced; profile before you optimize. See [how the browser renders a page](/blog/how-the-browser-renders-a-page) for what happens after commit.

useState: the simplest hook, fully understood

useState gives a component a piece of memory that survives across renders. It returns the current value and a setter. Calling the setter schedules a re-render with the new value, it does not mutate a variable in place.

tsx
function Counter() {
  const [count, setCount] = useState(0);

  // Each render, `count` is a fresh const holding this render's value.
  // setCount schedules a re-render; it does NOT change `count` now.
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Clicked {count} times
    </button>
  );
}

Notice setCount((c) => c + 1) instead of setCount(count + 1). The functional form reads the latest value React holds, which matters when you update more than once in a tick or inside an async callback where count may be stale. Reach for it whenever the next state depends on the previous state.

State updates are not instant

After `setCount(5)`, reading `count` on the very next line still gives the old value. The new value only exists in the *next* render. Treat each render as a snapshot frozen in time, the variables in scope belong to that snapshot.

useEffect: the right tool, and the wrong one

useEffect exists to synchronise your component with something *outside* React: a subscription, a timer, a browser API, an event listener. It runs after commit, and it can return a cleanup function that React calls before the next run and on unmount. That cleanup is not optional politeness, it is how you avoid leaks and duplicate subscriptions.

tsx
// CORRECT: synchronising with an external system (window resize).
function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);

  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    // cleanup runs before re-run and on unmount -> no leaked listeners
    return () => window.removeEventListener('resize', onResize);
  }, []); // [] = set up once, tear down on unmount

  return width;
}

That is a textbook effect: there is a real external thing (the window) you are subscribing to and must clean up. Now the trap. The single most common React mistake is using an effect to keep one piece of state 'in sync' with another. That is not synchronising with the outside world, it is *derived data*, and derived data should be computed during render, not mirrored into state.

tsx
// WRONG: an effect that mirrors derived state.
function Cart({ items }: { items: Item[] }) {
  const [total, setTotal] = useState(0);

  // Extra render, can flash a stale 0, easy to desync. Don't do this.
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>Total: {total}</p>;
}
tsx
// RIGHT: derive during render. No effect, no extra state, no desync.
function Cart({ items }: { items: Item[] }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Total: {total}</p>;
}

The rule: derive, don't sync

If a value can be computed from props or other state, compute it during render. Only reach for an effect when you must reach *outside* React, DOM nodes, network, timers, subscriptions, browser storage. Most fetching is better handled by a data layer; see [data fetching and server state](/blog/data-fetching-and-server-state).

useMemo, useCallback, or nothing

These two hooks cache something across renders: useMemo caches a computed *value*, useCallback caches a *function* identity. They are optimizations, not correctness tools, and wrapping everything in them makes code slower to read and sometimes slower to run (the cache has its own cost). Default to nothing; reach for them with a reason.

ToolCachesUse it when
nothing-The default. Recomputation is cheap and the child re-rendering is harmless. 90 percent of code.
useMemoA computed valueThe computation is genuinely expensive (large list transform), OR the value is a prop passed to a memoized child and must keep stable identity.
useCallbackA function referenceThe function is passed to a `React.memo` child or used as an effect dependency, and a new identity each render would break memoization or re-fire the effect.
When each is actually worth it.

The thread tying both to a real benefit is *referential stability* feeding a memoized boundary. If nothing downstream cares whether the reference changed, memoizing it buys you nothing but noise. Measure with the React Profiler before sprinkling these in.

The rules of hooks (and stable keys)

Two rules you cannot break

1) Only call hooks at the top level, never inside conditions, loops, or nested functions. React tracks hooks by call *order*, so a conditional hook shifts every later hook's slot and corrupts state. 2) Only call hooks from React function components or custom hooks (names starting with `use`). The ESLint plugin react-hooks enforces both, keep it on and trust it.

Keys are the other place 'call order' style identity matters. When you render a list, React uses the key to match each element across renders during reconcile. A stable, unique key (a database id) lets React move and reuse rows. An unstable key, the array index, tells React 'this is a different item' whenever the order changes, throwing away DOM state like focus and input values.

tsx
// BAD: index keys break when the list reorders or items are removed.
{todos.map((todo, i) => <Row key={i} todo={todo} />)}

// GOOD: a stable id ties the element to its data across renders.
{todos.map((todo) => <Row key={todo.id} todo={todo} />)}

Dependency arrays and stale closures

An effect or `useCallback` closes over the variables visible at the render it was created in. If you omit a dependency, the callback keeps pointing at *old* values, a stale closure: your interval logs the count from render 0 forever. The fix is almost never to lie to the linter with an empty array; it is to list every value you use, or use the functional updater `setX(prev => ...)` so you do not need the dependency at all.

Sharing state: lift, drill, context, or a library

When two components need the same state, you have a ladder of options. Climb it only as far as you must, each rung adds coupling or indirection.

  1. Lift state up. Move the state to the nearest common parent and pass it down as props. Simplest, most explicit, and correct for the vast majority of cases.
  2. Prop drilling is lifting taken too far, threading a prop through five layers that do not use it. Not wrong, just noisy. A little drilling is fine; a lot is a smell.
  3. Context when truly app-wide, rarely-changing values (theme, current user, locale) are needed by many distant components. Context avoids drilling but every consumer re-renders when the value changes, do not put fast-changing state in it.
  4. A state library (Zustand, Redux Toolkit, Jotai) when you have complex, shared, frequently-updated client state with selectors and devtools needs. This is the top rung, reserve it for real complexity. See state management in frontend apps for choosing.

Server state is not client state

Data from your API is a separate category, it has caching, refetching, and staleness concerns that `useState` and context handle badly. Use a server-state library for it; details in [data fetching and server state](/blog/data-fetching-and-server-state).

Common mistakes that cost hours

  1. Using an effect to compute derived state. If it can be calculated from props/state, calculate it in render. No effect.
  2. Index keys on dynamic lists. Causes lost focus, wrong inputs, and ghost rows when the list changes. Use a stable id.
  3. Mutating state in place. arr.push(x); setArr(arr) does not trigger a render, same reference. Always create a new array/object: setArr([...arr, x]).
  4. Lying to the dependency array. An empty [] to silence the linter hides stale closures. List deps honestly or use functional updaters.
  5. Memoizing everything. useMemo/useCallback on cheap values adds cost and noise without a memoized boundary downstream.
  6. Calling hooks conditionally. if (x) useState(...) corrupts hook order. Hooks always at the top level.
  7. Putting fast-changing values in context. Every consumer re-renders on every change. Keep context for stable, app-wide data.

Takeaways

The whole article in eight lines

  • Your UI is a pure function of state: change state, React re-runs the function and returns new UI.
  • Render -> reconcile -> commit. Only commit touches the DOM; re-rendering in memory is cheap.
  • `useState` gives memory across renders; each render is a frozen snapshot, updates appear next render.
  • Effects synchronise with the outside world and clean up after themselves, never use them for derived state.
  • Derive, don't sync: compute values from props/state during render instead of mirroring them into state.
  • `useMemo`/`useCallback` only pay off when feeding a memoized boundary, default to neither.
  • Hooks at the top level only; stable unique keys on lists; honest dependency arrays to dodge stale closures.
  • Lift state first, drill a little, use context for app-wide stable values, reach for a library only at real complexity.

Where to go next

You now have the load-bearing model: UI from state, the render cycle, and hooks as tools with sharp edges. The next questions are about scale, how to manage state once an app grows, what the browser does with React's commits, and how to handle data that lives on a server.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.