JavaScript Signals: Reactive State Without a Framework

The TC39 Signals proposal (Stage 1 as of 2025) brings a standardised reactive primitive to JavaScript — the same mental model behind Angular signals, Preact signals, Solid.js, and Vue’s reactivity system. A Signal holds a value; any computation that reads it is automatically re-run when the value changes. The @tc39/signal-polyfill package lets you experiment with the API today.

Problem: JavaScript state management in WordPress block editor extensions or admin SPAs relies on Redux-style stores from @wordpress/data — which requires boilerplate actions, reducers, and selectors even for simple reactive values shared between components.

Solution: Use JavaScript Signals (TC39 stage 1, or libraries like @preact/signals compatible with WordPress's Preact build) for fine-grained reactivity without a store. A signal is a reactive value: read it anywhere, mutate it, and all dependents re-render automatically. Computed signals memoize derived values without explicit selectors.


The examples below show basic signal creation, computed signals, effects (side-effects that re-run on change), and a practical WordPress admin pattern: a reactive cart counter that updates a DOM badge whenever the WooCommerce Store API cart changes.


// ── Using the TC39 signal polyfill ───────────────────────────────────────
import { Signal } from '@tc39/signal-polyfill';
import { effect  } from '@preact/signals-core'; // or a custom effect scheduler

// 1. Basic signal
const count = new Signal.State( 0 );
console.log( count.get() ); // 0
count.set( 5 );
console.log( count.get() ); // 5

// 2. Computed signal — derived, cached, lazy
const doubled = new Signal.Computed( () => count.get() * 2 );
console.log( doubled.get() ); // 10
count.set( 7 );
console.log( doubled.get() ); // 14  (recomputed only when read after change)

// 3. Multiple dependent signals
const firstName = new Signal.State( 'John' );
const lastName  = new Signal.State( 'Doe' );
const fullName  = new Signal.Computed( () => `${firstName.get()} ${lastName.get()}` );

firstName.set( 'Jane' );
console.log( fullName.get() ); // "Jane Doe"

// 4. Practical: reactive WooCommerce cart badge
// Assume this runs in a block editor / FSE context with Store API available
const cartCount = new Signal.State( 0 );

// Fetch initial cart
fetch( '/wp-json/wc/store/v1/cart', { credentials: 'same-origin' } )
    .then( r => r.json() )
    .then( data => cartCount.set( data.items_count ?? 0 ) );

// Subscribe to Store API cart updates (WooCommerce block cart store)
if ( window.wc?.wcStore ) {
    window.wc.wcStore.subscribe( () => {
        const state = window.wc.wcStore.getState();
        const count = state?.cart?.itemsCount ?? 0;
        cartCount.set( count );
    } );
}

// Effect: update the DOM badge whenever cartCount changes
// (using a minimal effect wrapper around the Watcher API)
function watchSignal( signal, callback ) {
    const watcher = new Signal.subtle.Watcher( () => {
        watcher.watch();        // re-arm
        callback( signal.get() );
    } );
    watcher.watch( signal );
    callback( signal.get() );   // run once immediately
}

const badge = document.querySelector( '.cart-count-badge' );
if ( badge ) {
    watchSignal( cartCount, value => {
        badge.textContent  = value;
        badge.hidden       = value === 0;
    } );
}


NOTE: The TC39 Signals API is still a proposal and the polyfill API may change; for production use today, prefer the framework-specific signal libraries (@preact/signals-core, @angular/core signals, or Vue's ref()/computed()) which are stable and ship their own efficient effect schedulers.