Skip to content

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.

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');
});
EventHandler receivesWhen
field:changeFieldEventDetailAny field value changes
field:validFieldEventDetailA field becomes valid
field:invalidFieldEventDetailA field becomes invalid
field:addedFieldEventDetailA field is added to the DOM (mutation observer)
field:removedFieldEventDetailA field is removed from the DOM
form:submitFormEventDetailForm submit triggered (before validation; isSubmitting is already true)
form:loadingFormEventDetailLoading state changed (state.isSubmitting toggled)
form:validFormEventDetailForm passes validation
form:invalidFormEventDetailForm 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 |

interface FieldEventDetail {
formId: string;
fieldName: string;
state: FieldState;
}
interface FormEventDetail {
formId: string;
state: FormState;
}

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(', ');
});
EventHandler receivesWhen
changeFieldStateField value or validity changes
validFieldStateField becomes valid
invalidFieldStateField becomes invalid

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`);
});

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.

Live Preview
<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`;
}
});

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&apos;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;
}
});

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.