Type-safe, reactive form management for React with fine-grained field subscriptions.
Fieldwise is a lightweight, event-driven form library that provides precise control over component re-renders through field-level subscriptions. No more unnecessary re-renders from unrelated field changes.
- Fine-grained reactivity - Subscribe to specific fields, not entire form state
- Type-safe - Full TypeScript support with type inference
- Lightweight - Event-driven architecture with no state in React components
- Plugin system - Extensible with custom validation and behavior
- Performance - Automatic microtask batching for synchronous updates
- Zod validation - Built-in Zod schema validation
npm install fieldwise zod
# or
yarn add fieldwise zod
# or
pnpm add fieldwise zodPeer dependencies:
- React 18+ or React 19+
- Zod 3.x, 4.x (optional, only if using validation)
import { fieldwise, zod } from 'fieldwise';
import { z } from 'zod';
// Define your schema
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address')
});
// Create form hooks
const { useForm, useSlice } = fieldwise({
name: '',
email: ''
})
.use(zod(userSchema))
.hooks();
// Export for use in components
export { useForm as useUserForm, useSlice as useUserSlice };// In your component
import { useUserForm } from './userForm';
import Input from 'components/Input';
// ^- Input is a simple custom wrapper that consumes 4 properties generated
// by `i` function call: `name`, `value`, `onChange(value: <InferredType>) => void`
// and `error`.
function UserForm() {
const { emit, once, i, isValidating } = useUserForm();
const handleSubmit = (e) => {
e.preventDefault();
emit.later('validate'); // Defer validation to microtask
once('validated', (values, errors) => {
if (errors) return emit('errors', errors); // Validation failed, assign input errors
// Submit the form
console.log('Submitting:', values);
});
};
return (
<form onSubmit={handleSubmit}>
<Input {...i('name')} placeholder="Name" />
<Input {...i('email')} type="email" placeholder="Email" />
<button type="submit" disabled={isValidating}>
{isValidating ? 'Validating...' : 'Submit'}
</button>
</form>
);
}Unlike traditional form libraries, Fieldwise allows you to subscribe to specific fields:
// Subscribe to ALL fields (re-renders on any change)
const { fields } = useUserForm();
// Subscribe to SPECIFIC fields only (re-renders only when email changes)
const { fields } = useUserSlice(['email']);Fieldwise uses an event system for all state changes:
const { emit, once, fields } = useUserForm();
// Update a field
emit('change', 'name', 'John Doe');
// Trigger validation
emit.later('validate');
// Listen for validation results (one-time)
once('validated', (values, errors) => {
// Handle result
});
// Reset form
emit('reset'); // to initial values
emit('reset', newValues); // to specific valuesThe i() function generates all necessary props for controlled inputs:
<input {...i('email')} />
// Expands to:
{
name: 'email',
value: fields.email.value,
onChange: (value) => emit('change', 'email', value),
error: fields.email.error
}Creates a form builder with the specified initial values.
const builder = fieldwise({ name: '', email: '' });Applies a plugin to the form. Plugins can add validation, logging, or custom behavior.
builder.use(zod(schema)).use(myEventHandler); // Chain multiple pluginsGenerates React hooks for the form.
const { useForm, useSlice } = builder.hooks();Hook that subscribes to all form fields.
Returns:
fields: FieldSet<T>- Object containing all fields with{ value, error, isTouched }emit: EmitFn- Function to trigger eventsonce: OneTimeFn- Function to listen to events onceisTouched: boolean- Whether any field has been modifiedisValidating: boolean- Whether async validation is currently runningi: InputHelper- Function to generate input props
Hook that subscribes to specific form fields.
const { fields, emit, i } = useUserSlice(['email', 'name']);
// Only re-renders when email or name changesAvailable events:
change- Field value changed:emit('change', key, value)changeMany- Multiple fields changed:emit('changeMany', { field1: value1, field2: value2 })touch- Mark field as touched:emit('touch', key)touchMany- Mark multiple fields as touched:emit('touchMany', [key1, key2])validate- Validation requested:emit('validate')validated- Validation completed:once('validated', (values, errors) => {})reset- Form reset:emit('reset', snapshot?)
import { zod } from 'fieldwise';
import { z } from 'zod';
const schema = z
.object({
email: z.email(),
password: z.string().min(8, 'Must be at least 8 characters'),
confirmPassword: z.string()
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword']
});
type UserValues = z.infer<typeof schema>;
const emptyUser: UserValues = { email: '', password: '', confirmPassword: '' };
const { useForm } = fieldwise(emptyUser).use(zod(schema)).hooks();The validation plugin:
- Handles schema refinements with custom paths
- Returns errors as strings (can be integrated with i18n libraries if needed)
- Supports
z.coercefor HTML input type coercion - Error format:
{ field: 'error message' }asRecord<keyof T, string | null>
Create custom validators using registerValidator:
const customValidation = (form) => {
form.registerValidator(async (values, syncErrors) => {
// syncErrors contains results from sync validators that ran before this
// Use it to skip expensive async operations
if (syncErrors && Object.keys(syncErrors).length > 0) {
return null; // Skip if there are already errors
}
// Your async validation logic
const errors = await validateAsync(values);
return errors;
});
};
fieldwise(initialValues).use(customValidation).hooks();Fieldwise supports multiple validators that run in sequence:
const syncValidator = (form) => {
form.registerValidator((values) => {
// Sync validation (runs first)
if (!values.email) return { email: 'Required' };
return null;
});
};
const asyncValidator = (form) => {
form.registerValidator(async (values) => {
// Async validation (only runs if sync validation passes)
const available = await checkEmailAvailability(values.email);
return available ? null : { email: 'Email already taken' };
});
};
fieldwise(initialValues)
.use(zod(schema)) // Validator 1: Zod schema (sync)
.use(syncValidator) // Validator 2: Custom sync
.use(asyncValidator) // Validator 3: Async (skipped if errors exist)
.hooks();Validation flow:
- Validators are partitioned by arity (
validator.length < 2= pure,>= 2= error-dependent) - All pure validators are called and results collected (mix of sync/async)
- Sync errors from pure validators are merged
- All error-dependent validators are called with merged errors
- All async results (from both groups) are awaited in parallel
- All results are merged and emitted via
validatedevent
function RegistrationForm() {
const { fields, emit, i } = useForm();
// Show/hide based on field value
return (
<form>
<Select {...i('accountType')}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{fields.accountType.value === 'business' && (
<Input {...i('companyName')} placeholder="Company Name" />
)}
</form>
);
}Enable debug logging by setting Form.debugMode:
import { Form } from 'fieldwise';
// Log all events
Form.debugMode = true;
// Log only specific events
Form.debugMode = { only: ['reset', 'validate', 'validated'] };Debug plugin is attached automatically when debug mode is enabled.
Note: Material-UI inputs require custom wrappers since their API slightly
differs from fieldwise input interface. You'll need to create wrapper components
that adapt the i() helper props to Material-UI's expected props.
import TextField from '@mui/material/TextField';
const TextFieldWrapper = ({ name, value, onChange, error }) => (
<TextField
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
label={name}
helperText={error}
error={!!error}
/>
);
function MyForm() {
const { i } = useMyForm();
return <TextFieldWrapper {...i('email')} />;
}// ❌ Bad: Re-renders on ANY field change
const { fields } = useUserForm();
// ✅ Good: Only re-renders when email or password changes
const { fields } = useUserSlice(['email', 'password']);Fieldwise automatically batches synchronous updates:
emit('change', 'name', 'John');
emit('change', 'email', 'john@example.com');
// Both updates trigger only ONE re-renderUse emit.later() to defer validation to the microtask queue:
const handleSubmit = () => {
emit.later('validate'); // Defers to microtask
once('validated', (values, errors) => {
// Runs after all synchronous updates complete
});
};The isValidating flag helps provide feedback during async validation:
const { isValidating, emit, once, i } = useForm();
const handleSubmit = () => {
emit.later('validate');
once('validated', (values, errors) => {
if (!errors) submitForm(values);
});
};
return (
<form>
<Input {...i('email')} />
<button disabled={isValidating}>
{isValidating ? 'Validating...' : 'Submit'}
</button>
</form>
);Fieldwise is written in TypeScript and provides full type inference:
type User = {
name: string;
email: string;
age: number;
};
const { useForm } = fieldwise<User>({
name: '',
email: '',
age: 0
}).hooks();
const { fields, emit, i } = useForm();
// ✅ Type-safe
emit('change', 'name', 'John');
fields.name.value; // string
// ❌ Type errors
emit('change', 'invalid', 'value'); // Error: 'invalid' is not a valid key
emit('change', 'age', 'not a number'); // Error: expected number// Formik
const formik = useFormik({
initialValues: { email: '' },
validationSchema: schema,
onSubmit: (values) => { ... }
});
// Fieldwise
const { fields, emit, once, i } = useForm();
const handleSubmit = () => {
emit.later('validate');
once('validated', (values, errors) => {
if (!errors) onSubmit(values);
});
};// React Hook Form
const {
register,
handleSubmit,
formState: { errors }
} = useForm();
// Fieldwise
const { i, emit, once, fields } = useForm();
// Errors available at fields.fieldName.errorEach fieldwise() call creates hooks for a single form instance. To use multiple forms on the same page, create separate hook sets:
// Separate forms with different hook names
const { useForm: useUserForm } = fieldwise(userDefaults).hooks();
const { useForm: useCheckoutForm } = fieldwise(checkoutDefaults).hooks();
function MyComponent() {
const userForm = useUserForm();
const checkoutForm = useCheckoutForm();
// Each form operates independently
}This is intentional - it provides clear separation and follows patterns from other form libraries (React Hook Form, Formik).
Current Limitation: Fieldwise currently supports flat form structures only. Nested objects are not supported:
// ❌ Not currently supported
const form = fieldwise({
user: {
name: '',
address: {
street: '',
city: ''
}
}
});
// ✅ Current workaround - flatten the structure
const form = fieldwise({
userName: '',
userAddressStreet: '',
userAddressCity: ''
});Roadmap: Support for nested data structures is planned for a future release (likely 1.1 or 1.2). This will include:
- Dot notation for nested field paths (
user.address.street) - Type-safe nested field subscriptions
- Validation path mapping for nested schemas
- Nested field grouping in
useSlice()
For now, if you need complex nested structures, consider:
- Flattening your form data
- Using separate forms for nested sections
- Transforming data shape on submit
Create custom plugins to extend Fieldwise:
const myPlugin = (form) => {
// Listen to events
form.on('change', (key, value) => {
console.log(`${key} changed to ${value}`);
});
// Add custom validation
form.registerValidator((values) => {
// Custom validation logic
return null; // or errors object
});
};
fieldwise(initialValues).use(myPlugin).hooks();Contributions are welcome! Please follow these guidelines:
- Maintain zero React state in Form class
- Keep plugins composable and single-responsibility
- Add tests for new features
- Document all public API changes
MIT
Extracted from a production application managing 15+ complex forms with dynamic validation, conditional fields, and multi-step flows.