Engineering

The Component Model

Lifecycle, reactivity, and composition — understood through backend eyes

Learning Objectives

By the end of this module you will be able to:

  • Describe the mount / update / unmount lifecycle of a component and explain why cleanup functions are not optional.
  • Implement the AbortController pattern for canceling in-flight requests on component unmount.
  • Explain the difference between virtual DOM diffing and signals-based reactivity, and articulate their respective performance tradeoffs.
  • Identify the main composition patterns — custom hooks, compound components, headless UI, slots/children — and select the right one for a given situation.
  • Map custom hooks onto the backend concept of a service layer, and React Context onto dependency injection.

Core Concepts

1. The component lifecycle: mount, update, unmount

Every component in a UI framework follows three broad phases.

Mount — the component is created and inserted into the DOM. This is where you set up subscriptions, start timers, fire initial data requests, and register event listeners.

Update — the component re-executes because its state or props changed. The framework reconciles the previous and new output and applies only the diff to the DOM.

Unmount — the component is removed from the DOM. This is where you must undo everything you set up during mount: cancel timers, remove event listeners, abort in-flight requests, unsubscribe from streams.

Cleanup is not optional

Without cleanup, event listeners, timers, and subscriptions persist after the component is gone, silently accumulating until they cause memory leaks or stale callbacks firing on state that no longer exists — one of the most common leak patterns in production frontends.

In React, cleanup lives inside useEffect's return value:

useEffect(() => {
  const subscription = eventBus.subscribe(handler);
  return () => subscription.unsubscribe(); // cleanup
}, []);

React's cleanup runs in a predictable sequence: before re-running the effect when dependencies change (with the old props/state), and one final time on unmount. This means cleanup always runs with the context from the previous execution — exactly what you want when tearing down the old state before setting up the new one.

The dependency array controls when the effect re-runs:

  • [] — run once after initial mount, cleanup once on unmount.
  • [a, b] — run after mount and any time a or b changes.
  • No array — run after every render.

Vue 3 expresses the same contract through onMounted / onUnmounted. Both must be called synchronously during component setup, because Vue associates them with the currently active component instance at call time — calling them inside an async function or setTimeout breaks that association.

Svelte's onMount can return a cleanup function directly, letting you co-locate setup and teardown in one place.


2. Effect cleanup as resource disposal

Backend engineers already know this pattern. When a goroutine receives a context cancellation, you drain the channel and exit. When a connection is taken from a pool, you return it on close. The frontend equivalent is the cleanup function.

The most concrete example is canceling in-flight HTTP requests. Without cleanup, navigating away from a page can leave a pending fetch that eventually resolves and tries to update state on a component that no longer exists — triggering React's "Can't perform a state update on an unmounted component" warning.

The modern solution is AbortController:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort(); // cleanup cancels the request
}, [userId]);

The signal is passed into fetch. When abort() is called during cleanup, the browser cancels the in-flight request and the fetch promise rejects with an AbortError, which you explicitly ignore.

One subtle trap: event listeners require the exact same function reference for removal as was passed to addEventListener. Passing a different arrow function — even one with identical logic — fails silently, leaving the listener attached. Define handler functions inside the effect scope so they are always in scope for removal.


3. Virtual DOM diffing vs. signals — two reactivity architectures

These are the two dominant approaches to "what should update when state changes."

Fig 1
VIRTUAL DOM (React) state change ↓ component re-executes ↓ diff new vDOM vs old ↓ patch real DOM O(n) per subtree SIGNALS (Solid / Vue 3) signal value changes ↓ notify dependents only ↓ update bound DOM node   surgical, no diffing
Virtual DOM triggers component re-execution; signals bind directly to DOM nodes.

Virtual DOM (React). The component is the unit of reactivity. A state change causes the entire component function to re-execute, producing a new virtual DOM tree. React then diffs the new tree against the previous one and applies only the changed nodes to the real DOM. This achieves O(n) complexity through two heuristics: different element types produce different trees, and stable key props allow the reconciler to match elements across renders.

The tradeoff: all that re-execution is work. React therefore provides opt-in escapes — useMemo, useCallback, React.memo — that you have to know when to reach for. Optimization is not automatic; it is explicit.

