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: EventBus is scoped to the DynamicForm component's injector tree. It is only available to components rendered inside DynamicForm (i.e. custom field components). Injecting EventBus in a parent or host component fails with a NullInjectorError (there is no provider at that level); providing your own EventBus only yields a disconnected instance the form knows nothing about. Use EventDispatcher from 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 for event.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: EventBus is provided by DynamicForm via 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 a NullInjectorError unless you provide your own instance, and a self-provided instance is disconnected from the form. Use EventDispatcher instead.

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 pages
  • previousPageIndex?: 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 pages
  • isFirstPage: boolean - On the first visible page
  • isLastPage: 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 } - Set validate: false to land on the target regardless of earlier pages, for restoring a saved session. Bounds and hidden-page checks still apply. Defaults to true
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 0

Observing 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