Why This Article Exists
In the upcoming articles, we’ll implement signal() using closures to preserve state, and we’ll read and write values via object destructuring, for example:
const { get, set } = signal(0);
If you’re not comfortable with these two concepts, it’s very easy to fall into common misunderstandings during tutorials—such as:
- thinking values are “snapshotted” and reactivity is broken, or
- running into incorrect assumptions about
thisbinding.
This is not hypothetical. In a previous role at a well-known company, I encountered a frontend tech lead who had a flawed understanding of destructuring assignment—likely due to limited exposure outside of React.
So, just to be safe, let’s briefly review these fundamentals before moving on.
(If you already understand them well, feel free to skip this article.)
Closures
A closure allows a function to remember variables from the lexical scope in which it was created—even after that scope has finished executing.
If you want a deeper explanation, you can refer to my Medium article on closures. If lexical scope itself is unclear, I recommend reviewing that first.
Here, we’ll adapt a standard closure example into what a basic signal implementation looks like:
function signal<T>(initial: T) {
// Private state remembered by the closure
let value = initial;
// Read
const get = () => value;
// Write
const set = (next: T) => {
value = next;
};
// Returning an object is more readable for most people;
// an array form is also possible if you prefer.
return { get, set };
}
const count = signal(0);
count.set(count.get() + 1);
console.log(count.get()); // 1
Why This Pattern Works Well
Encapsulation (Immutability by intent)
The external world cannot access value directly—only via get and set.
This is the same design intent as private fields in classes or Proxy-based encapsulation.
Stable references
get and set are stable function references; they do not need to be recreated.
This makes them ideal for integration with React, event handlers, or any callback-based system.
Compared to useState
React hooks must be called during render.
A closure-based signal() can be created at any time and is framework-agnostic.
Closures are not magic—they’re just functions + lexical scope.
Once you internalize this, topics like dependency tracking and schedulers will feel much more natural.
Destructuring Assignment
Arrays or objects can be “unpacked” into multiple variables, with support for renaming, default values, and nested patterns.
This is the exact concept I’ve seen misunderstood in real-world teams. If you want a more comprehensive explanation, you can refer to a dedicated destructuring article.
Array Destructuring
Let’s use Solid’s createSignal as an example:
function createSignal<T>(initial: T) {
let value = initial;
const getter = () => value;
const setter = (next: T) => {
value = next;
};
return [getter, setter] as const;
}
const [count, setCount] = createSignal(0);
setCount(count() + 1);
Object Destructuring
Using our earlier signal example:
function signal<T>(initial: T) {
let value = initial;
const get = () => value;
const set = (next: T) => {
value = next;
};
return { get, set };
}
const { get, set } = signal(0);
set(get() + 1);
Renaming is also supported:
const { get: count, set: setCount } = signal(0);
setCount(count() + 1);
Common Pitfalls
Destructuring Extracts References, Not Copies
const { get } = signal(0);
What you receive is a function reference, not a snapshot of the value.
The current state is only read when you actually call get().
Don’t Cache Values If You Need Fresh State
If you want the latest value, call get() again—don’t reuse an old variable.
const { get, set } = signal(0);
const v = get(); // snapshot
set(10);
console.log(v); // still 0, not 10
Why Signals Prefer “Closure + Destructuring”
Closures keep internal state private and controlled, and naturally support
extensions like computed caching, equality checks, and subscriber lists.Object destructuring produces clearer APIs (
get,set,peek,on, etc.)
compared to position-sensitive array destructuring, and scales better as an
API becomes more discoverable.
Frequently Asked Questions
Can I do const value = get() and pass it around?
Yes, but that value is a snapshot.
If you need live state, pass the get function itself or call get() at the usage site.
Does destructuring break reactivity?
No. Reactivity occurs at the read point—when get() is called—not when the function is destructured.
Why not use a class?
Closures are lighter, avoid this binding issues, and work better with tree-shaking and functional composition.
A Signal That Returns an Object
Combining everything above leads to the following foundational API, which we’ll extend in later articles:
export type Signal<T> = {
get(): T;
set(next: T | ((prev: T) => T)): void;
};
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const get = () => value;
const set = (next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === "function"
? (next as (p: T) => T)(value)
: next;
if (!Object.is(value, nextValue)) {
value = nextValue;
}
};
return { get, set };
}
Conclusion
This article reviewed two fundamental JavaScript concepts—closures and destructuring—and showed how they form the foundation of a Signal implementation.
In the next article, we’ll build on this foundation by introducing a subscription mechanism.
Top comments (0)