Skip to main content

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 useFieldValidator to respect the FieldValidationMode specified in the Formitiva form
  • Use useUncontrolledValidatedInput to 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: