Skip to main content
SprinculModel is the base class you extend to create reactive components. Every model you write inherits the $el property, the reactive state proxy, the two lifecycle hooks, and the addComputedProp helper. You never call new SprinculModel() yourself — Sprincul does that when it processes each data-model element during Sprincul.init().

Properties

$el

The DOM element that this model instance is bound to — the element carrying the data-model attribute. All bindings and event listeners managed by this model are scoped to descendants of $el. Type: HTMLElement
import { SprinculModel } from 'sprincul';

class Banner extends SprinculModel {
  afterInit() {
    console.log(this.$el); // <div data-model="Banner">
    this.$el.classList.add('ready');
  }
}

state

A reactive proxy over the model’s internal nanostores MapStore. Read and write state as ordinary object properties. Every assignment to a state property triggers all data-bind-<prop> callbacks for that property and re-evaluates any computed properties that list it as a dependency. Type: Record<string, any> State property names must be lowercase. HTML lower-cases data-* attribute names when building the DOM, so a binding written as data-bind-myProp becomes data-bind-myprop — and Sprincul reads that lowercase attribute name to match against state keys. If you use mixed-case state keys your bindings will silently never fire.
import { SprinculModel } from 'sprincul';

interface CounterState {
  count: number;
}

class Counter extends SprinculModel {
  state!: CounterState;

  beforeInit() {
    this.state.count = 0;  // initialize
  }

  increment() {
    this.state.count++;    // triggers showCount binding
  }
}
Always mutate this.state.<prop>, not this.<prop>. Writing to this.count instead of this.state.count bypasses the reactive proxy entirely — bindings will not fire.

Lifecycle hooks

Sprincul calls lifecycle hooks on each model instance during Sprincul.init(). You override them in your subclass; both are optional.

beforeInit()

Called synchronously before bindings are attached to the DOM. This is the right place to initialize state values and register computed properties with addComputedProp(). Because bindings haven’t been wired yet, you can freely set initial state without triggering renders. Return type: void | Promise<void> If you return a Promise, Sprincul logs any rejection but does not await it before wiring bindings. The async portion of beforeInit may finish after bindings are already active. For reliable async work, use afterInit() instead.
import { SprinculModel } from 'sprincul';

class Totals extends SprinculModel {
  beforeInit() {
    this.state.price = 10;
    this.state.qty = 2;
    this.addComputedProp(
      'total',
      () => this.state.price * this.state.qty,
      ['price', 'qty']
    );
  }
}
// JavaScript
import { SprinculModel } from 'sprincul';

export default class Totals extends SprinculModel {
  beforeInit() {
    this.state.price = 10;
    this.state.qty = 2;
    this.addComputedProp(
      'total',
      () => this.state.price * this.state.qty,
      ['price', 'qty']
    );
  }
}

afterInit()

Called after bindings and event listeners are active. This hook can be safely async. Use it for API calls, connecting to external stores, or any work that should happen once the component is fully wired. Return type: void | Promise<void> When afterInit completes (including any async work), Sprincul removes the data-cloaked attribute from the model’s root element if one is present. This makes afterInit the right hook to drive model-level uncloaking.
import { SprinculModel } from 'sprincul';

class Profile extends SprinculModel {
  beforeInit() {
    this.state.name = '--';
    this.state.email = '--';
  }

  showName(el: HTMLElement) {
    el.textContent = this.state.name;
  }

  showEmail(el: HTMLElement) {
    el.textContent = this.state.email;
  }

  async afterInit() {
    const { name, email } = await fetch('/api/me').then(r => r.json());
    this.state.name = name;
    this.state.email = email;
  }
}
// JavaScript
import { SprinculModel } from 'sprincul';

export default class Profile extends SprinculModel {
  beforeInit() {
    this.state.name = '--';
    this.state.email = '--';
  }

  /** @param {HTMLElement} el */
  showName(el) {
    el.textContent = this.state.name;
  }

  /** @param {HTMLElement} el */
  showEmail(el) {
    el.textContent = this.state.email;
  }

  async afterInit() {
    const { name, email } = await fetch('/api/me').then(r => r.json());
    this.state.name = name;
    this.state.email = email;
  }
}
For models with heavy initialization — API calls, data processing — use data-cloaked on the model root. Sprincul removes it after afterInit resolves, so users never see an incomplete UI.

Methods

addComputedProp(name, fn, dependencies)

Registers a derived state value that is recomputed whenever any of its listed dependencies change. The computed value is accessible on this.state.<name> just like any other state property, and any data-bind-<name> elements are re-rendered when the value changes. Call addComputedProp from inside beforeInit(). Calling it before the internal core is ready will throw.
name
string
required
The state key under which the computed value is stored. Must be lowercase.
fn
() => any
required
A function that returns the computed value. Called once immediately to seed the initial value, then again whenever a dependency changes.
dependencies
string[]
required
An array of state property names that this computed property depends on. Sprincul re-runs fn and schedules a DOM update whenever any listed key changes. If you pass an empty array, the value is set once but bound elements will not re-render when it changes — Sprincul logs a warning in this case.
Returns: () => void — An unsubscribe function. You can call it to manually remove the computed property listener. Sprincul also cleans it up automatically when the model root is removed from the DOM.
import { SprinculModel } from 'sprincul';

class Totals extends SprinculModel {
  beforeInit() {
    this.state.price = 10;
    this.state.qty = 2;

    // Recomputes whenever price or qty changes
    this.addComputedProp(
      'total',
      () => this.state.price * this.state.qty,
      ['price', 'qty']
    );
  }

  /** @param {HTMLInputElement} e */
  setPrice(e: InputEvent) {
    this.state.price = Number((e.target as HTMLInputElement).value || 0);
  }

  /** @param {HTMLInputElement} e */
  setQty(e: InputEvent) {
    this.state.qty = Number((e.target as HTMLInputElement).value || 0);
  }

  showTotal(el: HTMLElement) {
    el.textContent = String(this.state.total);
  }
}
// JavaScript
import { SprinculModel } from 'sprincul';

export default class Totals extends SprinculModel {
  beforeInit() {
    this.state.price = 10;
    this.state.qty = 2;

    this.addComputedProp(
      'total',
      () => this.state.price * this.state.qty,
      ['price', 'qty']
    );
  }

  /** @param {InputEvent} e */
  setPrice(e) {
    this.state.price = Number(e.target.value || 0);
  }

  /** @param {InputEvent} e */
  setQty(e) {
    this.state.qty = Number(e.target.value || 0);
  }

  /** @param {HTMLElement} el */
  showTotal(el) {
    el.textContent = String(this.state.total);
  }
}
The matching HTML for the example above:
<div data-model="Totals">
  <label for="price">Price</label>
  <input id="price" type="number" value="10" oninput="setPrice" />
  <label for="qty">Quantity</label>
  <input id="qty" type="number" value="2" oninput="setQty" />
  <p>Total: <span data-bind-total="showTotal"></span></p>
</div>
Computed property values and their dependency updates are batched with requestAnimationFrame, the same as regular state changes. Multiple updates in a single frame coalesce into one render pass.