State Management: The _studs Brain
Let's see how the "Cake" of data we merged in Topic 9 is actually brought to life. This is the moment a static object becomes a Reactive State, which LegoDOM stores in a property called _studs.
The _studs Property
Once snap() has merged the data from the Script (Tier 1), the Template (Tier 2), and the Instance (Tier 3), it doesn't just save that object to the element. It transforms it into a reactive proxy.
Location in the Codebase
This transformation happens in src/core/lifecycle.js:
// src/core/lifecycle.js
import { reactive } from './reactive.js';
import { mergeDescriptors } from '../utils/helpers.js';
import { globals } from './globals.js';
import { findAncestor } from '../utils/dom.js';
export const snap = (el) => {
// ... template and shadow setup ...
// TIER 1: Script logic from Lego.block or .lego file
const scriptLogic = legoFileLogic.get(name) || {};
// TIER 2: Template-level logic
const templateLogic = parseJSObject(
templateNode.getAttribute('b-logic') ||
templateNode.getAttribute('b-logic') || '{}'
);
// TIER 3: Instance-level logic (scoped to parent)
const parentBlock = findAncestor(el, '*') || findAncestor(el.getRootNode().host, '*');
const parentScope = parentBlock?.state || {};
const instanceLogic = parseJSObject(
el.getAttribute('b-logic') ||
el.getAttribute('b-logic') || '{}',
parentScope
);
// Build base state with framework helpers
const baseState = {
$vars: {},
$element: el,
get $parent() { return findAncestor(el, '*') },
$emit: (name, detail) => {
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
},
get $route() { return globals.$route },
get $go() { return globals.$go }
};
// Merge all tiers
mergeDescriptors(scriptLogic, baseState);
mergeDescriptors(templateLogic, baseState);
mergeDescriptors(instanceLogic, baseState);
// Transform into reactive proxy
el._studs = reactive(baseState, el);
// Convenience getter/setter
Object.defineProperty(el, 'state', {
get() { return this._studs },
set(v) { Object.assign(this._studs, v) },
configurable: true,
enumerable: false
});
// ... rest of snap logic ...
};1. Why the name _studs?
In the physical world, "studs" are the bumps on top of a Lego brick that allow it to connect to others. In this library:
el._studsrepresents the connection point between your JavaScript logic and the DOM.It is the "source of truth" for the block. Every
[[ variable ]]you write in your HTML is looking for a matching key inside_studs.The underscore prefix
_indicates it's an internal property (though still publicly accessible for debugging).
2. The Transformation: reactive()
LegoDOM executes this line during the snap process:
el._studs = reactive(baseState, el);The reactive() function is defined in src/core/reactive.js:
// src/core/reactive.js
import { globalBatcher } from './batcher.js';
import { isDbDescriptor, initializeDbProp, handleDbUpdate } from '../features/persistence.js';
const proxyCache = new WeakMap();
export const reactive = (obj, el, batcher = globalBatcher) => {
if (obj === null || typeof obj !== 'object' || obj instanceof Node) return obj;
if (proxyCache.has(obj)) return proxyCache.get(obj);
// Initialize $db descriptors
for (const k in obj) {
if (isDbDescriptor(obj[k])) {
initializeDbProp(obj, k, obj[k], el, batcher);
}
}
const handler = {
get: (t, k) => {
const val = Reflect.get(t, k);
// Recursively make nested objects reactive
if (val !== null && typeof val === 'object' && !(val instanceof Node)) {
return reactive(val, el, batcher);
}
return val;
},
set: (t, k, v) => {
const old = t[k];
const r = Reflect.set(t, k, v);
if (old !== v) {
// Notify persistence layer if needed
handleDbUpdate(t, k, v);
// Queue render
if (batcher) batcher.add(el);
}
return r;
},
deleteProperty: (t, k) => {
const r = Reflect.deleteProperty(t, k);
if (batcher) batcher.add(el);
return r;
}
};
const p = new Proxy(obj, handler);
proxyCache.set(obj, p);
return p;
};The Proxy wrapping: This wraps the merged object in a Proxy (see Topic 4 for details).
The
elargument: Crucially, the proxy is given a reference to the DOM element (el). This allows the Proxy'ssettrap to know exactly which block needs to be added to theglobalBatcherwhen a property changes.
3. Contextual Binding (The this Keyword)
After the _studs proxy is created, LegoDOM ensures that your methods work correctly. When you define a method in your Lego File, you expect this to point to your data.
Inside
snap(), lifecycle hooks are called like this:jsif (typeof el._studs.mounted === 'function') { try { el._studs.mounted.call(el._studs); } catch (e) { handleError(e, el, 'mounted'); } }By using
.call(el._studs), LegoDOM forces the execution context of your functions to be the reactive proxy.Result: When you write
this.count++in your code, you are actually interacting with the Proxy, which triggers thesettrap, which notifies the Batcher, which triggers the Render.
4. The state Convenience Property
For easier access, LegoDOM also defines a state property on the element:
Object.defineProperty(el, 'state', {
get() { return this._studs },
set(v) { Object.assign(this._studs, v) },
configurable: true,
enumerable: false
});This allows you to use either el._studs or el.state interchangeably:
const myBlock = document.querySelector('my-block');
myBlock.state.count++; // Same as myBlock._studs.count++5. Injected Framework Helpers
The base state includes several framework-provided helpers:
const baseState = {
$vars: {}, // DOM element references (populated by b-var)
$element: el, // The block's root element
get $parent() { ... }, // Lazy parent block lookup
$emit: (name, detail) => { ... }, // Event emitter
get $route() { ... }, // Current route (from src/features/router.js)
get $go() { ... } // Navigation helper (from src/features/router.js)
};These are available in all blocks automatically:
// In your block logic:
{
mounted() {
console.log(this.$element); // The <my-block> DOM element
console.log(this.$parent); // The parent block (if nested)
this.$emit('loaded', { time: Date.now() });
this.$go('/dashboard'); // Navigate to route
}
}6. Database Integration
If you use $db properties, they are automatically initialized:
// In your block:
{
todos: Lego.db('my-todos').default([])
}
// During snap(), reactive() detects the descriptor and converts it:
// 1. Loads existing value from localStorage
// 2. Replaces descriptor with actual value
// 3. Registers for auto-save on changes
// 4. Registers for cross-tab syncThis is handled by src/features/persistence.js.
7. Visibility vs. Privacy
_studs: This is attached directly to the DOM element. You can actually typedocument.querySelector('my-block')._studsin your browser console to see and even modify the live state of any block. This is great for debugging.privateData: Unlike_studs, the internal "housekeeping" (like whether the block has already snapped, bindings cache, etc.) is kept in aWeakMapcalledprivateData(insrc/core/registry.js), making it inaccessible to the outside world.
// src/core/registry.js
export const privateData = new WeakMap();
export const getPrivateData = (el) => {
if (!privateData.has(el)) {
privateData.set(el, {
snapped: false,
bindings: null,
rendering: false,
anchor: null,
bound: false
});
}
return privateData.get(el);
};Summary: _studs is the reactive engine of your block. It is created by merging all data tiers (Script, Template, Instance) with framework helpers, then wrapping the result in a Proxy from src/core/reactive.js. This proxy is uniquely linked to that specific DOM element, enabling surgical, per-block reactivity.