Web Development

Signals versus hooks: two reactivity models and what each costs you

Comparison of signal-based and hook-based reactivity models in JavaScript frameworks, covering performance trade-offs, memory costs, and integration challenges.

Mohammed Saqib8 min read
A developer working on a laptop, typing code, showcasing programming and technology skills.
Photo by olia danilevich on Pexels · Pexels License

React developers know the pain of a component re-rendering when only one piece of deeply nested state changed. The entire tree runs, hooks fire, and React.memo comparisons add their own cost—all to update a single checkbox. Signal-based frameworks like SolidJS or Preact Signals avoid that overhead by pushing updates only to the consumers that actually depend on the changed value. This article compares the two reactivity models, focusing on the performance, memory, and ergonomic trade-offs that matter when building at scale.

The core difference: pull-based vs push-based reactivity

The React hooks model is pull-based. When state changes, React schedules a re-render of the component that owns that state. During the render, the component function runs from top to bottom, evaluating all hooks (useState, useEffect, useMemo, etc.). The resulting virtual DOM is diffed against the previous one, and only the differences are applied to the real DOM. The component itself decides what to recompute by running its entire function—nothing is automatically skipped.

Signals operate on a push-based model. A signal is an observable value that maintains a list of subscribers (e.g., computed values, effects, or template expressions). When the signal’s value changes, it notifies those subscribers directly. Only the code that explicitly read the signal during its last execution gets re‑evaluated. There is no component re‑render unless a signal read is tracked inside a render function.

This shift changes how you think about dependencies. In hooks, dependencies are declared manually via arrays. In signals, dependencies are recorded automatically at runtime. The component tree becomes less relevant—updates flow through the signal graph, not through the component hierarchy.

Hooks in practice: re‑renders, stale closures, and useEffect

Every useState update causes the entire component function to re‑run. That means all local variables are re‑declared, all useEffect cleanup functions run, and all child components are re‑rendered unless wrapped in React.memo. The memo itself adds a shallow comparison cost.

Stale closures are a common consequence. A useEffect callback captures the values from the render in which it was created. If the dependency array is missing a value, the effect sees an old version. Conversely, including a value that changes on every render can cause an infinite loop.

function Counter({ initial }) {
  const [count, setCount] = useState(initial);
  useEffect(() => {
    const timer = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(timer);
  }, []); // missing 'initial' – closure captures old 'initial'
  return <div>{count}</div>;
}

The empty dependency array means the interval function captures the initial prop from the first render. If initial changes later, the interval still uses the old value. Adding initial to the array would restart the interval every time the prop changes—often not what you want. The standard fix is to use a ref or a reducer, adding more ceremony.

Re‑renders also cascade to children. Without React.memo, a parent re‑render forces every child to re‑render, even if their props haven’t changed. React.memo helps, but it introduces a per‑prop comparison that can itself become a bottleneck for large lists.

Signals in practice: fine‑grained updates and no closure capture

A signal holds a value and notifies only the code that explicitly subscribes—typically a computed (derived value) or an effect. In SolidJS, a component’s JSX template automatically subscribes to any signals read inside it. When a signal changes, only the DOM nodes that depend on that specific signal are updated. No parent re‑render, no child re‑render.

Dependency tracking is automatic. The framework records which signals were accessed during the execution of a computed or effect. No dependency array, no possibility of forgetting a dependency.

Closures always see the current value because subscriptions are re‑established on every read. The following SolidJS code works correctly without manual dependency management:

function Counter(props) {
  const [count, setCount] = createSignal(0);
  createEffect(() => {
    const timer = setInterval(() => setCount(c => c + 1), 1000);
    onCleanup(() => clearInterval(timer));
  }, [props.initial]); // Solid's createEffect actually doesn't take a dep array – this is a mistake. Let me correct.
 
  // Correct Solid:
  const [count, setCount] = createSignal(0);
  createEffect(() => {
    // no dep array – the effect re-runs when any signal read inside it changes
    const timer = setInterval(() => setCount(c => c + 1), 1000);
    onCleanup(() => clearInterval(timer));
  });
  return <div>{count()}</div>;
}

In Solid, createEffect does not take a dependency array. It automatically tracks any signals read during its execution and re‑runs when those signals change. The effect above reads setCount (a setter, not a signal) and count (inside the callback), but the interval itself captures the current closure correctly because the effect re‑runs when count() changes—which it does on every tick. This is a subtle point: the effect re‑runs every tick because count() changes, creating a new interval each time. That’s wasteful. The idiomatic approach uses createTimer or a manual subscription. The point stands: automatic tracking eliminates stale closures for derived values, but you still need to be careful with side‑effect scheduling.

Concrete comparison: SolidJS signals vs React hooks in a todo list

Consider a todo list where each item has a checkbox to toggle its completion status.

React implementation:

function TodoList({ todos }) {
  const [items, setItems] = useState(todos);
  const toggle = (id) => {
    setItems(prev => prev.map(item =>
      item.id === id ? { ...item, done: !item.done } : item
    ));
  };
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          <input type="checkbox" checked={item.done} onChange={() => toggle(item.id)} />
          {item.text}
        </li>
      ))}
    </ul>
  );
}

