GitHub

Constraints

Constraints are always-on bounds for numeric state values. After every mutation, Pulse clamps constrained values to their declared range before the view re-renders. The value can never go out of range — there is no code path where it can.

Declaring constraints

The constraints field maps top-level state keys to bounds objects with optional min and max properties:

export default {
  route: '/cart',
  state: {
    quantity: 1,
    zoom:     1.0,
    rating:   0,
  },
  constraints: {
    quantity: { min: 1,   max: 99  },
    zoom:     { min: 0.5, max: 3.0 },
    rating:   { min: 0,   max: 5   },
  },
  mutations: {
    increaseQty: (state) => ({ quantity: state.quantity + 1 }),
    decreaseQty: (state) => ({ quantity: state.quantity - 1 }),
    zoomIn:      (state) => ({ zoom: state.zoom + 0.1 }),
    zoomOut:     (state) => ({ zoom: state.zoom - 0.1 }),
  },
}

When decreaseQty runs and quantity is already 1, the constraint clamps it back to 1 before the view renders. The mutation does not need to check bounds — the spec declares them once and Pulse enforces them everywhere.

Constraints vs Validation

ConstraintsValidation
When it runsAfter every mutation, automaticallyOnly when an action has validate: true
What it doesClamps numeric values silentlyRejects the action and surfaces errors
User feedbackNone — state is silently correctedExplicit error messages shown in the view
Best forNumeric ranges that must never be exceededForm field correctness before submission
Constraints and validation serve different purposes. Constraints silently enforce numeric bounds at every mutation — they cannot be bypassed. Validation rejects invalid form data before an action's async work begins — it only runs when explicitly declared.

One-sided bounds

Either min or max can be declared alone — both are optional:

constraints: {
  count:    { min: 0 },          // no upper limit
  discount: { max: 100 },        // no lower limit
  offset:   { min: 0, max: 999 } // both bounds
}

How clamping works

After Pulse applies a mutation's partial state update, it iterates over all declared constraints and applies Math.max(min, Math.min(max, value)) to each constrained key. The view is then called with the clamped state.

// state.count = 10, constraints.count = { min: 0, max: 10 }
mutations: {
  increment: (state) => ({ count: state.count + 1 }),
  // mutation returns { count: 11 }
  // constraint clamps to 10
  // view receives { count: 10 }
}

Top-level keys only

Constraints apply to top-level state keys. To constrain nested values, consider flattening your state structure or applying bounds logic in the mutation itself:

// Cannot do:
constraints: {
  'player.health': { min: 0, max: 100 }   // ✗ nested paths not supported
}

// Do instead:
state: { playerHealth: 100 },
constraints: { playerHealth: { min: 0, max: 100 } },

// Or handle in the mutation:
mutations: {
  takeDamage: (state, _, amount) => ({
    player: {
      ...state.player,
      health: Math.max(0, state.player.health - amount),
    }
  })
}