Headless TypeScript forms over @tanstack/form-core with a MobX-native public
API.
TanStack engine. MobX public surface. No React runtime.
Forms live outside React component lifecycle. Consumers observe public getters with MobX
computed,reaction, andautorun. TanStack store and error-map details stay private.
@revisium/forms-core is for application state layers that need forms outside
React component lifecycle. TanStack Form Core owns the form engine; this package
adapts its stores into MobX-observable public getters so consumers can use
computed, reaction, and autorun without subscribing to TanStack stores.
The package is headless. It does not export React components, Chakra wrappers, or UI-specific hooks.
npm install @revisium/forms-core @tanstack/form-core mobximport { reaction } from 'mobx';
import { createForm, field } from '@revisium/forms-core';
const form = createForm({
defaultValues: {
email: '',
password: '',
},
fields: {
email: field<string>({
validators: {
onChange: ({ value }) =>
value.includes('@') ? undefined : 'Invalid email',
},
}),
password: field<string>(),
},
});
const disposeReaction = reaction(
() => form.controls.email.value,
(email) => {
console.log('email changed', email);
},
);
form.controls.email.setValue('user@example.com');
form.controls.email.blur();
console.log(form.isValid);
console.log(form.getRawValue());
disposeReaction();
form.dispose();Create forms with createForm({ defaultValues, fields, arrays, validators }).
Field controls are available under form.controls:
form.controls.email.value;
form.controls.email.displayValue;
form.controls.email.error;
form.controls.email.visibleError;
form.controls.email.isDirty;
form.controls.email.isTouched;
form.controls.email.isValidating;
form.controls.email.setValue('user@example.com');
form.controls.email.blur();
form.controls.email.reset();Form state is exposed through MobX-reactive getters:
form.isValid;
form.isDirty;
form.isTouched;
form.isSubmitting;
form.errors;
form.getRawValue();
await form.validate();
await form.submit();
form.reset();
form.reset(nextValues);
form.dispose();Exports include createForm, field, arrayField, and public types for form
options, controls, arrays, validators, patches, and listeners.
createForm(...) infers the form value shape from defaultValues. Created
controls, arrays, reset(values), and applyServerErrors(paths) are typed
from that value shape and the configured field/array paths.
Use explicit helper generics when a callback needs contextual types inside the configuration object:
type Values = { email: string; confirmEmail: string };
field<string, Values>({
validators: {
onChange: ({ value, values }) =>
value === values.confirmEmail ? undefined : 'Emails must match',
onChangeListenTo: ['confirmEmail'],
},
});
arrayField<{ id: string; name: string }>({
getItemId: (item) => item.id,
});Field validators support sync, async, debounce, submit, blur, and linked-field validation where TanStack Form Core supports the underlying behavior.
type EmailFormValues = {
email: string;
confirmEmail: string;
};
const defaultValues: EmailFormValues = {
email: '',
confirmEmail: '',
};
const form = createForm({
defaultValues,
fields: {
email: field<string>({
validators: {
onChange: ({ value }) =>
value.includes('@') ? undefined : 'Invalid email',
onChangeAsync: async ({ value, signal }) => {
await checkEmailAvailability(value, { signal });
},
onChangeAsyncDebounceMs: 300,
},
}),
confirmEmail: field<string, EmailFormValues>({
validators: {
onChangeListenTo: ['email'],
onChange: ({ value, values }) =>
value === values.email ? undefined : 'Emails must match',
},
}),
},
});Form-level validators can return a form error string or field errors:
declare function checkEmailAvailability(
value: string,
options: { signal: AbortSignal },
): Promise<void>;
const form = createForm({
defaultValues: {
password: '',
},
fields: {
password: field<string>(),
},
validators: {
onSubmit: ({ value }) => ({
fields: {
password: value.password ? undefined : 'Required',
},
}),
},
});applyServerErrors() accepts dot/bracket paths and exposes simple string errors
on controls.
form.applyServerErrors({
email: 'Email already exists',
'members[0].name': 'Required',
});
form.controls.email.error;
form.controls.email.visibleError;Server errors are explicit external errors. They survive validation cycles, are
visible immediately, clear when the relevant field value changes, and clear on
reset().
Use arrayField({ getItemId }) for public array identity. getItemId must
return a unique stable id for every item; public identity never relies on array
indexes.
import { arrayField, createForm, field } from '@revisium/forms-core';
const form = createForm({
defaultValues: {
members: [{ id: '1', name: '' }],
},
fields: {},
arrays: {
members: arrayField<{ id: string; name: string }>({
getItemId: (item) => item.id,
}),
},
});
form.arrays.members.items;
form.arrays.members.push({ id: '2', name: 'Ada' });
form.arrays.members.insert(1, { id: '3', name: 'Grace' });
form.arrays.members.move(0, 1);
form.arrays.members.removeById('2');
form.arrays.members.clear();Array items expose the current index, current value, stable id, and generated controls for object item fields:
const first = form.arrays.members.items[0];
first?.id;
first?.index;
first?.value;
first?.controls.name.setValue('Ada');onPatch(listener) emits value patches by diffing previous and current form
values. This is intended for autosave and persistence adapters.
import type { FormPatch } from '@revisium/forms-core';
declare function queueAutosave(patch: FormPatch): void;
const disposePatchListener = form.onPatch((patches) => {
for (const patch of patches) {
queueAutosave(patch);
}
});
form.controls.email.setValue('user@example.com');
disposePatchListener();Patch shape:
type FormPatch =
| { type: 'set'; path: string; value: unknown; previousValue: unknown }
| { type: 'remove'; path: string; previousValue: unknown }
| { type: 'insert'; path: string; index: number; value: unknown }
| { type: 'move'; path: string; fromIndex: number; toIndex: number }
| { type: 'clear'; path: string; previousValue: unknown[] };Scalar and nested edits emit full paths such as email or profile.name.
Configured arrays emit stable operation patches for push, insert,
removeAt, removeById, move, and clear. Ambiguous bulk array changes fall
back to a set patch for the array path.
React integration is consumer-side. Create and own the form in your application state layer, view model, or dependency-injection scope, then observe the public MobX getters from React through your chosen MobX React binding.
This package intentionally does not import React or export React components.
@tanstack/react-form is a React adapter. forms-core needs a headless MobX
adapter that can live outside component lifecycle and be consumed by MobX
computed, reaction, and autorun. Using the React adapter as the main API
would make React a hidden runtime dependency and would not satisfy the
MobX-native contract.
Known limitations and deliberate constraints are tracked in docs/limitations.md.
Use Node.js 24.11.1 or newer. CI runs the primary Sonar-enabled job on 24.11.1.
npm ci
npm run verifyUseful commands:
npm run tsc- TypeScript typecheck.npm run lint:ci- ESLint with zero warnings.npm run test:cov- Jest coverage output for Sonar.npm run build- package build and declaration output.npm run ci:local:sonar- run local verify, Sonar quality gate, and issue inspection.npm pack --dry-run- inspect package contents before publish.
For PRs, do not treat Sonar PASSED as complete by itself. Inspect unresolved
issues and fix every valid issue; this repo uses zero tolerance for PR Sonar
issues. Pushes to master upload Sonar analysis but do not enforce total branch
issue count.
Release and npm publish rules are documented in
docs/release-train.md. Publishing requires explicit
approval and uses shared workflows from revisium/revisium-actions.