Creating Reactive State
Quick Start (30 seconds)
// The simplest possible reactive state
const counter = state({ count: 0 });
// Use it like a normal object
counter.count++;
// React to changes
effect(() => {
console.log('Count:', counter.count);
});What is state()?
state() is the foundation of the entire reactive system. It takes a plain JavaScript object and returns a reactive version of it — an object that tracks every read and write automatically.
Everything else in the reactive system (effects, computed properties, watchers, forms, collections) builds on top of state().
Think of state() as the "on switch" for reactivity. Without it, your data is static. With it, your data is alive and connected.
Syntax
// Basic state creation
const myState = state(initialObject);
// With multiple properties
const app = state({
name: 'Alice',
age: 25,
isLoggedIn: false
});
// Nested objects — deep reactivity is automatic
const store = state({
user: {
profile: {
name: 'Alice'
}
},
settings: {
theme: 'light'
}
});Parameters:
initialObject— A plain JavaScript object with your starting values
Returns:
- A reactive Proxy that behaves exactly like your original object
Why Does This Exist?
Working With state() vs Plain JavaScript
When you need reactive behavior, state() is your go-to tool. Here's the contrast in a real scenario:
Plain JavaScript — Managing State Manually
// Plain JavaScript approach
let user = {
name: 'Alice',
email: 'alice@example.com',
role: 'user'
};
function updateName(newName) {
user.name = newName;
// Must manually update every display
Id('header-name').update({ textContent: newName });
Id('profile-name').update({ textContent: newName });
Id('nav-name').update({ textContent: newName });
document.title = `Profile - ${newName}`;
}
function updateRole(newRole) {
user.role = newRole;
// Must manually update role-related UI
Id('role-badge').update({ textContent: newRole });
Id('admin-panel').update({ hidden: newRole !== 'admin' });
Id('user-menu').update({ className: `menu menu-${newRole}` });
}At first glance, this looks manageable. But there's a hidden problem.
What's the Real Issue?
User changes name
↓
You must remember every place that shows the name
↓
Update each one manually in the right function
↓
Miss one? → UI is out of sync
Add a new display? → Update the function
Change another property? → Write another update block
↓
Code grows. Bugs appear. Syncing becomes fragile.Problems: ❌ Every property change needs its own manual update block ❌ Adding new UI elements means updating existing functions ❌ Easy to forget a display — UI silently shows wrong data ❌ Functions grow longer as the app grows
The Solution with state()
// Reactive approach
const user = state({
name: 'Alice',
email: 'alice@example.com',
role: 'user'
});
// Describe the relationships once
effect(() => {
Elements.update({
header-name: { textContent: user.name },
profile-name: { textContent: user.name },
nav-name: { textContent: user.name }
});
document.title = `Profile - ${user.name}`;
});
effect(() => {
Elements.update({
role-badge: { textContent: user.role },
admin-panel: { hidden: user.role !== 'admin' }
});
Id('user-menu').update({ className: `menu menu-${user.role}` });
});
// Now updates are trivial
user.name = 'Bob'; // All name displays update automatically
user.role = 'admin'; // All role displays update automaticallyWhat Just Happened?
user.name = 'Bob'
↓
Proxy intercepts the write
↓
Finds all effects that read "name"
↓
Re-runs them automatically
↓
All displays show "Bob"Benefits: ✅ Change user.name once — all displays update ✅ Add a new display? Just read user.name in an effect — no function changes ✅ The UI can never be out of sync ✅ Update logic stays clean and simple
Mental Model
A Live Database with Automatic Notifications
Think of reactive state as a live database table with subscriptions.
Regular JavaScript object:
┌───────────────────────────────────┐
│ user = { name: "Alice" } │
│ │
│ (A static record. Changing it │
│ notifies nobody. You must call │
│ updateEverything() manually.) │
└───────────────────────────────────┘
Reactive state:
┌───────────────────────────────────┐
│ user = state({ name: "Alice" }) │
│ │
│ SUBSCRIPTIONS: │
│ ├── Header display │
│ ├── Profile display │
│ ├── Navigation display │
│ └── Page title │
│ │
│ (Change "name" → all subscribers │
│ update automatically, instantly)│
└───────────────────────────────────┘You don't manage the subscriptions yourself. The reactive system records them automatically when an effect reads a property.
How Does It Work?
The Proxy Wrapper
When you call state({ count: 0 }), the system:
- Takes your plain object
{ count: 0 } - Wraps it in a JavaScript
Proxy - Returns the Proxy (which looks and acts like the original)
state({ count: 0 })
↓
┌─────────────────────────────────────┐
│ Proxy wraps your object │
│ │
│ GET trap: "count" was read │
│ → if inside effect, record dep │
│ │
│ SET trap: "count" was written │
│ → find all deps for "count" │
│ → queue them for re-run │
└─────────────────────────────────────┘
↓
Your effects re-run automaticallyYou use the object normally — app.count, app.count = 5. The Proxy does all the work invisibly.
Deep Reactivity
Nested objects become reactive automatically. You don't need to call state() on nested objects:
state({
user: { ← Wrapped in Proxy automatically
profile: { ← Wrapped in Proxy automatically
name: 'Alice' ← Each access tracked
}
}
})The system recursively wraps nested plain objects in Proxies when they are first accessed.
What Doesn't Get Wrapped
Some built-in JavaScript types are intentionally skipped — they would break if proxied:
const app = state({
// These are stored as-is, NOT wrapped in Proxy:
createdAt: new Date(), // Date objects
data: new Map(), // Map objects
tags: new Set(), // Set objects
request: new AbortController(), // AbortController
element: document.querySelector('#app'), // DOM elements
// But the PROPERTY holding them is still reactive:
count: 0, // This IS tracked
name: '' // This IS tracked
});
// Assigning a new Date triggers effects:
app.createdAt = new Date(); // ✅ Tracked — property changed
// But modifying the Date internally does NOT:
app.createdAt.setFullYear(2025); // ❌ Not tracked — internal mutationBasic Usage
Creating Simple State
// Boolean state
const visibility = state({ isVisible: true });
// String state
const theme = state({ mode: 'light' });
// Number state
const score = state({ points: 0, highScore: 0 });
// Mixed state
const app = state({
isLoading: false,
count: 0,
name: '',
tags: []
});Creating Nested State
// Deeply nested — all levels are reactive
const userProfile = state({
personal: {
name: 'Alice',
age: 25,
address: {
city: 'New York',
country: 'USA'
}
},
settings: {
notifications: true,
privacy: 'public',
theme: 'light'
}
});
// Reading nested values
console.log(userProfile.personal.name); // "Alice"
console.log(userProfile.personal.address.city); // "New York"
// Writing nested values — all reactive
userProfile.personal.name = 'Bob';
userProfile.personal.address.city = 'Los Angeles';
userProfile.settings.theme = 'dark';Reading State
const app = state({
count: 5,
name: 'DOMHelpers',
items: ['a', 'b', 'c']
});
// Read exactly like a plain object
console.log(app.count); // 5
console.log(app.name); // "DOMHelpers"
console.log(app.items[0]); // "a"
console.log(app.items.length); // 3
// Works with destructuring too
const { count, name } = app;
console.log(count); // 5 (note: this is a snapshot — not reactive)Important: Destructuring extracts a snapshot of the value at that moment. The variable
countabove will not update whenapp.countchanges. To keep reactivity, always read through the state object inside an effect.
Writing State
const app = state({ count: 0, name: 'Alice' });
// Direct assignment — the simplest way
app.count = 5;
app.name = 'Bob';
// Increment
app.count++;
app.count += 10;
// Using set() for functional updates
set(app, {
count: prev => prev + 1, // Increment by 1
name: prev => prev.trim() // Transform current value
});Checking If Something Is Reactive
import { isReactive } from '...'; // or use global isReactive
const plain = { count: 0 };
const reactive = state({ count: 0 });
console.log(isReactive(plain)); // false
console.log(isReactive(reactive)); // trueGetting the Raw Object
Sometimes you need the original, unwrapped object — for example when sending to an API:
const user = state({
name: 'Alice',
email: 'alice@example.com'
});
// Get the raw (non-reactive) object
const rawUser = getRaw(user);
console.log(rawUser); // { name: 'Alice', email: 'alice@example.com' }
// Common use: sending to API
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(getRaw(user)) // Send plain object, not proxy
});Deep Dive: Creating State for Different Scenarios
Form Data State
const loginForm = state({
email: '',
password: '',
rememberMe: false,
isSubmitting: false,
error: null
});
// Track form input
Elements.email.addEventListener('input', (e) => {
loginForm.email = e.target.value;
});
Elements.password.addEventListener('input', (e) => {
loginForm.password = e.target.value;
});
// Show error if it appears
effect(() => {
Id('error-message').update({
textContent: loginForm.error || '',
hidden: !loginForm.error
});
});
// Disable button while submitting
effect(() => {
Id('submit-btn').update({ disabled: loginForm.isSubmitting });
});UI State
const ui = state({
sidebarOpen: false,
activeTab: 'overview',
modalOpen: false,
notifications: []
});
// Toggle sidebar
Id('toggle-sidebar').addEventListener('click', () => {
ui.sidebarOpen = !ui.sidebarOpen;
});
// React to sidebar state
effect(() => {
Elements.sidebar.update({ classList: { toggle: ['open', ui.sidebarOpen] } });
Elements.content.update({ classList: { toggle: ['shifted', ui.sidebarOpen] } });
});Application Data State
const store = state({
users: [],
products: [],
currentUser: null,
cart: {
items: [],
total: 0
}
});
// Update cart total when items change
effect(() => {
store.cart.total = store.cart.items.reduce((sum, item) => sum + item.price, 0);
});Multiple Independent States
You can have as many independent reactive states as you need:
// Each state is independent
const uiState = state({ theme: 'light', sidebarOpen: false });
const userData = state({ name: '', email: '', isLoggedIn: false });
const cartData = state({ items: [], coupon: null });
const settingsData = state({ notifications: true, language: 'en' });
// Effects can read from multiple states
effect(() => {
// This effect depends on both uiState AND userData
document.title = userData.isLoggedIn
? `Welcome, ${userData.name} — ${uiState.theme} mode`
: 'Please log in';
});Deep Dive: State Initialization Patterns
Initializing with Default Values
// Always provide sensible defaults
const app = state({
isLoading: false, // Not null or undefined — be explicit
data: null, // null is a valid "empty" state
error: null,
count: 0,
name: '',
items: [],
config: {}
});Providing explicit initial values makes your state predictable and self-documenting.
Initializing From an API Response
// Start with defaults, then fill in with data
const userState = state({
isLoading: true,
name: '',
email: '',
avatar: null
});
// Fetch real data
async function loadUser(userId) {
userState.isLoading = true;
try {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
userState.name = user.name;
userState.email = user.email;
userState.avatar = user.avatar;
} catch (err) {
console.error('Failed to load user:', err);
} finally {
userState.isLoading = false;
}
}Initializing From LocalStorage
// Load saved preferences or use defaults
const savedTheme = localStorage.getItem('theme') || 'light';
const savedLanguage = localStorage.getItem('language') || 'en';
const preferences = state({
theme: savedTheme,
language: savedLanguage,
fontSize: 16
});
// Save when preferences change
effect(() => {
localStorage.setItem('theme', preferences.theme);
localStorage.setItem('language', preferences.language);
});Common Pitfalls
Pitfall 1: Mutating Nested Arrays With Direct Methods
const app = state({ items: [] });
// This may NOT trigger reactive updates (depends on array patching):
app.items.push('new item'); // ⚠️ Might not be tracked
// This IS always tracked — reassign the array:
app.items = [...app.items, 'new item']; // ✅ Always worksNote: The reactive array patch module (
02_dh-reactive-array-patch.js) adds reactivity to array mutation methods. If it's loaded,push,pop,splice, etc. will work reactively. If not, reassigning the array is the safe approach.
Pitfall 2: Replacing State Entirely
const user = state({ name: 'Alice', age: 25 });
// ❌ This breaks the reactive connection
user = state({ name: 'Bob', age: 30 }); // Can't reassign const
// ❌ This replaces the object and may lose tracking
// (Avoid reassigning the state variable itself)
// ✅ Update properties instead
user.name = 'Bob';
user.age = 30;
// ✅ Or use set() for multiple properties
set(user, { name: 'Bob', age: 30 });Pitfall 3: Reading State Outside an Effect
const app = state({ count: 0 });
// ❌ Reading outside an effect — not reactive
const count = app.count; // Snapshot at this moment
// `count` will always be 0, even if app.count changes
// ✅ Reading inside an effect — reactive
effect(() => {
const count = app.count; // Re-reads on every run
console.log(count);
});Summary
state()is the foundation of the reactive system — it creates a reactive object from a plain JavaScript object- Use it like a normal object — dot notation for reading and writing
- Deep reactivity works automatically — nested objects are reactive too
- Some built-in types (Date, Map, Set, DOM elements) are stored as-is, but the properties holding them remain reactive
- Use
getRaw()when you need the unwrapped, plain object (e.g., for JSON serialization) - Use
isReactive()to check if something is reactive - Always initialize with explicit default values —
'',null,false,0,[],{} - Update properties directly (
app.count = 5) or useset()for functional updates
The rule: Create state once, update properties directly, and let effects handle the rest.
What's Next?
Now that you know how to create reactive state, let's look at all the ways to update it — from direct assignment to functional updates and batching multiple changes together.
Continue to: 03 — Updating State