Chapter 2

Component Model: The Real Difference Between Function and Class Components

React 16.8 introduced Hooks, and function components gained the ability to manage state and side effects. But the common description โ€” "function components can now do everything class components can" โ€” is technically misleading. It papers over a fundamental semantic difference between the two models. That difference is not about API surface. It is about how values exist in time.

What Rendering Actually Means

The right place to start is understanding how React "renders" a component in each model.

For a class component, React:

  1. Instantiates the class (new MyComponent(props)), creating an object instance
  2. Calls render() on that instance to get the React element tree
  3. Stores the instance on the Fiber node and reuses it on future updates, mutating only props and state on the existing object

For a function component, React:

  1. Calls the function directly (MyComponent(props)), which returns the element tree
  2. On every update, calls the function again
  3. Maintains no instance; each call is an independent function invocation

This means: each render of a function component is an isolated function call with its own independent scope and closure. There is no shared object bridging the calls.

Closure Semantics: Function Components Capture Values at Render Time

A concrete example makes this tangible. Suppose you have a counter that displays a message three seconds after a button click, showing the count at the time of the click:

Function component:

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

  function handleClick() {
    setTimeout(() => {
      alert(`You clicked ${count} times`);
    }, 3000);
  }

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={handleClick}>Show after 3s</button>
      <p>Count: {count}</p>
    </div>
  );
}

Class component:

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    setTimeout(() => {
      alert(`You clicked ${this.state.count} times`);
    }, 3000);
  };

  render() {
    return (
      <div>
        <button onClick={() => this.setState(s => ({ count: s.count + 1 }))}>+</button>
        <button onClick={this.handleClick}>Show after 3s</button>
        <p>Count: {this.state.count}</p>
      </div>
    );
  }
}

Try this sequence: click "+" several times, then click "Show after 3s", then keep clicking "+" before the three seconds elapse.

Neither behavior is a bug. Both are deliberate consequences of the underlying model.

In the function component, handleClick is a closure created during a specific render. It captures the count value from that render โ€” a concrete number, not a reference to a mutable container. In the class component, this.state.count reads a property off this, which is a mutable object persisting across all renders. this always refers to the same instance, so you always read the current value.

Instance Semantics: Class Components Carry a Mutable this

The identity of a class component is this โ€” a single mutable object that exists for the entire lifetime of the component. this.state, this.props, this.setState are all accessed through this object.

This creates a fundamental tension: this is shared and mutable, while time flows forward. When you read this.props or this.state inside an asynchronous callback (setTimeout, Promise resolution, event handler), you are not guaranteed to get the values that were in scope when the callback was scheduled. You get whatever is current at execution time.

This is sometimes the behavior you want โ€” always getting the latest value. But when you want to capture a snapshot of values at a specific moment, you must do it manually:

// Manual snapshot in a class component
handleClick = () => {
  const count = this.state.count; // capture now
  setTimeout(() => {
    alert(`You clicked ${count} times`);
  }, 3000);
};

Function components make snapshot capture the default behavior. You do nothing special โ€” the data from each render is automatically an immutable snapshot for that render's scope.

Stale Closures: The Gotcha in Function Components

While closure semantics are generally the right default for function components, they introduce the "stale closure" problem when working with Hooks if you do not fully internalize the model.

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // Bug: count is always 0 here
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps: effect runs only once

  return <div>Count: {count}</div>;
}

The problem: the useEffect callback is created during the first render, capturing count = 0. Because the dependency array is empty, the effect never re-runs. The setInterval callback uses the stale count = 0 on every tick, so setCount(0 + 1) executes every second, holding count at 1 forever instead of incrementing.

Two correct solutions:

Option 1: Functional update form (preferred)

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // updater function receives current value
  }, 1000);
  return () => clearInterval(id);
}, []);

Option 2: Add count to the dependency array

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // effect re-runs when count changes, rebuilding the interval

Option 1 is usually better: the updater function c => c + 1 receives the latest state value as its argument from React's scheduler, bypassing the closure entirely.

useRef as the Escape Hatch

