Custom Components
Customized components let you extend Formitiva with your own field types while seamlessly integrating with its validation, layout, localization, and builder ecosystem. This allows you to model complex data structures, build specialized inputs, and maintain a consistent form experience throughout your application.
In this guide, you will learn how to:
- Define a custom input component for your framework
- Register the new component in your app (then use it the same way as built-in components)
- Define an example JSON schema using the new component
- Use the definition in a form
- Define a type validation handler and register it
The following example demonstrates a Point2D component that captures a pair of values [X, Y] and validates them as a single value.
Step 1: Define Custom Component
React
import React from "react";
import type { BaseInputProps, DefinitionPropertyField } from "@formitiva/react";
import { StandardFieldLayout, CSS_CLASSES, combineClasses } from "@formitiva/react";
import { useFormitivaContext, useFieldValidator, useUncontrolledValidatedInput } from "@formitiva/react";
type Point2DValue = [string, string];
export type Point2DInputProps = BaseInputProps<Point2DValue, DefinitionPropertyField>;
const Point2DInput: React.FC<Point2DInputProps> = ({ field, value, onChange, onError, error: externalError }) => {
const { t } = useFormitivaContext();
const fieldValidate = useFieldValidator(field, externalError);
const normalizedValue: Point2DValue = Array.isArray(value)
? [String(value[0] ?? ""), String(value[1] ?? "")]
: ["", ""];
const handleChange = React.useCallback(
(nextValue: string | string[]) => {
const arr = Array.isArray(nextValue) ? nextValue : [nextValue, ""];
onChange?.([String(arr[0] ?? ""), String(arr[1] ?? "")]);
},
[onChange]
);
const handleValidation = React.useCallback(
(nextValue: string | string[], trigger?: 'change' | 'blur' | 'sync') => {
const arr = Array.isArray(nextValue) ? nextValue : [nextValue, ""];
return fieldValidate([String(arr[0] ?? ""), String(arr[1] ?? "")], trigger as any);
},
[fieldValidate]
);
const { getInputRef, error, getHandleChange } = useUncontrolledValidatedInput<HTMLInputElement, string[]>({
value: normalizedValue,
count: 2,
onError,
onChange: handleChange,
validate: handleValidation,
});
return (
<StandardFieldLayout field={field} error={error}>
<input
id={`${field.name}-x`}
type="text"
placeholder={t('X')}
defaultValue={normalizedValue[0]}
ref={getInputRef(0)}
onChange={getHandleChange(0)}
className={combineClasses(CSS_CLASSES.input, CSS_CLASSES.inputNumber)}
aria-label={'X'}
/>
<input
id={`${field.name}-y`}
type="text"
placeholder={t('Y')}
defaultValue={normalizedValue[1]}
ref={getInputRef(1)}
onChange={getHandleChange(1)}
aria-label={'Y'}
className={combineClasses(CSS_CLASSES.input, CSS_CLASSES.inputNumber)}
/>
</StandardFieldLayout>
);
};
export default React.memo(Point2DInput);
Note: Pay attention to these rules to make this component work as built-in components:
- Use
useFieldValidatorto respect theFieldValidationModespecified in the Formitiva form - Use
useUncontrolledValidatedInputto use uncontrolled input logic
Vue
Create a Point2DInput.vue single-file component:
<script setup lang="ts">
import { computed } from 'vue';
import { StandardFieldLayout } from '@formitiva/vue';
import type { DefinitionPropertyField, FieldValueType } from '@formitiva/vue';
const props = defineProps<{
field: DefinitionPropertyField;
value?: FieldValueType;
error?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
change: [value: FieldValueType];
}>();
const xVal = computed(() => Array.isArray(props.value) ? String(props.value[0] ?? '') : '');
const yVal = computed(() => Array.isArray(props.value) ? String(props.value[1] ?? '') : '');
function emitUpdate(newX: string, newY: string) {
emit('change', [newX, newY] as unknown as FieldValueType);
}
</script>
<template>
<StandardFieldLayout :field="field" :error="error">
<div style="display:flex;gap:8px;align-items:center">
<label>X:</label>
<input
type="number"
:value="xVal"
:disabled="disabled"
@blur="(e) => emitUpdate((e.target as HTMLInputElement).value, yVal)"
/>
<label>Y:</label>
<input
type="number"
:value="yVal"
:disabled="disabled"
@blur="(e) => emitUpdate(xVal, (e.target as HTMLInputElement).value)"
/>
</div>
</StandardFieldLayout>
</template>
Angular
Create a Point2DInputComponent that extends BaseFieldComponent:
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
BaseFieldComponent,
StandardFieldLayoutComponent,
registerFieldTypeValidationHandler,
} from '@formitiva/angular';
import type { DefinitionPropertyField, FieldValueType, TranslationFunction } from '@formitiva/angular';
type Point2DValue = [string, string];
// Register type-level validator once at module load
registerFieldTypeValidationHandler(
'point2d',
(_field: DefinitionPropertyField, input: FieldValueType, t: TranslationFunction) => {
if (!Array.isArray(input) || input.length !== 2) return t('Value must be a 2D point [x, y]');
if (!Number.isFinite(Number(input[0]))) return t('X must be a valid number');
if (!Number.isFinite(Number(input[1]))) return t('Y must be a valid number');
return undefined;
}
);
@Component({
selector: 'app-point2d-input',
standalone: true,
imports: [CommonModule, StandardFieldLayoutComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<fv-standard-field-layout [field]="field" [error]="errorSig()">
<div style="display:flex;gap:8px;align-items:center">
<label>X:</label>
<input type="number" [value]="xVal" [disabled]="disabled"
(blur)="onXBlur($event)" />
<label>Y:</label>
<input type="number" [value]="yVal" [disabled]="disabled"
(blur)="onYBlur($event)" />
</div>
</fv-standard-field-layout>
`,
})
export class Point2DInputComponent extends BaseFieldComponent<Point2DValue> {
constructor(cdr: ChangeDetectorRef) { super(cdr); }
get xVal(): string { return Array.isArray(this.value) ? String(this.value[0] ?? '') : ''; }
get yVal(): string { return Array.isArray(this.value) ? String(this.value[1] ?? '') : ''; }
onXBlur(event: Event): void {
this.emitChange([( event.target as HTMLInputElement).value, this.yVal] as unknown as Point2DValue);
}
onYBlur(event: Event): void {
this.emitChange([this.xVal, (event.target as HTMLInputElement).value] as unknown as Point2DValue);
}
}
Vanilla JS
Create a FieldFactory function returning a FieldWidget:
import { registerComponent, createStandardFieldLayout } from '@formitiva/vanilla';
import type { FieldFactory, FieldWidget, FieldValueType } from '@formitiva/vanilla';
const point2dFactory: FieldFactory = (field, ctx, onChange, _onError, initialValue, initialError, disabled) => {
const layout = createStandardFieldLayout(field, ctx);
const parseVal = (v: unknown): { x: string; y: string } => {
if (Array.isArray(v) && v.length === 2) return { x: String(v[0] ?? ''), y: String(v[1] ?? '') };
return { x: '', y: '' };
};
const current = parseVal(initialValue);
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;align-items:center';
const makeInput = (placeholder: string, value: string): HTMLInputElement => {
const input = document.createElement('input');
input.type = 'number';
input.placeholder = placeholder;
input.value = value;
input.disabled = disabled;
input.style.cssText = 'width:80px';
return input;
};
const xInput = makeInput('X', current.x);
const yInput = makeInput('Y', current.y);
const emit = () => onChange([xInput.value, yInput.value] as unknown as FieldValueType);
xInput.addEventListener('input', emit);
yInput.addEventListener('input', emit);
row.append('x: ', xInput, ' y: ', yInput);
layout.slot.appendChild(row);
if (initialError) layout.setError(initialError);
const widget: FieldWidget = {
el: layout.el,
update(value, error, isDisabled) {
const parsed = parseVal(value);
xInput.value = parsed.x;
yInput.value = parsed.y;
xInput.disabled = isDisabled;
yInput.disabled = isDisabled;
layout.setError(error);
},
destroy() {
xInput.removeEventListener('input', emit);
yInput.removeEventListener('input', emit);
},
};
return widget;
};
Step 2: Register the component
The registration call should happen before the Formitiva form is used. It can be called globally or on app mount.
// React
import { registerComponent } from '@formitiva/react';
registerComponent("point2d", Point2DInput);
// Vue
import { registerComponent } from '@formitiva/vue';
registerComponent("point2d", Point2DInput); // Point2DInput.vue component
// Angular
import { registerComponent } from '@formitiva/angular';
registerComponent("point2d", Point2DInputComponent);
// Vanilla JS
import { registerComponent } from '@formitiva/vanilla';
registerComponent("point2d", point2dFactory);
Step 3: Create an example definition using the component
The definition is identical across all frameworks:
{
"name": "RectangleDefinition",
"displayName": "Rectangle Definition",
"version": "1.0.0",
"properties": [
{
"type": "point2d",
"name": "topLeft",
"displayName": "Top Left",
"defaultValue": ["0", "0"],
"required": true
},
{
"type": "point2d",
"name": "bottomRight",
"displayName": "Bottom Right",
"defaultValue": ["640", "480"],
"required": true
}
]
}
Step 4: Use in app
React
import { Formitiva } from '@formitiva/react';
export default function App() {
return (
<div>
<h2>Custom Component: Point2D</h2>
<Formitiva definitionData={rectDef} />
</div>
);
}
Vue
<script setup lang="ts">
import { Formitiva } from '@formitiva/vue';
</script>
<template>
<div>
<h2>Custom Component: Point2D</h2>
<Formitiva :definition-data="rectDef" />
</div>
</template>
Angular
import { Component } from '@angular/core';
import { FormitivaComponent } from '@formitiva/angular';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormitivaComponent],
template: `
<div>
<h2>Custom Component: Point2D</h2>
<fv-formitiva [definitionData]="rectDef"></fv-formitiva>
</div>
`,
})
export class AppComponent {
rectDef = rectDef;
}
Vanilla JS
import { Formitiva } from '@formitiva/vanilla';
const form = new Formitiva({ definitionData: rectDef });
await form.mount(document.getElementById('app')!);
Step 5: Type validation
If the component includes default validation logic, register it using registerFieldTypeValidationHandler.
Within the registered handler, validate that the x and y values are valid numbers.
This validation respects FieldValidationMode and runs before custom field validation and form-level validation.
The registration API is the same across all frameworks (import from your framework package):
import { registerFieldTypeValidationHandler } from '@formitiva/react'; // or /vue, /angular, /vanilla
import type { DefinitionPropertyField, FieldValueType, TranslationFunction } from '@formitiva/react';
registerFieldTypeValidationHandler('point2d', (
field: DefinitionPropertyField,
input: FieldValueType,
t: TranslationFunction) =>
{
void field; // unused
if (!Array.isArray(input) || input.length !== 2) {
return t('Value must be a 2D point array');
}
const [x, y] = input;
if (!Number.isFinite(Number(x))) return t('X must be a valid number');
if (!Number.isFinite(Number(y))) return t('Y must be a valid number');
return undefined;
});
Example reference
Please check the example apps: