I’m a big fan of the Signia library. Signia is a minimal, fast, and scalable signals library for TypeScript. It was built by the team at TLDraw (another excellent product).
There are many libraries available for using signals in Javascript - and there is even a TC39 proposal to add signals to the language.I’m a big fan of the Signia library. Signia is a minimal, fast, and scalable signals library for TypeScript. It was built by the team at TLDraw (another excellent product).
There are many libraries available for using signals in JavaScript - and there is even a TC39 proposal to add signals to the language.
We use Signia at Rough.app to build a highly performant web app even when querying large amounts of data.
Atomic Signals
In Signia, it all starts with “atoms”. An atom is just like a variable - you can store and update its state.
For example, suppose we want to store the name of the currently logged-in user. We can define an atom to hold the first and last names.
import { atom } from "signia";
const firstName = atom<string>("firstName", "");const lastName = atom<string>("lastName", "");We can then update the value of this atom using the .set or .update methods. The current value of the atom can be accessed via the .value property.
// overwrite the valuefirstName.set("George")firstName.value // "George"
// mutate the existing valuefirstName.update((prevName) => prevName.toUpperCase())firstName.value // "GEORGE"By themselves, atoms aren’t that interesting. We could do the same thing with a plain old variable. However, what makes atoms magic is that you use them to compute new values.
Computed Signals
Computed signals are created using the computed function. The value of the signal is equal to the return value of the function.
import { computed } from "signia";
firstName.set("George")lastName.set("Hudson")
const fullName = computed("fullName", () => { return `${firstName.value} ${lastName.value}`})
fullName.value // "George Hudson"This still isn’t that impressive - so far, this could all be done with regular variables. But watch this:
lastName.set("Mallory")
fullName.value // "George Mallory"As we update the atoms, the computed value is automatically updated.
Now, you might say, Why not just use a function? This seems like a much simpler way of achieving the same behaviour:
let firstName = ""let lastName = ""
let fullName = () => `${firstName} ${lastName}`()
firstName = "George"lastName = "Hudson"fullName() // "George Hudson"
lastName = "Mallory"fullName() // "George Mallory"One reason signals are better here is that they are lazy - they save you from doing extra work when it’s not necessary. If the computed signal is read twice, and the inputs don’t change, then it only needs to compute the value once.
Let’s track how many times the computed function runs.
let i = 0
const fullName = computed("fullName", () => { i += 1 return `${firstName.value} ${lastName.value}`})
lastName.set("Michael")
fullName.value // "George Michael"fullName.value // "George Michael"fullName.value // "George Michael"
i // 1
lastName.set("Hearst")
fullName.value // "George Hearst"fullName.value // "George Hearst"fullName.value // "George Hearst"
i // 2As you can see, the computed function only runs when necessary. If the inputs haven’t changed, then the computed signal can reuse the previous value it computed. This is great for performance!
Reacting to changes
Another key advantage of signals is that they can notify us when they change. This is critical when building user interfaces.
For example, we could render the value of the fullName signal - and keep the UI up to date as the value changes. We can do this using the react function. Note: that this is in no way related to the React library.
import { react } from "signia"
const heading = document.createElement("h1")
const stop = react("render fullName", () => { heading.innerText = fullName.value})
document.addChild(heading)
// call stop() when the heading is no longer being renderedLet’s couple this with an input to automatically update the first and last name atoms.
const firstNameInput = document.createElement("input");firstNameInput.placeholder = "First Name";firstNameInput.value = firstName.value;document.body.appendChild(firstNameInput);
const lastNameInput = document.createElement("input");lastNameInput.placeholder = "Last Name";lastNameInput.value = lastName.value;document.body.appendChild(lastNameInput);
// whenever the atom changes, update the atomconst handleChange = (atom) => (event) => { atom.set(event.currentTarget.value);}
firstNameInput.addEventListener("input", handleChange(firstName));lastNameInput.addEventListener("input", handleChange(lastName));Notice how our inputs only update the state of the firstName and lastName atoms. They are completely unaware of the computed fullName value or of rendering it to the UI.
Even so, as you type into the inputs, the full name is instantly calculated and displayed.
This is the power of Signals.
Learning more
I highly recommend reading the Signia documentation to learn more.