When you genuinely need to read the latest value inside a long-lived callback without rebuilding the closure, useRef is the correct tool:

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // Keep the ref in sync with state
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      // countRef.current is always the latest count
      console.log('Latest count:', countRef.current);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>Count: {count}</div>;
}

The object returned by useRef is the same object across the entire lifetime of the component โ€” analogous to this in a class component. Reading .current always gives you the latest value stored there, regardless of when the closure was created.

This is the deliberate design: useRef gives you a controlled escape hatch back to mutable-instance semantics when you need them, without surrendering the clean snapshot semantics of the rest of the function component model.

Component Identity and Keys

React determines whether to update an existing component or unmount and remount it based on the component's position in the tree. When the same component type renders at the same position across two renders, React reuses the existing Fiber node and passes in new props.

The key prop is the explicit control mechanism for this behavior. Identical key values signal "this is the same component." Different key values signal "this is a new component โ€” destroy the old one."

Consider a user profile that loads data based on a selected user ID:

function UserProfile({ userId }) {
  const [name, setName] = useState('');

  useEffect(() => {
    fetchUser(userId).then(user => setName(user.name));
  }, [userId]);

  return <input value={name} onChange={e => setName(e.target.value)} />;
}

If selectedId changes from 1 to 2, the component re-renders and the useEffect re-fires due to userId changing. The fetch runs and updates name. But if the user has edited the input field during loading, there is a transient state inconsistency โ€” the old name lingers while the fetch is in flight.

Force a full reset with key:

<UserProfile key={selectedId} userId={selectedId} />

When selectedId changes, key changes. React unmounts the old component instance and mounts a fresh one. All internal state โ€” including name โ€” resets to its initial value. No manual useEffect cleanup is required, no state reset logic needs to be written. The component simply starts fresh.

This pattern is one of the most underused tools in React. It is clean, declarative, and eliminates an entire class of "stale state after prop change" bugs.

Why Function Components Won

Function components did not win because they look cleaner or require fewer lines of code. They won because their semantics are more consistent with React's core data model.

React's foundational proposition is: UI = f(state). The UI is a pure function of state. Class components introduced this โ€” a mutable container that persists across time โ€” which compromised this purity. Given the same state value, calling render() at different moments could yield different results if other properties of this had changed in the interim.

Function components enforce the equation. Each render is a fresh call with current props and state, and the result is entirely determined by those inputs. This makes components easier to reason about, easier to test, and easier for compilers to analyze and optimize.

There is also a practical argument: class components encouraged a style of programming where logic was organized around lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) rather than around concerns. A single concern โ€” say, setting up a subscription and cleaning it up โ€” was split across three lifecycle methods. Hooks allow co-location of related logic:

// Logic for a subscription, co-located in one useEffect
useEffect(() => {
  const sub = subscribe(userId, handleMessage);
  return () => sub.unsubscribe(); // cleanup lives next to setup
}, [userId]);

React 19's Improvements to the Component Model

React 19 promotes Server Components to a stable, production-ready feature. This represents a meaningful extension of the component model. Server Components are function components that run exclusively on the server โ€” they have no state, no lifecycle hooks, and no interactivity, but they can directly access databases, filesystems, and other server-only resources. Their output is serialized as a JSON stream delivered to the client.

// A Server Component (async function is valid here)
async function UserList() {
  const users = await db.query('SELECT * FROM users');

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Server Components cannot be class components โ€” they are async functions, a direct extension of the "function component = pure function" philosophy into server-rendered territory.

React 19 also ships the first production release of the React Compiler (formerly React Forget). The compiler performs static analysis on function components to identify values that are stable across renders and automatically inserts useMemo and useCallback equivalents. Class components cannot benefit from this analysis: the presence of this as a mutable object makes static reasoning about what changes between renders extremely difficult.

Function components are not a trend. They are the architectural direction that all of React's future capabilities โ€” Server Components, the Compiler, concurrent features โ€” are built upon.

Rate this chapter
4.6  / 5  (95 ratings)

๐Ÿ’ฌ Comments