Events & Hooks
FormLayer provides a three-layer event system: registry events for form lifecycle, form events for field and form state changes, and field events for individual field changes.
Form Events
Section titled “Form Events”Subscribe to events on a form controller via on(), once(), and off():
import { formRegistry } from 'formlayer';
const form = formRegistry.get('contact');
form.on('field:change', (detail) => { console.log(`Field "${detail.fieldName}" changed to "${detail.state.value}"`);});
form.on('form:submit', (detail) => { console.log('Form submitted, current state:', detail.state);});
form.once('form:valid', (detail) => { console.log('Form valid for the first time');});Available Form Events
Section titled “Available Form Events”| Event | Handler receives | When |
|---|---|---|
field:change | FieldEventDetail | Any field value changes |
field:valid | FieldEventDetail | A field becomes valid |
field:invalid | FieldEventDetail | A field becomes invalid |
field:added | FieldEventDetail | A field is added to the DOM (mutation observer) |
field:removed | FieldEventDetail | A field is removed from the DOM |
form:submit | FormEventDetail | Form submit triggered (before validation; isSubmitting is already true) |
form:loading | FormEventDetail | Loading state changed (state.isSubmitting toggled) |
form:valid | FormEventDetail | Form passes validation |
form:invalid | FormEventDetail | Form fails validation |
For custom form-level error summaries (banners, toasts), listen to form:invalid or use onFormInvalid in FormControllerOptions. See Error Rendering.
| form:reset | FormEventDetail | Form is reset |
Event Detail Types
Section titled “Event Detail Types”interface FieldEventDetail { formId: string; fieldName: string; state: FieldState;}
interface FormEventDetail { formId: string; state: FormState;}Field Events
Section titled “Field Events”Individual fields have their own event system with on(), once(), and off():
import { initField } from 'formlayer';
const ctrl = initField(document.getElementById('email-field')!);
ctrl.on('change', (state) => { console.log('New value:', state.value);});
ctrl.on('valid', (state) => { document.getElementById('email-hint')!.textContent = 'Looks good!';});
ctrl.on('invalid', (state) => { document.getElementById('email-hint')!.textContent = state.errors.join(', ');});Available Field Events
Section titled “Available Field Events”| Event | Handler receives | When |
|---|---|---|
change | FieldState | Field value or validity changes |
valid | FieldState | Field becomes valid |
invalid | FieldState | Field becomes invalid |
Registry Events
Section titled “Registry Events”React to forms being registered or unregistered:
import { formRegistry } from 'formlayer';
formRegistry.on('form:registered', ({ formId }) => { console.log(`Form "${formId}" registered`); const api = formRegistry.get(formId);});
formRegistry.on('form:unregistered', ({ formId }) => { console.log(`Form "${formId}" destroyed`);});Interactive Demo
Section titled “Interactive Demo”This demo combines a character counter with dependent field visibility. Open the event log below to see events fire in real time as you interact with the forms.
Example: Live Character Counter
Section titled “Example: Live Character Counter”<form id="post-form"> <div data-form-field="body" data-validate='[{"type":"StringLength","options":{"maximum":280,"message":"Max 280 characters"}}]'> <label for="body">Post</label> <textarea id="body" name="body"></textarea> <div id="body-errors" class="invalid-feedback"></div> <span id="char-count">0 / 280</span> </div> <button type="submit">Post</button></form>const form = formRegistry.get('post-form');
form.on('field:change', (detail) => { if (detail.fieldName === 'body') { const count = detail.state.value.length; document.getElementById('char-count')!.textContent = `${count} / 280`; }});Example: Dependent Field Visibility
Section titled “Example: Dependent Field Visibility”Show a field only when another field has a specific value — without the client-variants plugin, using plain events:
<form id="survey"> <div data-form-field="hasPet" data-validate='[{"type":"NotEmpty"}]'> <label>Do you have a pet?</label> <select id="hasPet" name="hasPet"> <option value="">Select...</option> <option value="yes">Yes</option> <option value="no">No</option> </select> </div>
<div data-form-field="petName" id="pet-name-field" hidden data-validate='[{"type":"NotEmpty","options":{"message":"What is your pet's name?"}}]'> <label for="petName">Pet Name</label> <input id="petName" name="petName" /> <div id="petName-errors" class="invalid-feedback"></div> </div>
<button type="submit">Submit</button></form>const form = formRegistry.get('survey');
form.on('field:change', (detail) => { if (detail.fieldName === 'hasPet') { const showPet = detail.state.value === 'yes'; const petField = document.getElementById('pet-name-field')!; petField.hidden = !showPet; }});Example: Submit Button Loading State
Section titled “Example: Submit Button Loading State”FormLayer toggles data-loading on the submit button by default during submission. Style it with CSS:
button[data-loading] { opacity: 0.6; pointer-events: none;}For custom behavior, use the form:loading event or onLoadingStateChange:
const form = formRegistry.get('contact');
form.on('form:loading', (detail) => { const btn = detail.formEl.querySelector('button[type="submit"]') as HTMLButtonElement; btn.textContent = detail.state.isSubmitting ? 'Sending…' : 'Send';});Or pass options when registering:
formRegistry.register(formEl, submitFn, { loadingState: false, // disable default data-loading onLoadingStateChange({ isSubmitting, submitter }) { submitter?.classList.toggle('is-busy', isSubmitting); },});See Loading State for full details.