Signals (Solid, Vue 3, Angular signals, Svelte 5). Individual signal values are the unit of reactivity. The framework tracks which computations read each signal at runtime, and when a signal changes, it notifies only those dependents — eliminating diffing entirely. Solid, for instance, binds signals directly to DOM nodes: updates bypass component re-execution altogether.

The performance difference is stark: fine-grained signal updates can reduce DOM mutations by up to 99.9%, heap usage by 70%, and update latency by 94% compared to virtual DOM approaches. Performance is the default; you opt out, not in.

By 2024–2026, signals became the dominant reactivity paradigm across major frameworks — Angular moved from RxJS to signals, Vue uses Composition API reactivity, Svelte 5 introduced runes — representing one of the most significant architectural shifts in frontend development.

When does this matter to you? If you are reading React code, understand that a state change anywhere in a component rerenders the whole component (unless you memoize). If you are reading Vue 3 or Solid code, understand that ref() and signal() are observable cells; only code that reads them re-runs.


4. Composition patterns — the frontend service layer

Custom hooks: your service layer

Custom hooks are the direct frontend equivalent of backend service layers. They extract and encapsulate stateful logic — combinations of useState, useEffect, and other hooks — into a named function that any component can call.

// useFetchUser.js — a "service" for user data
function useFetchUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
      .finally(() => setLoading(false));
    return () => controller.abort();
  }, [userId]);

  return { user, loading };
}

The component that calls useFetchUser knows nothing about the fetch mechanics. Just like a service layer, the hook encapsulates the concern and exposes a typed interface. Vue composables are the exact same pattern, using Vue's Composition API.

Custom hooks also solve the dependency visibility problem that plagued higher-order components: because hooks are called explicitly at the top level of a component, their dependencies are visible at the call site — no hidden wrapping or prop injection.

React Context: dependency injection

React.createContext with Context.Provider is frontend dependency injection. Any descendant component can call useContext() to receive the injected value without prop drilling through every intermediate layer.

const AuthContext = createContext(null);

function App() {
  return (
    <AuthContext.Provider value={currentUser}>
      <Dashboard /> {/* doesn't receive currentUser as a prop */}
    </AuthContext.Provider>
  );
}

function UserMenu() {
  const user = useContext(AuthContext); // injected from ancestor
}

The Provider is your DI container. The context value is the injected dependency. Components inside the boundary are resolved consumers.

Compound components: explicit contracts between siblings

The compound component pattern composes smaller components that work together as a cohesive unit, sharing implicit state through context. The canonical example from HTML is <select> and <option> — neither works alone, both are simple, together they form a complete interaction.

<Accordion>
  <AccordionItem>
    <AccordionTrigger>Section 1</AccordionTrigger>
    <AccordionContent>Content here.</AccordionContent>
  </AccordionItem>
</Accordion>

The root component holds state and exposes it via context. The leaf components consume it. The developer arranges them freely, gets built-in accessibility behavior, and applies their own styles. Libraries like Radix UI build their entire API surface this way.

Headless UI: logic without markup

The headless UI pattern decouples behavior, state management, and accessibility from any visual rendering. You get the logic; you own the markup.

This is the pattern to reach for when a design system or branded component library needs to sit on top of well-tested interaction behavior without being locked into someone else's HTML structure or CSS.

Slots / children: layout injection

In React, the children prop lets a component define a layout shell while delegating its interior to the caller. In Vue and Svelte, this is formalized as named slots, enabling multiple injection points per component.

