Jovi De Croock

Software Engineer

Preact Signals, why it matters

State management is a highly debated topic in front-end development, we as developers often face trade-offs between performance and simplicity. React hooks like useMemo and useCallback are widely used as ways of handling computed values and referrential integrity of those values.

Preact Signals offer a different approach, let's explores how Preact Signals' pull-based model contrasts with the eager nature of React hooks.

The Problem with Hooks: Push-Based State Management

React hooks such as useMemo are designed to optimize performance by memoizing expensive computations. However, they come with certain inefficiencies:

  • Eager Evaluation: Hooks like useMemo calculate their values during the render cycle. Even if the computed value is only used later (e.g., inside a callback), it is still calculated upfront before the JSX is returned.
  • Dependency Management: Developers must manually specify dependencies in hooks like useMemo or useCallback. Incorrect dependency arrays can lead to stale or unnecessary computations.

For example, consider the following React code:

import React, { useMemo } from 'react';

const Component = ({ input }) => {
  const expensiveValue = useMemo(() => {
    // Expensive computation
    return input * 2;
  }, [input]);

  const onClick = () => {
    console.log(expensiveValue)
  }

  return <button onClick={onClick}>Send value</button>;
};

Here, the computation is performed on every render where input changes, even if the result is only used when the button gets clicked.

The Pull-Based Model of Preact Signals

Preact Signals take a different approach by shifting to a pull-based model, rather than eagerly computing values during rendering, Signals calculate derived values only when they are accessed. This lazy evaluation ensures that computations happen just in time, reducing unnecessary overhead.

Here’s how Preact Signals work:

Signals as State Primitives, a signal represents a piece of state that can be read or updated. For example:

import { signal } from "@preact/signals";

const count = signal(0);
count.value++; // Update the value
console.log(count.value); // Access the value

Signals can be declared anywhere, you can add them to the global scope and once you use them in a component/computed/... it will be subscribed to updates. This means that we also reduce GC pressure as the lifetime of the value won't be bound to the component lifecycle. A signal won't be active until it's used which makes it primed for usage as global state.

On top of that, in Preact for example we make the updates a bit more granular where we do direct upates to HTML text and element-attributes so we can avoid subscribing the component as a whole as much as possible. subscribing the component completely would result in rerenders which are more expensive compared to just updating to what the result of said rerender would be.

Computed Signals, derived state can be created using computed(). These computed signals automatically recalculate their values when signals accessed inside the closure are updated:

import { computed, signal } from "@preact/signals";

const count = signal(0);
const double = computed(() => count.value * 2);

console.log(double.value); // Computed
console.log(double.value); // Just returns value without computing
count.value = 2;           // Value is updated
console.log(double.value); // Computed lazily

You can play with it in this stackblitz, just run node index.mjs and look at the console output.

Comparison: Hooks vs. Signals

FeatureHooks (useMemo)Signals (computed)
Evaluation TimingEagerLazy
Dependency ManagementManualAutomatic
Re-RendersRequires optimization (React.memo)Granular updates without re-renders

Conclusion

I won't claim any technology to be a silver bullet but, it's worth giving signals a spin! You can use this in both React as well as Preact or use signals-core to make your own implementation.