State (Store)

State provides deep reactivity for objects and arrays. Unlike signals which only track reassignment, state tracks nested property changes automatically.

The Shortcomings of Signals

// Signals only react to reassignment
const user = signal({ name: 'Alice', age: 25 });

user.value.age = 26;           // ❌ Won't trigger updates!
user.value = { ...user.value, age: 26 };  // ✅ Works, but verbose

// Arrays have the same issue
const items = signal([1, 2, 3]);
items.value.push(4);           // ❌ Won't trigger updates!
items.value = [...items.value, 4];  // ✅ Works, but tedious

State to the Rescue

const { state } = LightviewX;

// Deep reactivity - mutations work!
const user = state({ name: 'Alice', age: 25 });

user.age = 26;                 // ✅ Triggers updates!
user.name = 'Bob';             // ✅ Triggers updates!

// Arrays just work
const items = state([1, 2, 3]);
items.push(4);                 // ✅ Triggers updates!
items[0] = 10;                 // ✅ Triggers updates!
items.sort();                  // ✅ Triggers updates!

State Function

The state function is the primary initializer for reactive stores.

state(initialValue, nameOrOptions?)

Parameters

See the examples in the sections below for detailed usage of these parameters.

Nested Objects

State tracks changes at any depth:

const app = state({
    user: {
        profile: {
            name: 'Alice',
            settings: {
                theme: 'dark',
                notifications: true
            }
        }
    },
    items: []
});

// All of these trigger updates:
app.user.profile.name = 'Bob';
app.user.profile.settings.theme = 'light';
app.items.push({ id: 1, text: 'Hello' });

In the UI

const { state } = LightviewX;
const { tags, $ } = Lightview;
const { div, ul, li, input, span, button } = tags;

const todos = state([
    { text: 'Learn Lightview', done: true },
    { text: 'Build app', done: false }
]);

