As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let’s talk about state in JavaScript applications. If you’ve ever built something interactive, you know the challenge: when a piece of data changes, you need to update the right parts of your interface, and you need to do it efficiently. For a long time, this meant a lot of manual work or using systems that often re-rendered more than necessary, which can slow things down.
There’s a different way to think about this problem. Imagine if every individual piece of your application’s state could announce its own changes, and only the parts of your app that specifically care about that piece would listen and react. Nothing else would be disturbed. This is the core idea behind signals and fine-grained reactivity. It’s a shift from telling the entire UI to “check for changes” to having a direct, one-to-one conversation between data and its consumers.
I want to walk you through how this works, not just in theory but in practice. We’ll build up the concepts from the ground up, with code you can use and adapt. Think of it as learning a new set of tools that make your applications faster and your code simpler to reason about.
At the heart of this system is the signal. A signal is a container for a value with a simple superpower: it knows what other parts of the code are interested in its current value. When that value changes, it can notify just those interested parties.
Here is a basic way to create one. We start with a class that holds a value and keeps track of subscribers.
class SignalCore {
constructor(value) {
this._value = value;
this._subscribers = new Set();
}
get value() {
// Dependency tracking happens here
return this._value;
}
set value(newValue) {
if (Object.is(this._value, newValue)) return;
this._value = newValue;
this._notifySubscribers();
}
_notifySubscribers() {
this._subscribers.forEach(subscriber => subscriber());
}
subscribe(fn) {
this._subscribers.add(fn);
return () => this._subscribers.delete(fn);
}
}
This is the foundation. You create a signal with an initial value. When you get its .value, you’re reading it. When you set its .value, it compares the new value with the old one. If they’re different, it notifies everyone in its subscriber list by calling their functions.
This alone is useful, but the real magic starts when we add dependency tracking. We need a way for the signal to know who is reading it at any given moment, so it can add that reader to its subscriber list automatically. We do this by setting a global pointer to the currently running “computation” or effect.
Let’s modify our getter to use this system.
get value() {
// If something is currently 'listening', track this signal as a dependency
if (SignalCore._activeComputation) {
this._subscribers.add(SignalCore._activeComputation);
}
return this._value;
}
We need a place to store that active computation. We can use a static property on the class.
SignalCore._activeComputation = null;
Now we can create our first reactive primitive: an effect. An effect is a function that runs and automatically re-runs whenever any signal it reads inside changes.
function createEffect(effectFn) {
const execute = () => {
// Set this effect as the active computation
SignalCore._activeComputation = execute;
try {
effectFn();
} finally {
// Clean up after running
SignalCore._activeComputation = null;
}
};
// Run it once to set up initial dependencies
execute();
}
Let’s see it in action with two signals.
const firstName = new SignalCore('John');
const lastName = new SignalCore('Doe');
createEffect(() => {
console.log(`Hello, ${firstName.value} ${lastName.value}!`);
});
// Logs: Hello, John Doe!
firstName.value = 'Jane';
// Automatically logs: Hello, Jane Doe!
The effect ran once. It read both signals, so it became a subscriber to both. When firstName changed, the effect’s function was called again. This is fine-grained reactivity. The effect didn’t need to know what changed; the signals told it.
Our next technique is the computed signal. This is a signal whose value is derived from other signals. It should only re-calculate when one of its dependencies changes.
To build this, we need a more robust tracking system. Our SignalCore needs to track not just subscriber functions, but also which computed signals depend on it. Let’s update the class.
class SignalCore {
constructor(value) {
this._value = value;
this._subscribers = new Set();
this._dependencies = new Set(); // For computed signals that depend on this
}
get value() {
if (SignalCore._activeComputation) {
this._dependencies.add(SignalCore._activeComputation);
SignalCore._activeComputation._dependencies.add(this);
}
return this._value;
}
set value(newValue) {
if (Object.is(this._value, newValue)) return;
this._value = newValue;
this._notify();
}
_notify() {
// Notify direct subscriber functions
this._subscribers.forEach(sub => sub());
// Notify any dependent computed signals
this._dependencies.forEach(dep => dep._update());
}
subscribe(fn) {
this._subscribers.add(fn);
return () => this._subscribers.delete(fn);
}
}
Now we can create a ComputedSignal class that extends this core. It won’t have a setter. Its value comes from a computation function.
class ComputedSignal extends SignalCore {
constructor(computeFn) {
super(undefined); // Start with no value
this._computeFn = computeFn;
this._isDirty = true; // Needs first calculation
this._update(); // Calculate initial value
}
get value() {
if (this._isDirty) {
this._update();
}
// Use parent's getter to track dependencies of this computed signal
return super.value;
}
_update() {
// Clear old dependencies
this._dependencies.forEach(dep => dep._dependencies.delete(this));
this._dependencies.clear();
// Run computation with this as the active context
SignalCore._activeComputation = this;
try {
const newValue = this._computeFn();
if (!Object.is(this._value, newValue)) {
this._value = newValue;
this._isDirty = false;
// Notify anyone depending on this computed signal
this._notify();
}
} finally {
SignalCore._activeComputation = null;
}
}
// Called when a dependency notifies us
_markDirty() {
if (!this._isDirty) {
this._isDirty = true;
// We don't recalculate yet, just notify our dependents
this._notify();
}
}
}
Let’s create some helper functions to make this easier to use.
function createSignal(value) {
return new SignalCore(value);
}
function createComputed(fn) {
return new ComputedSignal(fn);
}
function createEffect(fn) {
const effect = {
_execute() {
SignalCore._activeComputation = effect;
try {
fn();
} finally {
SignalCore._activeComputation = null;
}
}
};
effect._dependencies = new Set();
effect._execute();
// For simplicity, we don't handle cleanup here yet
}
Now, here’s a practical example. Imagine a shopping cart.
const itemCount = createSignal(2);
const itemPrice = createSignal(25.99);
const subtotal = createComputed(() => itemCount.value * itemPrice.value);
const tax = createComputed(() => subtotal.value * 0.08);
const total = createComputed(() => subtotal.value + tax.value);
createEffect(() => {
console.log(`Total to pay: $${total.value.toFixed(2)}`);
});
// Logs: Total to pay: $56.14
itemCount.value = 3;
// Automatically re-computes and logs: Total to pay: $84.21
Only the necessary computations happen. When itemCount changes, subtotal, tax, and total are recalculated in the right order. Our effect runs once at the end.
A common problem in UI updates is something called a "glitch." This happens when you read a computed signal that is in a temporarily inconsistent state because its dependencies are mid-update. Our current setup might have this issue if we update multiple signals at once.
We can solve this with a fourth technique: batching updates. The goal is to postpone all notifications until a batch of changes is complete, then notify once with all final values.
We’ll create a simple batch manager.
const batchStack = [];
function batch(callback) {
batchStack.push(new Set());
try {
callback();
} finally {
const updates = batchStack.pop();
// If we're not in a nested batch, flush updates
if (batchStack.length === 0) {
updates.forEach(signal => signal._notify());
} else {
// Otherwise, add to parent batch
updates.forEach(signal => batchStack[batchStack.length - 1].add(signal));
}
}
}
Our signals need to cooperate. When a signal’s value is set inside a batch, it should schedule its notification instead of doing it immediately.
class SignalCore {
// ... previous code ...
set value(newValue) {
if (Object.is(this._value, newValue)) return;
this._value = newValue;
if (batchStack.length > 0) {
// Schedule for later
batchStack[batchStack.length - 1].add(this);
} else {
this._notify();
}
}
}
Now you can group changes.
batch(() => {
itemCount.value = 10;
itemPrice.value = 19.99;
});
// The effect runs only once, after both updates.
This prevents intermediate states from causing unnecessary work or visual flicker.
The fifth technique is about structure. In a real app, you don’t have just a few loose signals. You have a state tree. We can create a store that organizes signals and provides methods to update them predictably.
Here’s a simple store pattern.
class SignalStore {
constructor(initialState) {
this._state = {};
this._signals = {};
Object.keys(initialState).forEach(key => {
this._signals[key] = createSignal(initialState[key]);
// Define a getter for easy access
Object.defineProperty(this._state, key, {
get: () => this._signals[key].value,
enumerable: true
});
});
}
get(key) {
return this._state[key];
}
getSignal(key) {
return this._signals[key];
}
set(key, value) {
if (this._signals[key]) {
this._signals[key].value = value;
}
}
batchUpdate(updater) {
batch(() => updater(this));
}
}
Use it like this.
const userStore = new SignalStore({
name: 'Alice',
age: 30,
preferences: { theme: 'light' }
});
const userName = userStore.getSignal('name');
createEffect(() => {
document.title = `Profile: ${userName.value}`;
});
userStore.batchUpdate((store) => {
store.set('name', 'Bob');
store.set('age', 31);
});
This gives you a central place for state that is still built from fine-grained signals.
The sixth technique integrates this with the DOM. We want our UI to update when signals change. We can create a small library to bind signals to DOM elements.
class ReactiveDOM {
bindText(element, signal) {
const update = () => element.textContent = signal.value;
signal.subscribe(update);
update(); // Set initial value
}
bindAttribute(element, attrName, signal) {
const update = () => element.setAttribute(attrName, signal.value);
signal.subscribe(update);
update();
}
createReactiveElement(tag, props, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props || {})) {
if (value && typeof value === 'object' && 'value' in value) {
// It's a signal
this.bindAttribute(el, key, value);
} else {
el.setAttribute(key, value);
}
}
children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child && typeof child === 'object' && 'value' in child) {
// It's a signal for text content
this.bindText(el, child);
} else if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
return el;
}
}
Let’s build a counter UI.
const dom = new ReactiveDOM();
const count = createSignal(0);
const button = dom.createReactiveElement('button', {
onclick: () => count.value++
}, 'Count: ', count);
document.body.appendChild(button);
Every time you click, the count signal updates, and the button’s text updates automatically. Only that specific text node is touched by the DOM.
The seventh technique deals with a common need: managing side effects and cleanup. Our simple createEffect needs improvement. When a dependency changes, we should clean up the old effect before running the new one, and we should clean up when the effect is no longer needed.
Let’s build a more robust effect manager.
function createEffect(effectFn) {
let cleanupFn;
let isDisposed = false;
const execute = () => {
// Clean up previous effect
if (cleanupFn) {
cleanupFn();
cleanupFn = null;
}
// Set as active computation
SignalCore._activeComputation = execute;
try {
// The effect can return a cleanup function
cleanupFn = effectFn() || null;
} finally {
SignalCore._activeComputation = null;
}
};
// Run once to start
execute();
// Return a dispose function
return () => {
if (isDisposed) return;
isDisposed = true;
if (cleanupFn) cleanupFn();
// Remove this effect from all its dependencies
execute._dependencies.forEach(signal => {
signal._dependencies.delete(execute);
signal._subscribers.delete(execute);
});
};
}
This pattern is useful for setting up and tearing down event listeners, subscriptions, or intervals.
const timer = createSignal(0);
const disposeEffect = createEffect(() => {
const interval = setInterval(() => {
timer.value++;
}, 1000);
// Cleanup function
return () => clearInterval(interval);
});
// Later, to stop everything
// disposeEffect();
The eighth technique is about scalability and developer experience. As your app grows, you’ll want tools for debugging and inspection. We can add a simple devtools hook.
We can instrument our SignalCore to log changes.
class SignalCore {
constructor(value, name) {
this._value = value;
this._subscribers = new Set();
this._dependencies = new Set();
this._name = name; // For debugging
if (typeof window !== 'undefined' && window.__SIGNAL_DEVTOOLS__) {
window.__SIGNAL_DEVTOOLS__.registerSignal(this);
}
}
set value(newValue) {
const oldValue = this._value;
if (Object.is(oldValue, newValue)) return;
this._value = newValue;
// Notify devtools
if (window.__SIGNAL_DEVTOOLS__) {
window.__SIGNAL_DEVTOOLS__.logUpdate(this, oldValue, newValue);
}
// ... rest of setter logic ...
}
}
You could then implement a simple devtools panel that listens to this global hook and displays a log of signal changes, helping you trace the flow of data.
Bringing it all together, these eight techniques—basic signals, dependency tracking, effects, computed values, batching, structured stores, DOM binding, and managed effects—form a complete foundation for fine-grained reactivity.
This approach might seem different at first, but it directly addresses the performance and complexity issues of older models. Instead of diffing a large virtual tree to guess what changed, you know exactly what changed because the signals tell you.
The code examples here are building blocks. You can start small, with just a few signals and effects, and gradually incorporate more patterns as your application demands. The key insight is that by making state itself observable and reactive, you shift the burden of update coordination from the developer to the system, leading to code that is often simpler and applications that are consistently faster.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)