// Layout component as middleware wrapper
function PageShell({ children }) {
  return (
    <div className="page">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

This is layout-as-middleware: the shell injects structural concerns (sidebar, header, scroll container), and the caller fills the content. It differs from HOCs, which inject behavior rather than structure.


Worked Example

Problem: A user profile page fetches user data based on a URL parameter. When the user navigates to a different profile, the previous request should be canceled, loading state should reset, and the new request should fire.

// useUserProfile.js
function useUserProfile(userId) {
  const [state, setState] = useState({ user: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ user: null, loading: true, error: null });

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(user => setState({ user, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError') return; // canceled — ignore
        setState({ user: null, loading: false, error: err.message });
      });

    return () => controller.abort(); // cancel on userId change or unmount
  }, [userId]);

  return state;
}

// UserProfile.jsx
function UserProfile({ userId }) {
  const { user, loading, error } = useUserProfile(userId);

  if (loading) return <Spinner />;
  if (error) return <ErrorBanner message={error} />;
  return <ProfileCard user={user} />;
}

Walk through what happens when userId changes from 42 to 99:

  1. React re-renders UserProfile with userId = 99.
  2. Before re-running the effect, React calls the cleanup from the previous run: controller.abort() cancels the in-flight request for user 42.
  3. The effect runs again with userId = 99. A new AbortController is created, state resets to loading, and a new fetch fires.
  4. The previous fetch's .catch receives an AbortError and returns early — no stale state update.

This mirrors a goroutine that respects context cancellation: the caller cancels, the worker detects it and exits cleanly, the new worker starts fresh.


Common Misconceptions

"The cleanup runs after unmount." Partially true. Cleanup also runs before the effect re-runs when dependencies change — not only on unmount. This is intentional: cleanup uses the old closure values, so it can correctly tear down what the previous setup configured.

"An empty dependency array means the effect never runs again." It runs once — after mount. It also runs its cleanup once — on unmount. "Never runs again" is correct for the setup code, but cleanup is still guaranteed.

"Signals are just a performance optimization over virtual DOM." They represent a different reactivity model, not just a faster version of the same thing. Virtual DOM diffing is O(n) per subtree; signals are bound directly to DOM nodes. The programming model is different: with signals you are defining reactive data cells; with virtual DOM you are writing a render function that returns a description of the UI.

"Custom hooks are components." Hooks are not components and produce no UI. They are functions that can call other hooks. The naming convention (use prefix) is a lint signal, not a framework primitive. You can call custom hooks conditionally — you just cannot call the primitive hooks (useState, useEffect, etc.) conditionally inside them.

"React Context is a state manager." Context is a delivery mechanism, not a state container. It avoids prop drilling by making a value available to an arbitrary descendant. The state still lives in a useState or useReducer call somewhere above the Provider. Context does not replace Zustand, Redux, or Pinia for complex shared state — it replaces prop drilling for values that rarely change (auth, theme, locale).


Active Exercise

The stale request challenge. Build a search-as-you-type input that fetches results from an API endpoint as the user types. Requirements:

  1. Debounce the input so you only fire a request after the user pauses for 300ms.
  2. Cancel the previous in-flight request whenever a new one starts.
  3. Ignore responses from requests that were issued before the current one (protect against out-of-order responses).
  4. Extract all of this logic into a useSearch(query) custom hook. The component should receive only { results, loading, error }.

Verify your implementation by adding a console.log('cleanup called') in the effect cleanup. You should see one log per keystroke once the debounce fires — never a stale state update.

Stretch: swap the fetch call inside useSearch for a mock that returns a random delay between 50ms and 500ms. Confirm that only the last-issued request's results ever appear in the UI.

Key Takeaways

  1. Component lifecycle has three phases — mount, update, unmount — and cleanup functions are the frontend equivalent of resource disposal. Skipping cleanup causes memory leaks.
  2. AbortController is the canonical pattern for canceling in-flight requests on unmount or dependency change. The cleanup function calls abort(); the fetch .catch ignores AbortError.
  3. Virtual DOM (React) re-executes components on state change and diffs the result. Signals (Solid, Vue 3, Svelte 5) bind updates directly to DOM nodes, eliminating diffing. Signals are performant by default; virtual DOM requires explicit memoization.
  4. Custom hooks are the frontend service layer. They encapsulate stateful logic and expose a typed interface that components consume without knowing the internals.
  5. React Context is dependency injection for component trees. It solves prop drilling; it is not a state manager.

Further Exploration

React Documentation

Alternative Frameworks

  • Solid.js Reactivity Guide — Fine-grained reactivity explained from first principles; illuminates why signals eliminate diffing.
  • Vue 3 Composables — Vue's equivalent of custom hooks, including the useEventListener utility pattern.

Web APIs & Libraries

  • Radix UI Primitives — Compound component + headless UI patterns in a production-grade library; read the Accordion source for a concrete example.
  • AbortController — MDN — Browser-native request cancellation, the building block of cleanup-safe fetching.