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
oruseCallback
. 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
Feature | Hooks (useMemo) | Signals (computed) |
---|---|---|
Evaluation Timing | Eager | Lazy |
Dependency Management | Manual | Automatic |
Re-Renders | Requires 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.