How react-declarative manages form state for you

react-declarative manages form state for you. You supply an initial data loader, subscribe to changes, and define per-field rules as plain callback functions — the library handles diffing, re-renders, validation display, and disabled/hidden field states without any external state managers or manual useState wiring. This page explains all the moving parts.

The handler prop tells <One /> where to get its initial data. It can be a static object, a sync function, or an async function — the component resolves it automatically.

import { OneTyped, TypedField, FieldType } from 'react-declarative';

// Static object
<OneTyped
fields={fields}
handler={{ firstName: 'Jane', email: 'jane@example.com' }}
/>

// Async function — useful for fetching from an API
<OneTyped
fields={fields}
handler={async (payload) => {
const user = await fetch(`/api/users/${payload.userId}`).then(r => r.json());
return user;
}}
payload={{ userId: '42' }}
/>

handler receives the current payload value, so you can use runtime context (IDs, roles, feature flags) to decide what data to load without threading props manually.

Note: IOnePublicProps also exposes a data prop as an alternative to handler for developers who prefer the familiar controlled-component pattern. Use either; avoid mixing both on the same <One />.

onChange (aliased as change in the lower-level IOneProps) is called on every field change. It receives the full updated data object. Use it to sync state, auto-save, or update sibling UI.

const [formData, setFormData] = useState<ProfileData | null>(null);

<OneTyped<ProfileData>
fields={fields}
handler={() => fetchProfile()}
onChange={(data, initial) => {
// `initial` is true on the first call after handler resolves —
// useful to skip saves triggered by loading data.
if (!initial) {
setFormData(data);
}
}}
/>

For text-heavy forms you usually don't want to fire a network request on every keystroke. Use fieldDebounce on <One /> to delay onChange calls from FieldType.Text fields, and debounce the save logic itself on the consumer side.

import { useMemo } from 'react';
import debounce from 'lodash/debounce';

