@veams/form
v0.5.0
Published
Form state handlers and React bindings for the VEAMS StatusQuo ecosystem.
Readme
@veams/form
Form state handlers plus optional React bindings for the VEAMS StatusQuo ecosystem.
This package keeps form state generic at the root entrypoint and ships React-only helpers under @veams/form/react.
Docs
Live docs:
https://veams.github.io/status-quo/packages/form/overview
Install
npm install @veams/form @veams/status-quo reactPackage Exports
Root exports:
FormStateHandlerFormActionsFormErrorsFormFieldNameFormStateFormStateHandlerConfigFormStateHandlerOptionsFormTouchedFormValuesValidatorFn
React entrypoint:
@veams/form/reactFormProvideruseFormControlleruseFieldMetauseUncontrolledFieldController
Validator adapters:
@veams/form/validators@veams/form/validators/zodtoZodValidator(schema)
Quickstart
Create a generic form handler:
import { FormStateHandler } from '@veams/form';
type LoginValues = {
email: string;
password: string;
};
const loginForm = new FormStateHandler<LoginValues>({
initialValues: {
email: '',
password: '',
},
validator: (values) => {
const errors: Partial<Record<keyof LoginValues, string>> = {};
if (!values.email) {
errors.email = 'Email is required';
}
if (!values.password) {
errors.password = 'Password is required';
}
return errors;
},
});
loginForm.setFieldValue('email', '[email protected]');
loginForm.validateForm();Nested values are supported through dot-path field names:
type ProfileForm = {
profile: {
email: string;
};
};
const profileForm = new FormStateHandler<ProfileForm>({
initialValues: {
profile: {
email: '',
},
},
});
profileForm.setFieldValue('profile.email', '[email protected]');
profileForm.setFieldTouched('profile.email', true);React Quickstart
Use FormProvider to own one handler instance locally and useUncontrolledField() to bind native elements:
import { FormProvider, useUncontrolledField } from '@veams/form/react';
function EmailField() {
const { meta, registerProps } = useUncontrolledField('email');
return (
<label>
Email
<input {...registerProps} type="email" />
{meta.showError ? <span>{meta.error}</span> : null}
</label>
);
}
function LoginForm() {
return (
<FormProvider
initialValues={{ email: '', password: '' }}
onSubmit={async (values) => {
await submitLogin(values);
}}
validator={(values) => ({
...(values.email ? {} : { email: 'Email is required' }),
...(values.password ? {} : { password: 'Password is required' }),
})}
>
<EmailField />
<button type="submit">Sign in</button>
</FormProvider>
);
}Uncontrolled Field Principle
Native fields should stay uncontrolled by default in VEAMS Form, while FormStateHandler remains the source of truth for values, errors, touched state, and submit state.
Why this default is useful:
- Lower render churn: typing updates the DOM directly without forcing controlled React value props on every keystroke.
- Native behavior stays intact: browser input semantics, selection handling, and autofill work naturally.
- Cleaner component code: field components mostly spread
registerPropsand rendermeta. - Clear ownership boundaries: feature/form behavior stays in the handler, React stays a binding layer.
When a component requires controlled props (value + onChange), use Controller intentionally for that field only.
Feature-Owned Form State
A feature handler can own the form handler and pass it into the React provider. This keeps cross-field validation and non-form UI state in the same feature boundary.
When formHandlerInstance is provided, initialValues and validator stay on the handler and are not passed to FormProvider.
import { SignalStateHandler } from '@veams/status-quo';
import { FormStateHandler } from '@veams/form';
type LoginValues = {
email: string;
password: string;
};
type LoginState = {
isPasswordVisible: boolean;
};
type LoginActions = {
getFormHandler: () => FormStateHandler<LoginValues>;
submitLogin: (values: LoginValues) => Promise<void>;
togglePasswordVisibility: () => void;
};
class LoginStateHandler extends SignalStateHandler<LoginState, LoginActions> {
private readonly formHandler = new FormStateHandler<LoginValues>({
initialValues: {
email: '',
password: '',
},
validator: (values) => ({
...(values.email ? {} : { email: 'Email is required' }),
...(values.password ? {} : { password: 'Password is required' }),
}),
});
constructor() {
super({
initialState: {
isPasswordVisible: false,
},
});
}
getActions(): LoginActions {
return {
getFormHandler: () => this.formHandler,
submitLogin: async (_values) => undefined,
togglePasswordVisibility: () => {
this.setState({
isPasswordVisible: !this.getState().isPasswordVisible,
});
},
};
}
}import { useStateFactory } from '@veams/status-quo/react';
import { FormProvider, useUncontrolledField } from '@veams/form/react';
function PasswordField({ isVisible }: { isVisible: boolean }) {
const { meta, registerProps } = useUncontrolledField('password', {
type: isVisible ? 'text' : 'password',
});
return (
<label>
Password
<input {...registerProps} />
{meta.showError ? <span>{meta.error}</span> : null}
</label>
);
}
function LoginFeature() {
const [state, actions] = useStateFactory(() => new LoginStateHandler(), []);
return (
<FormProvider
formHandlerInstance={actions.getFormHandler()}
onSubmit={actions.submitLogin}
>
<PasswordField isVisible={state.isPasswordVisible} />
<button onClick={actions.togglePasswordVisibility} type="button">
Toggle password visibility
</button>
<button type="submit">Sign in</button>
</FormProvider>
);
}Controlled Components
Use Controller when a third-party field expects value and onChange instead of native uncontrolled props.
import { Controller, FormProvider } from '@veams/form/react';
function ControlledRoleSelect() {
return (
<Controller
name="role"
render={({ field, fieldState }) => (
<>
<RoleSelect onBlur={field.onBlur} onChange={field.onChange} value={field.value as string} />
{fieldState.touched && fieldState.error ? <span>{fieldState.error}</span> : null}
</>
)}
/>
);
}
function RoleForm() {
return (
<FormProvider
initialValues={{ role: 'user' }}
onSubmit={(values) => saveRole(values.role)}
>
<ControlledRoleSelect />
<button type="submit">Save</button>
</FormProvider>
);
}Form-Level Submit Errors
Keep backend errors that are not tied to one field out of the field error map.
Use setSubmitError() for those cases and read aggregate state through useFormMeta().
import { FormProvider, useFormMeta } from '@veams/form/react';
function SubmitErrorBanner() {
const { submitError } = useFormMeta<{ email: string; password: string }>();
return submitError ? <p role="alert">{submitError}</p> : null;
}Schema Validators (Zod)
@veams/form does not depend on Zod, but it exposes a lightweight adapter for Zod-style safeParse schemas.
The package currently includes only the Zod adapter because that is the most common schema setup in current usage. PRs for additional adapters are welcome as long as the package remains dependency-free.
import { z } from 'zod';
import { FormStateHandler } from '@veams/form';
import { toZodValidator } from '@veams/form/validators/zod';
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
password: z.string().min(12, 'Use at least 12 characters'),
});
type LoginValues = z.infer<typeof loginSchema>;
const form = new FormStateHandler<LoginValues>({
initialValues: {
email: '',
password: '',
},
validator: toZodValidator(loginSchema),
});