GitHub

Actions

Actions handle async operations with an enforced lifecycle. The order of steps — capture inputs, validate, run, succeed or fail — is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.

The action lifecycle

When a form with data-action is submitted, Pulse runs the action through a fixed sequence of steps:

onStart(state, formData)
  ↓  (optional) validate — checks spec.validation rules
run(state, serverState, formData)
  ↓  success           ↓  error
onSuccess(state, payload)   onError(state, err)

Each step triggers a view re-render, so the UI always reflects the current state — loading, validated, succeeded, or failed. The sequence cannot be reordered.

Defining an action

export default {
  route: '/contact',
  state: {
    status: 'idle',   // 'idle' | 'loading' | 'success' | 'error'
    errors: [],
  },
  validation: {
    'fields.name':  { required: true, minLength: 2 },
    'fields.email': { required: true, format: 'email' },
    'fields.message': { required: true, minLength: 10 },
  },
  actions: {
    submit: {
      // 1. Immediately update state — show loading indicator
      onStart: (state, formData) => ({
        status: 'loading',
        errors: [],
        // Capture form values into state before validation runs
        fields: {
          name:    formData.get('name'),
          email:   formData.get('email'),
          message: formData.get('message'),
        },
      }),

      // 2. Run spec.validation before proceeding to run()
      validate: true,

      // 3. Perform the async work
      run: async (state, serverState, formData) => {
        const res = await fetch('/api/contact', {
          method:  'POST',
          headers: { 'Content-Type': 'application/json' },
          body:    JSON.stringify(Object.fromEntries(formData)),
        })
        if (!res.ok) throw new Error('Request failed')
        return res.json()
      },

      // 4a. Success
      onSuccess: (state, payload) => ({
        status: 'success',
        errors: [],
      }),

      // 4b. Error — payload may have validation errors
      onError: (state, err) => ({
        status: 'error',
        errors: err?.validation ?? [{ message: err.message }],
      }),
    },
  },
}

Binding actions to forms

A data-action attribute on a <form> element binds it to an action. When the form is submitted, Pulse creates a FormData object from the form's inputs and passes it through the action lifecycle:

<"tok-fn">class="tok-kw">form "tok-fn">data-action="submit">
  <"tok-fn">class="tok-kw">input "tok-fn">name="name"    "tok-fn">type="text"  "tok-fn">placeholder="Your name">
  <"tok-fn">class="tok-kw">input "tok-fn">name="email"   "tok-fn">type="email" "tok-fn">placeholder="Email">
  <"tok-fn">class="tok-kw">textarea "tok-fn">name="message" "tok-fn">placeholder="Message"></"tok-fn">class="tok-kw">textarea>
  <"tok-fn">class="tok-kw">button "tok-fn">type="submit">Send</"tok-fn">class="tok-kw">button>
</"tok-fn">class="tok-kw">form>
Pulse intercepts and prevents the default form submission. The action lifecycle is fully in control of what happens with the data — no manual event.preventDefault() needed.

onStart

onStart(state, formData) runs synchronously as soon as the form is submitted, before any async work begins. It sets a loading state, captures form values into state so validation can check them, and clears previous errors.

onStart runs before validation. FormData values are captured into state first, because the HTML re-renders (destroying the form inputs) once validation runs. All form values are captured here so validation can read them from state via dot-paths.

validate

Set validate: true to run the spec's validation rules after onStart. If validation fails, onError is called immediately — run is never reached. Async work cannot execute against invalid input.

// Validation error structure
{
  message:    'Validation failed',
  validation: [
    { field: 'fields.email', rule: 'format', message: 'Must be a valid email' },
    { field: 'fields.name',  rule: 'required', message: 'Required' },
  ]
}

Access the errors in onError:

onError: (state, err) => ({
  status: 'error',
  errors: err?.validation ?? [{ message: err.message }],
})

run

run(state, serverState, formData) is where the async work happens. Throw or reject to trigger onError. The return value is passed to onSuccess as payload.

run: async (state, serverState, formData) => {
  const res = await fetch('/api/submit', {
    method: 'POST',
    body:   formData,
  })
  if (!res.ok) {
    const err = await res.json()
    throw Object.assign(new Error('Server error'), err)
  }
  return res.json()   // → onSuccess payload
},

onSuccess

onSuccess(state, payload) receives the current state and whatever run returned. Return a partial state update:

onSuccess: (state, payload) => ({
  status: 'success',
  userId: payload.id,
})

onError

onError(state, err) receives the current state and the thrown error. Return a partial state update to surface the error in the view:

onError: (state, err) => ({
  status: 'error',
  errors: err?.validation ?? [{ message: err.message }],
})

Toast notifications

Return _toast from any action hook to show a notification. It is stripped from spec state automatically — it never appears in getState() or the view.

onSuccess: (state, payload) => ({
  status:  'success',
  _toast:  { message: 'Saved successfully', variant: 'success' },
}),

onError: (state, err) => ({
  status:  'error',
  errors:  err?.validation ?? [{ message: err.message }],
  _toast:  { message: 'Something went wrong', variant: 'error' },
}),

_toast works in onStart, onSuccess, and onError, and also in mutations. The toast container is injected into document.body once and survives client-side navigations.

OptionTypeDefault
messagestringRequired. The notification text.
variantsuccess | error | warning | infoinfo
durationnumber (ms)4000Auto-dismiss delay. 0 = sticky until dismissed.

Pushing to the global store

Return _storeUpdate from onSuccess to push a partial update into the global store. Every mounted page that subscribes to the updated keys re-renders immediately — no navigation, no polling.

onSuccess: (state, theme) => ({
  saved:        true,
  _storeUpdate: { settings: { theme } },   // ← merged into global store state
}),

_storeUpdate is stripped from the page's own state — only the rest of the return object is merged into local state as usual. See Global Store for the full store API.

Rendering errors in the view

view: (state) => `
  <form data-action="submit">
    ${state.errors.map(e => `
      <p class="error">
        ${e.field ? `<strong>${e.field}:</strong> ` : ''}${e.message}
      </p>
    `).join('')}
    <!-- ... form fields ... -->
    <button ${state.status === 'loading' ? 'disabled' : ''}>
      ${state.status === 'loading' ? 'Sending…' : 'Send'}
    </button>
  </form>
`