const autoSave = useMemo(
() =>
debounce(async (data: ProfileData) => {
await fetch('/api/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}, 800),
[]
);

<OneTyped<ProfileData>
fields={fields}
handler={fetchProfile}
fieldDebounce={300} // debounces Text field onChange internally
onChange={(data, initial) => {
if (!initial) autoSave(data);
}}
/>

payload is a stable reference object (or factory function) that flows into every field callback alongside the data object. Use it for anything that comes from outside the form itself — user permissions, route parameters, feature flags, or references to external services.

interface AppPayload {
userRole: 'admin' | 'viewer';
}

const fields: TypedField<ProfileData, AppPayload>[] = [
{
type: FieldType.Text,
name: 'internalNote',
title: 'Internal note',
// Only admins can edit this field
isReadonly: (_data, payload) => payload.userRole !== 'admin',
},
];

<OneTyped<ProfileData, AppPayload>
fields={fields}
handler={fetchProfile}
payload={{ userRole: currentUser.role }}
/>

Warning: payload bypasses change detection by design — updating it does not trigger a re-render of the form tree. If you need reactive context that causes re-renders, use the context prop on <One /> instead.

Each field can declare four callbacks that receive the current data object (and payload) and return a value describing the field's state. react-declarative re-evaluates these on every change and updates the UI automatically.

Return a non-null string to mark the field invalid and display an error. Return null if the value is acceptable. Fields with errors prevent the invalidity callback on <One /> from resolving cleanly.

{
type: FieldType.Text,
name: 'email',
title: 'Email',
isInvalid({ email }) {
const expr = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/g;
if (!expr.test(email)) {
return 'Invalid email address';
}
return null;
},
}

isInvalid supports cross-field validation — the full data object is available, so you can compare two fields:

{
type: FieldType.Text,
name: 'to',
title: 'To',
isInvalid: ({ from, to }) => {
if (from && to && parseInt(from) > parseInt(to)) {
return 'Must be greater than "From"';
}
return null;
},
}

Like isInvalid, but returning a string shows a warning indicator in the UI without blocking form submission. Use it for spell-check style hints that don't prevent saving.

{
type: FieldType.Text,
name: 'username',
title: 'Username',
isIncorrect({ username }) {
if (username && username.length < 4) {
return 'Usernames shorter than 4 characters are not recommended';
}
return null;
},
}

Return false to remove the field from the DOM entirely. The field's value is still present in the data object even when hidden.

{
type: FieldType.Text,
name: 'companyName',
title: 'Company name',
isVisible: ({ accountType }) => accountType === 'business',
}

Return true to render the field in a disabled state. The value is still readable but cannot be changed by the user.

{
type: FieldType.Text,
name: 'email',
title: 'Email',
isDisabled: ({ locked }) => Boolean(locked),
}

Return true to render the field in read-only mode. Unlike isDisabled, the value is still visually styled as an active field but cannot be edited.

{
type: FieldType.Text,
name: 'createdAt',
title: 'Created at',
isReadonly: () => true,
}

The compute prop derives a field's display value from the data object each render. A field with compute is automatically read-only, because its value is never stored in the data object directly.

const fields: TypedField[] = [
{
type: FieldType.Text,
name: 'firstName',
title: 'First name',
},
{
type: FieldType.Text,
name: 'lastName',
title: 'Last name',
},
{
type: FieldType.Text,
name: 'fullName',
title: 'Full name (computed)',
// Always shows "firstName lastName", never stored separately
compute: ({ firstName, lastName }) =>
[firstName, lastName].filter(Boolean).join(' '),
},
];

defaultValue at the field level and handler-loaded data are two separate mechanisms that interact in a specific order:

  1. handler runs<One /> calls your handler function and awaits the result. The returned object becomes the initial data.
  2. defaultValue fills gaps — For each field that has a defaultValue, if the resolved data object has null, undefined, or is missing that key, the defaultValue is used instead.
  3. First onChange firesonChange is called once with initial: true to reflect the merged initial state. This gives you the fully resolved starting data.
// If handler returns { firstName: 'Jane' } and the schema has:
{
type: FieldType.Text,
name: 'role',
defaultValue: 'viewer', // used because handler didn't supply 'role'
},
{
type: FieldType.Text,
name: 'firstName',
defaultValue: 'Unknown', // NOT used — handler already supplied 'Jane'
},

defaultValue can also be a function that receives payload, which is useful when the default depends on runtime context:

{
type: FieldType.Text,
name: 'assignee',
defaultValue: (payload) => payload.currentUsername,
}
import { useState, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { OneTyped, TypedField, FieldType } from 'react-declarative';
import { Email } from '@mui/icons-material';

interface ProfileData {
firstName: string;
lastName: string;
email: string;
role: string;
visible: boolean;
disabled: boolean;
}

interface AppPayload {
userId: string;
}

const fields: TypedField<ProfileData, AppPayload>[] = [
{
type: FieldType.Text,
name: 'firstName',
title: 'First name',
columns: '6',
isDisabled: ({ disabled }) => disabled,
},
{
type: FieldType.Text,
name: 'lastName',
title: 'Last name',
columns: '6',
isDisabled: ({ disabled }) => disabled,
},
{
type: FieldType.Text,
name: 'email',
title: 'Email',
inputType: 'email',
trailingIcon: Email,
isVisible: ({ visible }) => visible,
isInvalid: ({ email }) => {
if (!email) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Invalid email';
return null;
},
},
{
type: FieldType.Expansion,
title: 'Debug controls',
fields: [
{ type: FieldType.Switch, name: 'visible', title: 'Show email field', defaultValue: true },
{ type: FieldType.Switch, name: 'disabled', title: 'Disable name fields' },
],
},
];

export function ProfileForm({ userId }: { userId: string }) {
const [saved, setSaved] = useState(false);

const autoSave = useMemo(
() =>
debounce(async (data: ProfileData) => {
await fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
setSaved(true);
}, 600),
[userId]
);

return (
<OneTyped<ProfileData, AppPayload>
fields={fields}
handler={async ({ userId }) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}}
payload={{ userId }}
fieldDebounce={300}
onChange={(data, initial) => {
if (!initial) {
setSaved(false);
autoSave(data);
}
}}
/>
);
}