const app = div({ style: 'padding: 1rem;' },
    ul({ style: 'list-style: none; padding: 0; margin-bottom: 1rem;' }, 
        () => todos.map((todo, i) => 
            li({ style: 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;' },
                input({ 
                    type: 'checkbox', 
                    checked: todo.done,
                    onchange: () => todos[i].done = !todos[i].done,
                    style: 'cursor: pointer;'
                }),
                span({ 
                    style: () => todo.done ? 'text-decoration: line-through; opacity: 0.6;' : '' 
                }, todo.text)
            )
        )
    ),
    button({ 
        onclick: () => todos.push({ text: `Task ${todos.length + 1}`, done: false }),
    }, '+ Add Task')
);

$('#example').content(app);

Array Methods

All mutating array methods are reactive:

const items = state([1, 2, 3]);

items.push(4);         // Add to end
items.pop();           // Remove from end
items.shift();         // Remove from start
items.unshift(0);      // Add to start
items.splice(1, 1);    // Remove at index
items.sort();          // Sort in place
items.reverse();       // Reverse in place
items.fill(0);         // Fill with value

Date Methods

When a property is a Date object, all mutating methods are reactive. This includes all set* methods (e.g. setFullYear(), setDate(), setHours(), etc.):

const event = state({ date: new Date() });
 
 // This will trigger UI updates
 event.date.setFullYear(2025);

Named State

Like signals, you can name state objects for global access. This is especially useful for shared application state:

// Create named state
const appState = state({ 
    user: 'Guest', 
    theme: 'dark' 
}, 'app');

// Retrieve it anywhere
const globalState = state.get('app');

// Get or create
const settings = state.get('settings', { notifications: true });

Stored State

You can store named state objects in Storage objects (e.g. sessionStorage or localStorage) for persistence. It will be saved any time there is a change. Objects are automatically serialized to JSON and deserialized back to objects.

const user = state({name:'Guest', theme:'dark'}, {name:'user', storage:sessionStorage});

// Retrieve it elsewhere (even in another file)
const sameUser = state.get('user');

// Get or create with default value
// If 'user' exists, returns it. If not, creates it with default value.
const score = state.get('user', {storage:sessionStorage, defaultValue:{name:'Guest', theme:'dark'}});

Note: Manually updating the object in storage will not trigger updates.

Schema Validation

State objects can be validated and transformed using the schema option. This ensures data integrity and can automatically coerce values to the expected types.

const user = state({ name: 'Alice', age: 25 }, { schema: 'auto' });

user.age = 26;          // ✅ Works (same type)
user.age = '30';        // ❌ Throws: Type mismatch
user.status = 'active'; // ❌ Throws: Cannot add new property

Built-in Schema Behaviors

Behavior Description
"auto" Infers a fixed schema from the initial value. Prevents adding new properties and enforces strict type checking.
"dynamic" Like auto, but allows the state object to grow with new properties.
"polymorphic" Allows growth and automatically coerces values to match the initial type (e.g., setting "100" to a number property saves it as the number 100).
// Polymorphic coerces types automatically
const settings = state({ volume: 50, muted: false }, { schema: 'polymorphic' });

settings.volume = '75';   // ✅ Coerced to number 75
settings.muted = 'true';   // ✅ Coerced to boolean true
settings.newProp = 'ok';  // ✅ Allowed (dynamic growth)

JSON Schema Lite

When you load lightview-x.js, the schema engine is upgraded to a "JSON Schema Lite" validator. It supports standard Draft 7 keywords while remaining incredibly lightweight.

Supported Keywords

Type Keywords
String minLength, maxLength, pattern, format: 'email'
Number minimum, maximum, multipleOf
Object required, properties, additionalProperties: false
Array items, minItems, maxItems, uniqueItems
General type, enum, const

Named Schemas

You can register schemas globally and reference them by name. This encourages reuse and keeps your state initializations clean.

// 1. Register a schema
Lightview.registerSchema('User', {
    type: "object",
    properties: {
        username: { type: "string", minLength: 3 },
        email: { type: "string", format: "email" }
    },
    required: ["username"]
});

// 2. Use it by name
const user = state({ username: "alice" }, { schema: "User" });

Transformation Helpers

Unlike industry-standard validators (which only allow/disallow data), Lightview's engine supports declarative transformations. This allows you to specify how data should be "cleaned" before it hits the state.

const UserSchema = {
    type: "object",
    properties: {
        username: { 
            type: "string", 
            transform: "lower"  // Using a registered helper name
        },
        salary: { 
            type: "number",
            transform: (v) => Math.round(v) // Using an inline function
        }
    }
};

const user = state({ username: 'ALICE', salary: 50212.55 }, { schema: UserSchema });
// user.username is 'alice' (via "lower")
// user.salary is 50213 (via inline function)

When using a string for the transform (like "lower"), Lightview looks for a matching function in the global registry. This requires that the helper has been registered via JPRX (requires lightview-cdom.js) or manually in Lightview.helpers. For more details on built-in helpers, see the cDOM documentation.

🔍 Naming Tip: When using string names for transforms, use the bare helper name (e.g., "lower", "round"). You do not need a leading $.

Loading Helpers

When using the transform keyword, Lightview searches for the named function in the following order:

  1. JPRX Helpers: Any helper registered via registerHelper (requires lightview-cdom.js).
  2. Public API: Functions attached to Lightview.helpers.
  3. Inline: You can also pass a function directly to the transform property.
💡 Note: To use JPRX helpers (like math, string, or array helpers) for state transformations, you must ensure lightview-cdom.js is loaded before your state is initialized.

Lite vs. Full Validators

Why use our "Lite" engine instead of a full validator like Ajv?

Feature Lightview Lite Full (Ajv, etc.)
Size ~2KB (built-in) ~40KB+ (external)
Transformation ✅ First-class support ❌ Not supported (Validators only)
Performance 🚀 Optimized for UI cycles Heavy overhead for simple checks
Spec Compliance Basic Draft 7 (No $ref) 100% Full Specification

Using a Full Validator

If you have complex enterprise schemas that require full specification compliance, you can swap out the Lightview engine for any industry-standard validator:

⚠️ Warning: If you swap the validator, you will lose support for the declarative transform keyword in your schemas unless your external validator also supports it.
import Ajv from "ajv";
const ajv = new Ajv();

// Swap the validation hook
Lightview.internals.hooks.validate = (value, schema) => {
    const valid = ajv.validate(schema, value);
    if (!valid) throw new Error(ajv.errorsText());
    return true;
};