Every toggle calls setItems, which triggers a re‑render of TodoList. The entire list function runs, creating new element descriptors for all items. React diffs the virtual DOM and updates only the changed checkbox—but the component function itself ran for every item. If you add React.memo on a child TodoItem component, you avoid re‑executing the child function, but you still pay the cost of the parent re‑render and the memo comparison.

SolidJS implementation:

function TodoList(props) {
  const [items, setItems] = createSignal(props.todos.map(todo => ({
    ...todo,
    done: createSignal(todo.done)
  })));
  return (
    <ul>
      <For each={items()}>
        {(item) => {
          const [done, setDone] = item.done;
          return (
            <li>
              <input type="checkbox" checked={done()} onChange={() => setDone(!done())} />
              {item.text}
            </li>
          );
        }}
      </For>
    </ul>
  );
}

Here each todo’s done property is its own signal. Toggling a checkbox calls setDone, which updates only the DOM node for that specific checkbox. The parent TodoList does not re‑render. The For component only updates the changed item’s template. No reconciliation, no memo, no parent function execution.

The trade‑off: Solid’s model gives fine‑grained updates at the cost of more upfront structure. You must model your state as signals or stores, and you cannot casually mutate an array without using the correct reactive primitives. React’s model is simpler to reason about at component boundaries—just pass props and let React figure out the diff. For deeply nested state that changes frequently, Solid’s approach can be dramatically faster, but it demands discipline.

Failure modes: memory leaks, infinite loops, and debugging

Hooks: Missing dependencies in useEffect cause stale closure bugs. Stale closures lead to reading old props or state, often manifesting as UI that doesn’t update. useEffect cleanup mistakes—forgetting to unsubscribe or clear timers—cause memory leaks. The React dev tools and the strictMode double‑invoke help catch some of these, but they remain common.

Signals: Orphaned subscriptions occur when a computed or effect is not properly disposed on component unmount. Solid’s onCleanup handles this inside effects, but if you create a computed outside a component lifecycle (e.g., in a global store) and never clean it up, the subscription lives forever. Signal graphs with cycles can produce infinite recomputation loops—for example, a computed that reads itself indirectly. The framework may detect this and throw, but it’s a runtime error that can be tricky to debug.

Debugging signals is harder because the dependency graph is implicit; you need dedicated DevTools that visualize signal subscriptions. React’s hook tracing and component tree are more mature. For example, React DevTools shows exactly which hooks ran and why. Solid’s DevTools exist but are less polished.

When to adopt each model based on your team and framework

If you are already in the React ecosystem, hooks are the default. Adding signals via a library like Preact Signals or @preact/signals-react is possible, but it may fight against React’s batching and concurrent mode. Signals rely on synchronous notification, while React’s concurrent rendering can defer updates. Using signals inside React can lead to unexpected timing issues—components might render with stale signal values if the signal updates are batched differently.

If you are starting a new project, a signal‑first framework (SolidJS, Preact Signals with Preact, Angular Signals) gives you performance out of the box. You lose access to the React ecosystem—no React Router, no React Query, no Next.js. But you gain predictable performance without memoization. For teams building data‑heavy dashboards or real‑time UIs, the trade‑off can be worth it.

Consider team familiarity. Hooks are widely understood. Signals require a shift in mental model about when and why components update. Developers new to signals often over‑create signals or forget to use createMemo for derived state, leading to unnecessary recomputation. The learning curve is real, but the payoff is fine‑grained reactivity without manual optimization.

For a deeper look at related performance concerns, see Fixing INP in React Apps That Already Pass LCP and Streaming SSR with Suspense: What Reaches the Browser First.

External documentation:

Key takeaways

  • Hooks are pull‑based: state changes trigger a full component re‑render, then diff the virtual DOM. Signals are push‑based: changes notify only subscribers, skipping component re‑renders entirely.
  • Signals eliminate dependency arrays and stale closures, but require disciplined state modeling and can be harder to debug.
  • For existing React projects, mixing signals risks compatibility issues with concurrent mode; signal‑first frameworks like SolidJS offer better integration.
  • Memory leaks in hooks come from missing cleanup; in signals, from orphaned subscriptions if effects or computeds are not disposed.
  • Choose hooks if your team is React‑focused and your state changes are coarse. Choose signals if you need fine‑grained updates and can adopt a different ecosystem.

Frequently asked questions

Do signals replace hooks entirely?
No. Signals are a low-level reactivity primitive for tracking dependencies and recomputing values. Hooks provide a broader API for side effects, context, and lifecycle management. You can use signals inside hooks or wrap hooks to expose signals.
Are signals always faster than hooks?
Not universally. Signals avoid full re-renders by pushing updates only to dependent computations, which wins for large component trees. But they introduce subscription overhead and can be slower for trivial leaf components. Benchmarks depend on scenario and framework.
Can I use signals in React today?
Yes, via libraries like Preact Signals, Legend State, or Valtio. Integration requires wrapping signal reads in a hook or using a bridge component to trigger React re-renders. It works but adds an extra layer and can confuse React's concurrent features.
#reactivity#signals#hooks#frontend#state-management
Share

Keep reading