Reactivity Without the Re-Render Tax
Originally posted on Medium.
Read the originalCustomer clicks a variant. React re-renders the whole product card. The image. The reviews. The breadcrumbs. All of it—just to update a price. Your cart drawer re-renders 47 components to change one number from 9 to 10.
That's the re-render tax. And every framework has spent the last three years trying to stop paying it.
The Problem
Traditional state management works top-down. You update state at the top, the framework walks the entire tree below it, diffing every component to figure out what actually changed.
setState({ price: 149.99 })
[App] ← re-renders
|
[ProductPage] ← re-renders
/ | \
[Image] [Price] [Reviews] ← ALL re-render
(only Price changed)
Most of those re-renders produce identical output. The framework diffs them, throws the diff away, and moves on. It's busywork.
With signals, the update skips the tree entirely:
price.value = 149.99
[App] ← untouched
|
[ProductPage] ← untouched
/ | \
[Image] [Price] [Reviews]
↑
only this updates
No tree walk. No diffing. The signal knows exactly which DOM node subscribed to it and updates that node directly.
Three Primitives, That's It
Every signals implementation boils down to three concepts:
Signal — a reactive container. Holds a value. Notifies subscribers when it changes.
const cartCount = signal(0)
const variantPrice = signal(29.99)
// Read
console.log(cartCount.value) // 0
// Write — subscribers update automatically
cartCount.value = 3
Computed — a derived value. Recalculates only when its dependencies change.
const quantity = signal(2)
const unitPrice = signal(49.99)
const lineTotal = computed(() => quantity.value * unitPrice.value)
// lineTotal.value → 99.98
quantity.value = 3
// lineTotal.value → 149.97 (recalculated automatically)
Effect — a side effect. Runs when tracked signals change.
const cartCount = signal(0)
effect(() => {
document.querySelector('.cart-badge').textContent = cartCount.value
})
// Later: update the signal, DOM updates automatically
cartCount.value = 5
That's the whole API. Everything else is sugar on top.
Shopify Example: Reactive Cart Price
Here's a variant price that updates without re-rendering the product card:
// Variant data from Liquid or the AJAX API
const variants = [
{ id: 1, title: 'Small', price: 2999 },
{ id: 2, title: 'Medium', price: 3999 },
{ id: 3, title: 'Large', price: 4999 },
]
const selectedId = signal(1)
const quantity = signal(1)
const selectedVariant = computed(() =>
variants.find((v) => v.id === selectedId.value),
)
const displayPrice = computed(() => {
const cents = selectedVariant.value.price * quantity.value
return '$' + (cents / 100).toFixed(2)
})
// Wire to DOM — only the price element updates
effect(() => {
document.querySelector('.price--current').textContent = displayPrice.value
})
// Variant selector changes
document.querySelector('.variant-select').addEventListener('change', (e) => {
selectedId.value = Number(e.target.value)
})
Select "Large" — the price text node updates. The product image, reviews, and cart button don't re-render. They were never involved.
One Library, Every Framework
@preact/signals-core is framework-agnostic. Same import, same API, same .value access—regardless of what renders your templates. Here's the same reactive cart price wired into five frameworks.
React
const selectedId = signal(1)
const quantity = signal(1)
const displayPrice = computed(() => {
const variant = variants.find((v) => v.id === selectedId.value)
return '$' + ((variant.price * quantity.value) / 100).toFixed(2)
})
function Price() {
return { displayPrice }
}
@preact/signals-react wraps the core so signals render directly in JSX. No hooks, no selectors. Only Price updates when the variant changes.
Vue 3
const quantity = signal(1)
const displayPrice = computed(() => {
const variant = variants.find(v => v.id === selectedId.value)
return '$' + ((variant.price * quantity.value) / 100).toFixed(2)
})
// Bridge: sync signal into Vue's reactivity
const price = ref(displayPrice.value)
let dispose
onMounted(() => {
dispose = effect(() => { price.value = displayPrice.value })
})
onUnmounted(() => dispose?.())
{{ price }}
Vue has its own signals (ref, computed). The bridge is a thin effect that pushes signal values into Vue's reactivity system. Useful when you want shared state logic across a Vue + React codebase.
Svelte 5
const quantity = signal(1)
const displayPrice = computed(() => {
const variant = variants.find(v => v.id === selectedId.value)
return '$' + ((variant.price * quantity.value) / 100).toFixed(2)
})
// Bridge: sync signal into Svelte's reactivity
let price = $state(displayPrice.value)
let dispose
onMount(() => {
dispose = effect(() => { price = displayPrice.value })
})
onDestroy(() => dispose?.())
{price}
Same pattern. The effect pushes signal changes into a $state variable that Svelte knows how to render. The reactive logic stays framework-agnostic.
Solid
const selectedId = signal(1)
const quantity = signal(1)
const displayPrice = computed(() => {
const variant = variants.find(v => v.id === selectedId.value)
return '$' + ((variant.price * quantity.value) / 100).toFixed(2)
})
function Price() {
// Bridge: sync signal into Solid's reactivity
const [price, setPrice] = createSignal(displayPrice.value)
onMount(() => {
const dispose = effect(() => setPrice(displayPrice.value))
onCleanup(dispose)
})
return {price()}
}
Solid already has fine-grained reactivity. The bridge is only useful if you’re sharing state logic with non-Solid code. Otherwise, use Solid’s native createSignal.
Angular
Component,
signal as ngSignal,
effect as ngEffect,
} from '@angular/core'
const selectedId = signal(1)
const quantity = signal(1)
const displayPrice = computed(() => {
const variant = variants.find((v) => v.id === selectedId.value)
return '$' + ((variant.price * quantity.value) / 100).toFixed(2)
})
@Component({
selector: 'app-price',
template: `{{ price() }}`,
})
export class PriceComponent {
price = ngSignal(displayPrice.value)
constructor() {
// Bridge: sync @preact/signals-core into Angular signals
effect(() => this.price.set(displayPrice.value))
}
}
Angular v16+ has its own signals. The bridge syncs @preact/signals-core into Angular's change detection. Same story: shared logic, framework-specific rendering.
The Pattern
Every framework integration follows the same shape:
@preact/signals-core (shared logic)
|
effect(() => {
|
framework.setState(signal.value) ← bridge
})
The reactive logic lives in @preact/signals-core. A thin effect bridges it into whatever reactivity system renders your templates. One source of truth, many renderers.
Quick Reference
React — built-in bridge, automatic cleanup. Package: @preact/signals-react
**Vue 3:**bridge via effect, cleanup via onUnmounted. Package: @preact/signals-core
**Svelte 5:**bridge via effect → $state, cleanup via onDestroy. Package: @preact/signals-core
**Solid:**bridge via effect → createSignal, cleanup via onCleanup. Package: @preact/signals-core
**Angular:**bridge via effect → signal().set, automatic cleanup (injection context). Package: @preact/signals-core
One library. One API. Five frameworks.
The Takeaway
@preact/signals-core is 1.6kB of framework-agnostic reactivity. Write your state logic once with signal, computed, and effect. Bridge it into whatever renders your UI with a single effect call.
The re-render tax is optional now. Stop paying it.
