Table of Content
When I first started writing about Signals, I planned to put everything into one big blog post—from the basics to the advanced stuff. But as I got into it, I realized there was just too much good information to squeeze into a single post. I didn’t want to overload you with too much at once, so I decided to split it up into a series.
So, welcome to the first part of a three-part series on mastering Signals in state management.
Intro
The concept of Signals in the JavaScript community was popularized by the Solid.js framework, introduced by Ryan Carniato.
Later preact-signals, introduced in 2022 by Jason Miller, have quickly become popular among developers. They provide a way to manage state that keeps apps fast and efficient, no matter how complex they get, by using reactive principles.
A signal is basically an object with a .value property that holds a value. When you use a signal’s value in a component, the component will automatically update whenever the signal’s value changes. Signals are designed to keep updates fast and efficient, and they’re easy to use, making them accessible to all developers.
One great thing about signals is that they can be used both inside and outside of components, unlike hooks. They also work well with hooks and class components, so you can introduce them gradually while still using what you already know. Plus, adding signals to React only adds 1.6kB to your bundle size, which is very small.
In this series of posts, we will explore:
- What Signals Aim to Solve: We’ll start by understanding the problems that signals address in state management.
- Building Signals from Scratch: We’ll create
signal,computed,effect, andbatchmethods from the ground up, as if we’re the first to invent them. - Best Practices with Signals: Once we’ve built our signals, we’ll dive into the best practices for using them effectively.
- Comparing Solutions: Finally, we’ll compare our signal-based approach with other state management solutions to see how they stack up.
Problems Signals Aim to Solve
Signals are designed to make state management easier as apps grow. Traditional methods like moving state up the component tree or using context can slow down performance and complicate the code. Memoization can help, but it’s tricky to use correctly in large projects.
Signals solve these problems by offering a fast and easy-to-use solution that works well with existing frameworks. They reduce the need for complex workarounds (Context, Memoization), giving developers a simpler way to manage state in their apps.
Signals offer the advantage of granular state updates, whether the state is global, passed through props or context, or local to a component.
Main Idea of Signals
Signals are reactive primitives that hold a value and automatically trigger updates when that value changes. They are designed to be the smallest unit of reactive state.
You may ask, how do signals know when to inform and update the UI when their value changes? Let’s break it down:
Imagine signals as messengers. Each signal holds a value—like a number or a piece of text—and keeps a close eye on it. When you create a signal, it quietly waits for someone to ask about its value, like when a UI component displays that value.
Now, when a component or function reads the signal’s value, the signal takes note of who’s interested. It’s like the signal saying, “Ah, I see you’re watching me! I’ll let you know if anything changes.”
So, when you update the signal’s value—let’s say you increase a counter—the signal immediately knows that it has new information. It then sends out a quick message to all those who were interested, saying, “Hey, my value changed! You might want to update the UI to reflect this.”
The UI, upon receiving this message, re-renders only the parts that need to show the new value. This way, the updates are super efficient—no unnecessary work is done. The signal does all the hard work of keeping track of who needs to know about its changes, so you don’t have to worry about it. You can read and learn more about signals here.
Now, that you know what is the main idea of Signals let’s try to create Signals from scratch together.
Building Signals from Scratch
Let’s start with method signal. Features that we focus to support at the beginning are:
- We need to keep track of value changes
- We need to inform the UI to rerender
- We need to let value consumers subscribe to our signal
- We need to let consumers unsubscribe from our signal
Now, let’s take a look at the following component:
import React from "react";
import { useSignals } from "./signal";
function Counter() {
const count = useSignals(0); // We initialise the signal with the value of 0 and we ask count signal about its value
return (
<div>
{/* We are using our count signal value */}
<p>{count.value}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}
We want to create a simple Counter component that uses signals to manage its state. The idea is to have a counter that updates automatically and shows the new value whenever it changes, without needing to use React’s useState.
Let’s see how we can achieve this:
We’ll start by handling a single subscriber, and then we’ll scale it from there.
First, we’ll create a function that tracks changes and triggers a callback after each update.
function createSignal(initialValue) {
let value = initialValue;
let onChangeNotify = null;
const signal = {
get value() {
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
if (onChangeNotify) {
onChangeNotify(value);
}
}
},
subscribe(subscriber) {
onChangeNotify = subscriber;
return () => {
onChangeNotify = null;
};
},
};
return signal;
}
Now if we use our function we will have
// Usage example
const mySignal = createSignal(10);
const unsubscribe = mySignal.subscribe((newValue) => {
console.log("Value changed to:", newValue);
});
mySignal.value = 20; // Console: Value changed to: 20
unsubscribe(); // Unsubscribe from changes
mySignal.value = 30; // No console output since we unsubscribed
Alright, now let’s add support for multiple subscribers.
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const signal = {
get value() {
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
subscribers.forEach((subscriber) => subscriber(value));
}
},
subscribe(subscriber) {
subscribers.add(subscriber);
return () => {
subscribers.delete(subscriber);
};
},
};
return signal;
}
Now, let’s see how it looks when we use this in our component.
export default function App() {
const count = createSignal(1);
useEffect(() => {
const unsubscribe = count.subscribe((value) => {
console.log("Subscriber1: Value Changed", value);
});
return () => {
unsubscribe();
};
}, []);
return (
<div>
{/* We are using our count signal value */}
<p>{count.value}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}
In the code above, each time the user clicks the button, the console shows the count going up. However, the App component doesn’t re-render. To fix this and trigger a re-render, the simplest solution is to use useState, as shown below.
export default function App() {
const count = createSignal(1);
const [localCount, setLocalCount] = useState(1);
useEffect(() => {
const unsubscribe = count.subscribe((value) => {
console.log("Subscriber1: Value Changed", value);
setLocalCount(value); // Correctly update localCount when signal changes
});
return () => {
unsubscribe();
};
}, []);
return (
<div>
<p>{localCount}</p>
<button onClick={() => (count.value = count.value + 1)}>Increment</button>
</div>
);
}
If you run the code above, you’ll notice that the value of localCount increases to 2 after the first click, but it doesn’t change after subsequent clicks. This happens because useEffect runs only once when the component mounts, and at that time, we start subscribing to our signal.
When the user clicks the button, the count value changes to 2, which triggers setLocalCount with the value 2. This causes the component to re-render. However, the problem occurs because the incrementSignal function directly updates the signal’s value (count.value = count.value + 1), but React doesn’t automatically recognize this change as a reason to re-render the component, except for the first time when useEffect subscribes.
To make sure the component re-renders each time the signal changes, we need to ensure that the subscription mechanism within useEffect properly listens to the changes in the signal and triggers the re-render consistently.
Here’s a brief summary of how the code works:
- createSignal(1) initializes a signal with the value of 1.
- useState(1) sets up local state, localCount, initialized with 1.
- useEffect subscribes to changes in the signal and updates localCount whenever the signal value changes.
- The incrementSignal function increments the signal value, but due to the way the signal is handled, subsequent changes might not trigger re-renders as expected.
To solve this issue you may say let’s add count signal as a dependency to our useEffect like below:
export default function App() {
const count = createSignal(1);
const [localCount, setLocalCount] = useState(count.value);
useEffect(() => {
const unsubscribe = count.subscribe((value) => {
console.log("Subscriber1: Value Changed", value);
setLocalCount(value); // Correctly update localCount when signal changes
});
return () => {
unsubscribe();
};
}, [count]); // Add 'count' as a dependency to the effect
return (
<div>
<p>{localCount}</p>
<button onClick={() => (count.value = count.value + 1)}>Increment</button>
</div>
);
}
if you run the above code you, you’ll notice the value oscillates between 2, 3 and then back to 2, 3, and so on, suggests that the useEffect hook might be re-running and causing a loop. This can happen if the count object is being re-created on each render, which can cause the effect to re-subscribe to the signal, leading to multiple subscriptions and unexpected behavior.
To avoid this issue, we should ensure that count remains stable across renders. We can achieve this by using useMemo or useRef to create the signal only once.
export default function App() {
const countRef = useRef(createSignal(1)); // Use useRef to create the signal only once
const count = countRef.current;
const [localCount, setLocalCount] = useState(count.value);
useEffect(() => {
const unsubscribe = count.subscribe((value) => {
console.log("Subscriber1: Value Changed", value);
setLocalCount(value);
});
return () => {
unsubscribe();
};
}, [count]); // Dependency array should include count (or countRef)
return (
<div>
<p>{localCount}</p>
<button onClick={() => (count.value = count.value + 1)}>Increment</button>
</div>
);
}
Now, if we run this code, our component re-renders properly, and the values stay in sync. But it’s not very clean, right? Let’s improve the readability and ergonomics of our signal.
We’ll create a custom hook to organize and group the related parts of our code.
function useSignal(initialValue) {
const signalRef = useRef(createSignal(initialValue)); // Create the signal only once
const signal = signalRef.current;
const [, setState] = useState(signal.value); // Use state to force re-renders
useEffect(() => {
const unsubscribe = signal.subscribe(() => {
setState(signal.value); // Force a re-render when the signal changes
});
return () => {
unsubscribe(); // Cleanup the subscription on unmount
};
}, [signal]);
return signal;
}
Now Let’s see how we can use our custom hook
export default function App() {
const count = useSignal(1);
return (
<div>
{/* Directly using countSignal.value */}
<p>{count.value}</p>
<button onClick={() => (count.value = count.value + 1)}>Increment</button>
</div>
);
}
Finally, here’s the improved version.
import {
useEffect,
useRef,
useState,
useMemo,
useSyncExternalStore,
} from "react";
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const signal = {
get value() {
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
subscribers.forEach((subscriber) => subscriber());
}
},
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
};
return signal;
}
export function useSignal(initialValue) {
const signal = useMemo(() => createSignal(initialValue), [initialValue]);
const subscribe = useMemo(() => {
return (callback) => {
const unsubscribe = signal.subscribe(callback);
return unsubscribe;
};
}, [signal]);
const getSnapshot = useMemo(() => {
return () => signal.value;
}, [signal]);
const storeVersion = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {
signal.value = storeVersion;
}, [storeVersion]);
return signal;
}
export default function App() {
const countSignal = useSignal(1);
return (
<div>
<p>{countSignal.value}</p>
<button onClick={() => (count.value = count.value + 1)}>Increment</button>
</div>
);
}
In our final implementation we used useSyncExternalStore hook which is designed to work with external sources of state that are not managed by React. It ensures that the external state (in this case, the signal) is correctly integrated into React’s rendering flow. This prevents stale values and ensures that the component always reflects the current state of the signal.
By using useSyncExternalStore, React can now better track the changes in the signal and trigger re-renders whenever the signal's value changes, ensuring consistent updates.
This code has only one limitation that does not allow us to create signals outside of our component. To support this we should change our code like below
import React, { useEffect, useRef, useSyncExternalStore } from "react";
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const signal = {
get value() {
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
subscribers.forEach((subscriber) => subscriber());
}
},
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
};
return signal;
}
// Custom hook to manage and subscribe to a signal
export function useSignal(initialValueOrSignal) {
const signalRef = useRef(null);
if (signalRef.current === null) {
signalRef.current =
typeof initialValueOrSignal === "object"
? initialValueOrSignal
: createSignal(initialValueOrSignal);
}
const signal = signalRef.current;
const subscribe = (callback) => {
const unsubscribe = signal.subscribe(callback);
return unsubscribe;
};
const getSnapshot = () => signal.value;
useSyncExternalStore(subscribe, getSnapshot);
return signal; // Return the signal itself so it can be used directly
}
// Create a signal outside the component
const externalSignal = createSignal(1);
function SiblingA() {
const countSignal = useSignal(externalSignal);
return (
<div>
<h3>Sibling A - External Signal</h3>
<p>{countSignal.value}</p>
<button onClick={() => (countSignal.value = countSignal.value + 1)}>
Increment in A
</button>
</div>
);
}
// Sibling Component B
function SiblingB() {
const countSignal = useSignal(externalSignal);
return (
<div>
<h3>Sibling B - External Signal</h3>
<p>{countSignal.value}</p>
<button onClick={() => (countSignal.value = countSignal.value + 1)}>
Increment in B
</button>
</div>
);
}
// External effect on the signal (e.g., simulating an API call)
function ExternalEffect() {
const countSignal = useSignal(externalSignal);
useEffect(() => {
const interval = setInterval(() => {
countSignal.value = countSignal.value + 1;
console.log("External effect incremented signal");
}, 5000);
return () => clearInterval(interval);
}, []);
return null;
}
const interval = setInterval(() => {
externalSignal.value = externalSignal.value + 10;
}, 10000);
window.addEventListener("beforeunload", () => {
clearInterval(interval);
});
export default function App() {
return (
<div>
<SiblingA />
<SiblingB />
<ExternalEffect />
</div>
);
}
I hope you found this post useful and got the idea of how signals work under the hood. Signals provide a powerful way to manage state by offering a simple, reactive model that can be easily integrated into your applications.
Coming up in part two, we’ll look at how to implement methods like computed, effect, and batch to enhance your state management knowledge and in part three I try to share signals best practices and compare it with other state management solutions.