@formos/react
v0.1.0
Published
React bindings for Formos - Headless form engine hooks
Maintainers
Readme
@formos/react
React bindings for the Formos form engine.
Purpose
@formos/react provides React hooks and components to integrate the headless @formos/kernel with React applications. It bridges the gap between the framework-agnostic form engine and React's component model.
Responsibilities
This package is responsible for:
- React Context Management: Providing form engine instance via React context
- Hook API: Exposing form functionality through idiomatic React hooks
- State Synchronization: Keeping React components in sync with form state
- SSR Compatibility: Safe server-side rendering support
- Performance Optimization: Minimizing unnecessary re-renders
What This Package Does NOT Do
❌ NO UI Components
- Does not provide input components, buttons, or form elements
- Does not include styling or CSS
- Does not use shadcn/ui or any UI library
- Headless - bring your own UI
❌ NO Form Logic
- Does not contain validation logic (use kernel adapters)
- Does not define schemas (use
@formos/schema) - Does not execute effects or validators
- Pure React bindings only
❌ NO Styling or Theming
- No CSS or style objects
- No theme providers
- Style your components however you want
Think of it as: React hooks for the form engine, not a complete form library.
Stability Guarantee
Hooks API is STABLE ✅
The public hooks and components are committed to backward compatibility:
- Hook signatures won't change
- Return types remain consistent
- Context API is stable
This enables:
- Safe updates: Upgrade without breaking UI
- Custom UI flexibility: Build any UI on these hooks
- Long-term reliability: Code written today works tomorrow
Installation
pnpm add @formos/react @formos/kernel @formos/schema reactBasic Usage
1. Setup FormProvider
Wrap your application with FormProvider:
import { FormProvider } from '@formos/react';
import { normalizeSchema, type FormSchemaV1 } from '@formos/schema';
const schema: FormSchemaV1 = {
version: 'v1',
fields: [
{
name: 'email',
type: 'email',
required: true
},
{
name: 'password',
type: 'password',
required: true
}
]
};
const normalized = normalizeSchema(schema);
function App() {
return (
<FormProvider
schema={normalized}
validators={{
required: (value) => value ? null : 'Required',
email: (value) => {
const regex = /\S+@\S+\.\S+/;
return regex.test(String(value)) ? null : 'Invalid email';
}
}}
onSubmit={async (values) => {
console.log('Form submitted:', values);
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(values)
});
}}
>
<LoginForm />
</FormProvider>
);
}2. Use Form Hooks
useForm - Form-level operations
import { useForm } from '@formos/react';
function LoginForm() {
const { submit, reset, isValid, isDirty } = useForm();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await submit();
if (success) {
console.log('Login successful');
} else {
console.log('Validation failed');
}
};
return (
<form onSubmit={handleSubmit}>
<EmailField />
<PasswordField />
<button type="submit" disabled={!isValid() || !isDirty()}>
Login
</button>
<button type="button" onClick={reset}>
Reset
</button>
</form>
);
}useField - Field-level state and actions
import { useField } from '@formos/react';
function EmailField() {
const {
value,
error,
touched,
dirty,
setValue,
markTouched
} = useField('email', {
validateOnChange: true,
validateOnBlur: true
});
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={String(value || '')}
onChange={(e) => setValue(e.target.value)}
onBlur={() => markTouched()}
className={touched && error ? 'error' : ''}
/>
{touched && error && (
<span className="error-message">{error}</span>
)}
{dirty && <span className="dirty-indicator">*</span>}
</div>
);
}useFormState - Observe form state
import { useFormState } from '@formos/react';
function SubmitButton() {
const { isValid, isDirty, isSubmitting } = useFormState();
return (
<button
type="submit"
disabled={!isValid || !isDirty || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Form'}
</button>
);
}
function ErrorSummary() {
const { errors } = useFormState();
const errorList = Object.entries(errors).filter(([_, error]) => error);
if (errorList.length === 0) return null;
return (
<div className="error-summary">
<h3>Please fix the following errors:</h3>
<ul>
{errorList.map(([field, error]) => (
<li key={field}>{field}: {error}</li>
))}
</ul>
</div>
);
}Multi-Step Forms
Use useStep for multi-step form navigation:
import { useStep } from '@formos/react';
const multiStepSchema: FormSchemaV1 = {
version: 'v1',
fields: [
{ name: 'firstName', type: 'text' },
{ name: 'lastName', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'phone', type: 'tel' }
],
steps: [
{ id: 'personal', title: 'Personal Info', fields: ['firstName', 'lastName'] },
{ id: 'contact', title: 'Contact', fields: ['email', 'phone'] }
]
};
function StepNavigator() {
const {
currentStep,
totalSteps,
nextStep,
prevStep,
canGoToStep
} = useStep();
const handleNext = async () => {
const moved = await nextStep();
if (!moved) {
alert('Please fix errors before proceeding');
}
};
return (
<div>
<div className="step-indicator">
Step {currentStep + 1} of {totalSteps}
</div>
<div className="step-content">
{currentStep === 0 ? <PersonalInfoStep /> : <ContactStep />}
</div>
<div className="step-navigation">
<button
onClick={prevStep}
disabled={currentStep === 0}
>
Previous
</button>
<button onClick={handleNext}>
{currentStep === totalSteps - 1 ? 'Submit' : 'Next'}
</button>
</div>
</div>
);
}Advanced Patterns
Custom Field Components
Create reusable field components:
interface TextFieldProps {
name: string;
label: string;
type?: 'text' | 'email' | 'password' | 'tel';
}
function TextField({ name, label, type = 'text' }: TextFieldProps) {
const { value, error, touched, setValue, markTouched } = useField(name);
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
value={String(value || '')}
onChange={(e) => setValue(e.target.value)}
onBlur={() => markTouched()}
aria-invalid={touched && !!error}
aria-describedby={error ? `${name}-error` : undefined}
/>
{touched && error && (
<span id={`${name}-error`} className="error" role="alert">
{error}
</span>
)}
</div>
);
}
// Usage
<TextField name="email" label="Email Address" type="email" />Conditional Fields
Show/hide fields based on conditions:
import { isFieldVisible } from '@formos/kernel';
function ConditionalField({ fieldName }: { fieldName: string }) {
const { schema, engine } = useForm();
const field = schema.fieldMap?.get(fieldName);
if (!field || !isFieldVisible(field, (name) => engine.getValue(name))) {
return null;
}
return <TextField name={fieldName} label={field.label || fieldName} />;
}Form-level Validation
Trigger validation at form level:
function ValidateAllButton() {
const { validate, getErrors } = useForm();
const handleValidate = async () => {
const isValid = await validate('manual');
if (isValid) {
alert('Form is valid!');
} else {
const errors = getErrors();
console.log('Validation errors:', errors);
}
};
return (
<button type="button" onClick={handleValidate}>
Validate All Fields
</button>
);
}SSR (Server-Side Rendering)
The package is SSR-safe. Use it with Next.js, Remix, or other SSR frameworks:
// app/page.tsx (Next.js App Router)
'use client';
import { FormProvider, useField } from '@formos/react';
import { normalizeSchema } from '@formos/schema';
const schema = normalizeSchema({
version: 'v1',
fields: [{ name: 'email', type: 'email' }]
});
export default function ContactForm() {
return (
<FormProvider schema={schema} validators={{/* ... */}}>
<EmailField />
</FormProvider>
);
}Note: FormProvider must be used in a Client Component ('use client') because it manages React state.
Performance Optimization
Minimize Re-renders
The hooks use React context with a version counter system. Components only re-render when:
- Their specific field value changes (for
useField) - Form state changes (for
useFormState) - Step changes (for
useStep)
Memoization
Form engine instance is memoized and only recreated if schema or validators change:
// Engine is stable across re-renders
const engine = useMemo(() => createFormEngine(schema, options), [schema, options]);Selective Subscriptions
Only subscribe to the state you need:
// ❌ Bad - subscribes to all form state
function SubmitButton() {
const { values, errors, isDirty } = useFormState();
return <button disabled={!isDirty}>Submit</button>;
}
// ✅ Good - only checks dirty state when needed
function SubmitButton() {
const { isDirty } = useForm();
return <button disabled={!isDirty()}>Submit</button>;
}API Reference
Components
FormProvider: Context provider for form engine
Hooks
useForm(): Form-level operations and engine accessuseField(name, options?): Field-level state and actionsuseFormState(): Reactive form state observationuseStep(): Multi-step navigation (multi-step forms only)
Types
FormProviderProps: Props for FormProviderUseFieldOptions: Options for useFieldUseFieldResult: Return type of useFieldUseFormStateResult: Return type of useFormStateUseStepResult: Return type of useStep
TypeScript Support
Full TypeScript support with strict typing:
import type { UseFieldResult, UseFormStateResult } from '@formos/react';
// Field result is fully typed
const field: UseFieldResult = useField('email');
// Form state is fully typed
const state: UseFormStateResult = useFormState();Package Structure
@formos/react/
├── src/
│ ├── index.ts # Public API exports
│ ├── FormContext.tsx # React context
│ ├── FormProvider.tsx # Provider component
│ ├── useForm.ts # Form hook
│ ├── useField.ts # Field hook
│ ├── useFormState.ts # State observation hook
│ ├── useStep.ts # Multi-step hook
│ └── types.ts # Type definitions
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── README.mdContributing
This package is part of the Formos monorepo. See the root README for contribution guidelines.
License
MIT
