The events API provides two complementary services for working with form events. Which one you use depends on where your code lives relative to the dynamic-form element.
Choosing the right API
EventBus |
EventDispatcher |
|
|---|---|---|
| Where to use | Inside DynamicForm (field components) | Outside DynamicForm (host components) |
| How to get it | inject(EventBus) |
providers: [EventDispatcher] + inject(EventDispatcher) |
| Dispatches | Constructor + args | Pre-built event instances |
| Subscribes | Yes, via .on() |
No |
Important:
EventBusis scoped to theDynamicFormcomponent's injector tree. It is only available to components rendered inside DynamicForm (i.e. custom field components). InjectingEventBusin a parent or host component fails with aNullInjectorError(there is no provider at that level); providing your ownEventBusonly yields a disconnected instance the form knows nothing about. UseEventDispatcherfrom the host instead.
EventDispatcher: dispatching from outside DynamicForm
Use EventDispatcher when you need to drive form behaviour from the host component: for example, appending array items in response to a field value change, triggering a form reset from a toolbar button, or reacting to external application state.
Setup
Provide EventDispatcher at the host component level (not root). DynamicForm automatically detects it and connects its internal event bus.
import { Component, effect, inject, signal } from '@angular/core';
import { DynamicForm, EventDispatcher, FormConfig, arrayEvent } from '@ng-forge/dynamic-forms';
@Component({
providers: [EventDispatcher], // Provide at host component level
imports: [DynamicForm],
template: `<form [dynamic-form]="config" [(value)]="formValue"></form>`,
})
export class MyFormComponent {
protected readonly config = { fields: [...] } as const satisfies FormConfig;
readonly formValue = signal<Record<string, unknown>>({});
private readonly dispatcher = inject(EventDispatcher);
constructor() {
effect(() => {
const category = this.formValue()?.['category'] as string | undefined;
if (category) {
this.dispatcher.dispatch(
arrayEvent('tasks').append([{ key: 'name', type: 'input', label: 'Task', value: category }])
);
}
});
}
}Dispatching events
EventDispatcher.dispatch() accepts any FormEvent instance. Use the arrayEvent() builder for array operations:
// Array manipulation
this.dispatcher.dispatch(arrayEvent('contacts').append(contactTemplate));
this.dispatcher.dispatch(arrayEvent('contacts').removeAt(0));
this.dispatcher.dispatch(arrayEvent('contacts').pop());
// Form lifecycle
this.dispatcher.dispatch(new FormResetEvent());
this.dispatcher.dispatch(new FormClearEvent());
// Custom events
this.dispatcher.dispatch(new MyCustomEvent());Multi-form note
If multiple DynamicForm instances exist under the same provider scope, all forms receive dispatched events. To target a specific form, scope the EventDispatcher provider to a wrapper component that contains only that form.
Listening to events from outside DynamicForm
For observing events from a host component, use the output bindings exposed directly on the dynamic-form element. This avoids any DI scoping concerns and works with standard Angular event binding syntax.
Output bindings
| Output | Emits |
|---|---|
(events) |
Every form event (full stream) |
(submitted) |
Form value when submitted and valid (FormSubmitEvent) |
(reset) |
When the form is reset to default values |
(cleared) |
When the form is cleared to empty state |
(onPageChange) |
PageChangeEvent on each wizard page navigation |
(onPageNavigationStateChange) |
PagerStateEvent whenever pager state changes (currentPageIndex, totalPages, isFirstPage, isLastPage) |
(activePageInitialized) |
EXPERIMENTAL. ActivePageInitializedEvent after the visible page and its fields render |
(validityChange) |
Boolean, whenever form validity changes |
(dirtyChange) |
Boolean, whenever form dirty state changes |
(initialized) |
Once a non-paged form, or the initially active page of a paged form, is ready for interaction |
Examples
React to specific well-known events via dedicated outputs:
@Component({
imports: [DynamicForm],
template: `
<form [dynamic-form]="config" (submitted)="onSubmit($event)" (reset)="onReset()" (onPageChange)="onPageChange($event)"></form>
`,
})
export class MyFormComponent {
onSubmit(value: Record<string, unknown>) {
console.log('Valid submission:', value);
}
onReset() {
console.log('Form reset to defaults');
}
onPageChange(event: PageChangeEvent) {
console.log(`Now on page ${event.currentPageIndex + 1} of ${event.totalPages}`);
}
}React to custom or less common events via (events):
@Component({
imports: [DynamicForm],
template: `<form [dynamic-form]="config" (events)="onEvent($event)"></form>`,
})
export class MyFormComponent {
onEvent(event: FormEvent) {
if (event.type === 'save-draft') {
this.saveDraft();
}
}
}Note:
(submitted)only fires when the form is valid. To handle submit events regardless of validity, use(events)and filter forevent.type === 'submit'.
EventBus: dispatching from inside DynamicForm
EventBus is the internal event bus scoped to each DynamicForm instance. Inject it inside custom field components to communicate with the parent form or other fields within the same form.
Scoping reminder:
EventBusis provided byDynamicFormvia its component injector. It is only resolvable from within field components rendered by that form. Do not inject it in host or parent components: the injection fails with aNullInjectorErrorunless you provide your own instance, and a self-provided instance is disconnected from the form. UseEventDispatcherinstead.
Usage in custom field components
import { Component, inject } from '@angular/core';
import { FormSubmitEvent } from '@ng-forge/dynamic-forms';
import { EventBus } from '@ng-forge/dynamic-forms/integration';
@Component({
selector: 'app-custom-submit-button',
template: `<button (click)="submit()">Submit Form</button>`,
})
export class CustomSubmitButton {
private readonly eventBus = inject(EventBus);
submit() {
this.eventBus.dispatch(FormSubmitEvent);
}
}Subscribing to events
export class CustomFieldComponent {
private readonly eventBus = inject(EventBus);
ngOnInit() {
this.eventBus.on<PageChangeEvent>('page-change').subscribe((event) => {
console.log(`Navigated to page ${event.currentPageIndex + 1}`);
});
}
}Built-in Events
FormSubmitEvent
Fired when form is submitted.
eventBus.on<FormSubmitEvent>('submit').subscribe(() => {
console.log('Form submitted');
});PageChangeEvent
Fired when navigating between wizard pages.
eventBus.on<PageChangeEvent>('page-change').subscribe((event) => {
console.log(`Page ${event.currentPageIndex + 1} of ${event.totalPages}`);
console.log(`Previous page: ${event.previousPageIndex}`);
});Properties:
currentPageIndex: number- Current page (0-based)totalPages: number- Total number of pagespreviousPageIndex?: number- Previous page index
PagerStateEvent
Emitted whenever the pager state of a paged form changes. Where PageChangeEvent fires only when the active page changed, this also fires when the derived flags move without the page moving, for example when isFirstPage / isLastPage flip because a page's hidden logic toggled and the set of visible pages shifted.
eventBus.on<PagerStateEvent>('pager-state').subscribe((event) => {
const { currentPageIndex, totalPages, isFirstPage, isLastPage } = event.state;
});Payload (PagerState):
currentPageIndex: number- Current page (0-based)totalPages: number- Total number of pagesisFirstPage: boolean- On the first visible pageisLastPage: boolean- On the last visible page
Bind it from a host component through the (onPageNavigationStateChange) output. This is the state to drive a custom step list or your own previous/next button enablement, since onPageChange alone would leave them stale.
NextPageEvent
Navigate to next page in wizard.
eventBus.dispatch(NextPageEvent);PreviousPageEvent
Navigate to previous page in wizard.
eventBus.dispatch(PreviousPageEvent);GoToPageEvent
Jump straight to a page by index. Useful for clickable step lists and for restoring the active page from a route parameter after a reload.
eventBus.dispatch(new GoToPageEvent(3));Properties:
pageIndex: number- Target page (0-based)options?: { validate?: boolean }- Setvalidate: falseto land on the target regardless of earlier pages, for restoring a saved session. Bounds and hidden-page checks still apply. Defaults totrue
eventBus.dispatch(new GoToPageEvent(3, { validate: false }));To open a form on a specific page in the first place, use options.initialPage rather than dispatching on load. It is applied as the form initializes, so it cannot race the orchestrator.
Validation semantics:
Backward jumps are unconditional, matching PreviousPageEvent, which has no validity gate.
Forward jumps validate every visible page the jump crosses: from the current page up to, but excluding, the target. Conditions may have changed since those pages were last visited, and ng-forge derives form state from the config, so every page's validity is computable without having visited it. If one of those pages is invalid, navigation stops on the first invalid page rather than staying put, which lands the user where work is required.
The target page itself is not validated, and hidden pages are skipped.
Like NextPageEvent, this respects the nextButton.disableWhenPageInvalid option: set it to false and forward jumps skip validation entirely.
// A 5-page form, currently on page 0. Pages 0 and 1 are valid, page 2 is invalid.
eventBus.dispatch(new GoToPageEvent(4)); // crosses 0,1,2,3 -> stops on page 2
// Now on page 2 (invalid), going back always works
eventBus.dispatch(new GoToPageEvent(0)); // lands on page 0Observing the outcome:
Dispatching is fire and forget. The event carries no result, so read the outcome from the notifications the navigation produces:
| Outcome | What fires |
|---|---|
| Jump succeeds | PageChangeEvent and PagerStateEvent for the target |
| Jump stops on a later invalid page | PageChangeEvent and PagerStateEvent for that page |
| The current page is the first invalid one | nothing, the form never moves |
| Target already active | nothing |
| Target out of bounds or hidden | nothing |
Two cases therefore produce no events at all: the current page being the one that blocks the jump, and an invalid target. Both are silent no-ops, and neither is distinguishable from "nothing happened" by listening alone.
A partial jump that does move is also not distinguishable from a successful one by the events alone. Compare the currentPageIndex you receive against the index you asked for: landing somewhere else means the jump was gated.
To detect the silent cases up front, check the target against totalPages from PagerStateEvent, and check the current page's validity before dispatching a forward jump.
FormResetEvent
Reset form to default values.
// Dispatch reset event
eventBus.dispatch(FormResetEvent);
// Listen for reset
eventBus.on<FormResetEvent>('form-reset').subscribe(() => {
console.log('Form was reset to defaults');
});FormClearEvent
Clear all form values (empty state, not defaults).
// Dispatch clear event
eventBus.dispatch(FormClearEvent);
// Listen for clear
eventBus.on<FormClearEvent>('form-clear').subscribe(() => {
console.log('Form was cleared');
});Array Events
The arrayEvent() builder provides a fluent API for array field manipulation. Import it from the main package:
import { arrayEvent } from '@ng-forge/dynamic-forms';Adding Items
Important: A template is required for all add operations. The template defines the structure of the new item:
- Single FieldDef creates a primitive item (field value extracted directly)
- Array of FieldDefs creates an object item (fields merged into object)
// Define templates
const tagTemplate = { key: 'tag', type: 'input', label: 'Tag' }; // Primitive item
const contactTemplate = [
// Object item
{ key: 'name', type: 'input', label: 'Name' },
{ key: 'email', type: 'input', label: 'Email' },
];
// Append item at end (most common)
eventBus.dispatch(arrayEvent('tags').append(tagTemplate));
eventBus.dispatch(arrayEvent('contacts').append(contactTemplate));
// Prepend item at beginning
eventBus.dispatch(arrayEvent('contacts').prepend(contactTemplate));
// Insert at specific index
eventBus.dispatch(arrayEvent('contacts').insertAt(2, contactTemplate));Removing Items
// Remove last item (stack pop)
eventBus.dispatch(arrayEvent('contacts').pop());
// Remove first item (queue shift)
eventBus.dispatch(arrayEvent('contacts').shift());
// Remove item at specific index
eventBus.dispatch(arrayEvent('contacts').removeAt(2));Reordering Items
// Move item from one index to another (atomic, no destroy/recreate)
eventBus.dispatch(arrayEvent('contacts').move(0, 2));Internal Event Classes
For advanced use cases (extending events or type-checking), you can import the underlying event classes:
import {
AppendArrayItemEvent,
PrependArrayItemEvent,
InsertArrayItemEvent,
MoveArrayItemEvent,
PopArrayItemEvent,
ShiftArrayItemEvent,
RemoveAtIndexEvent,
} from '@ng-forge/dynamic-forms';
// Example: extend for a pre-filled template
export class AddContactEvent extends AppendArrayItemEvent {
constructor() {
super('contacts', [
{ key: 'name', type: 'input', label: 'Name' },
{ key: 'phone', type: 'input', label: 'Phone' },
]);
}
}
// Usage with EventDispatcher (from outside)
dispatcher.dispatch(new AddContactEvent());
// Usage with EventBus (from inside a field component)
eventBus.dispatch(AddContactEvent);Event types:
| Event | Description |
|---|---|
AppendArrayItemEvent |
Add item at end of array |
PrependArrayItemEvent |
Add item at beginning of array |
InsertArrayItemEvent |
Add item at specific index |
MoveArrayItemEvent |
Move item from one index to another |
PopArrayItemEvent |
Remove last item |
ShiftArrayItemEvent |
Remove first item |
RemoveAtIndexEvent |
Remove item at specific index |
Multiple Event Subscriptions
Subscribe to multiple event types by passing an array of type strings:
eventBus.on<FormSubmitEvent | PageChangeEvent | NextPageEvent>(['submit', 'page-change', 'next-page']).subscribe((event) => {
switch (event.type) {
case 'submit':
handleSubmit();
break;
case 'page-change':
handlePageChange(event);
break;
case 'next-page':
handleNextPage();
break;
}
});Custom Events
Create custom events for your forms:
import { FormEvent } from '@ng-forge/dynamic-forms';
// Simple event
export class SaveDraftEvent implements FormEvent {
readonly type = 'save-draft' as const;
}
// Event with payload
export class ValidationErrorEvent implements FormEvent {
readonly type = 'validation-error' as const;
constructor(
public readonly fieldKey: string,
public readonly errorMessage: string,
) {}
}Usage inside a field component (via EventBus):
// Dispatch
eventBus.dispatch(SaveDraftEvent);
eventBus.dispatch(ValidationErrorEvent, 'email', 'Invalid email format');
// Subscribe
eventBus.on<SaveDraftEvent>('save-draft').subscribe(() => saveDraft());
eventBus.on<ValidationErrorEvent>('validation-error').subscribe((event) => {
showError(event.fieldKey, event.errorMessage);
});Usage from a host component (via EventDispatcher):
// Dispatch a pre-built instance
dispatcher.dispatch(new SaveDraftEvent());
dispatcher.dispatch(new ValidationErrorEvent('email', 'Invalid email format'));Attaching Form Values to Events
By default, events don't include the form's current value. You can opt-in to automatically attach the form value to all dispatched events using withEventFormValue().
Global Opt-in
Enable for all forms in your application:
import { provideDynamicForm, withEventFormValue } from '@ng-forge/dynamic-forms';
provideDynamicForm(...withMaterialFields(), withEventFormValue());Per-Form Control
Override the global setting for specific forms:
const config = {
fields: [...],
options: {
// Enable for this form (even if globally disabled)
emitFormValueOnEvents: true,
// Or disable for this form (even if globally enabled)
// emitFormValueOnEvents: false,
},
} as const satisfies FormConfig;Accessing the Form Value
Use the hasFormValue() type guard to safely access the attached value:
import { hasFormValue } from '@ng-forge/dynamic-forms';
eventBus.on<FormSubmitEvent>('submit').subscribe((event) => {
if (hasFormValue(event)) {
// TypeScript knows event.formValue exists
console.log('Form value at submission:', event.formValue);
sendToApi(event.formValue);
}
});This is useful when you need the complete form state at the time an event occurred, such as:
- Logging form values on submission
- Auto-saving on page changes
- Analytics tracking
Next Steps
- Form Submission: Configure async submission with loading states and server errors
- Custom Fields: Build custom field components that use EventBus
- Form Arrays: Use array events to add and remove items programmatically