GitHub

State

Pulse enforces a strict one-way data flow. State is declared once in the spec, deep-cloned on mount, and changed only through mutations. Direct mutation is not possible — the framework prevents it by design.

Declaring state

The state field of a spec is the initial value. Always a plain object — nested objects and arrays are supported:

export default {
  route: '/checkout',
  state: {
    step: 1,
    customer: {
      name:  '',
      email: '',
    },
    items:    [],
    promoCode: null,
  },
  // ...
}
state: {} is always included, even on pages with no interactivity. The spec schema requires it.

The view receives state

The view function is called with the current state as its first argument. On first render this is the initial state (or state restored from localStorage if persist is set). After mutations it is the updated state:

view: (state) => `
  <div>
    <p>Step ${state.step} of 3</p>
    <p>Hello, ${state.customer.name || 'guest'}</p>
    <ul>
      ${state.items.map(item => `<li>${item.name}</li>`).join('')}
    </ul>
  </div>
`

Immutability

State is never mutated directly. Mutations are pure functions that return a partial object to merge — the framework rejects any other pattern:

// CORRECT — return a partial update
mutations: {
  nextStep: (state) => ({ step: state.step + 1 }),
}

// WRONG — never mutate state directly
mutations: {
  nextStep: (state) => { state.step++ },   // ✗ do not do this
}

The runtime performs a shallow merge of the returned partial into the current state. This means top-level keys are replaced, not deep-merged:

// state = { step: 1, customer: { name: 'Alice', email: 'a@b.com' } }

mutations: {
  // Only updates step — customer is untouched
  nextStep: (state) => ({ step: state.step + 1 }),

  // Replaces the entire customer object — spread to preserve email
  setName: (state, e) => ({
    customer: { ...state.customer, name: e.target.value }
  }),
}

Deep clone on mount

When the page mounts, Pulse deep-clones spec.state. This guarantees:

  • The live state and the spec's initial state are completely independent — mutations cannot corrupt the spec.
  • Navigating away and back resets state to the spec's initial values (unless persisted).
  • Multiple instances of the same spec on the same page each get independent state.

State vs server state

Pulse draws a hard boundary between client state (the state field) and server state (from server.data()). Client state lives in the browser and changes in response to mutations. Server state is resolved before render and passed to the view as its second argument — it is never exposed to the client after hydration:

view: (state, server) => `
  <div>
    <h1>${server.product.name}</h1>      <!-- server state -->
    <p>Qty: ${state.quantity}</p>         <!-- client state -->
  </div>
`
Server state is read-only and not available on the client after hydration. Anything that needs client interactivity belongs in state.

State during SSR

On the server, the view is rendered with the spec's initial state. After hydration, mount() is called with { ssr: true }, which skips the first client-side re-render and preserves the SSR-painted HTML exactly as the server sent it. This is what enables fast LCP — the initial HTML arrives from the server and the JS binds events without touching the DOM.

State keys listed in the persist field are saved to localStorage and restored before the view renders on the next visit.