react-simple-formkit
v2.4.7
Published
Support form handling simply. Configuration is simple, fast, and efficient.
Maintainers
Readme
React Simple FormKit
A lightweight library for managing forms as uncontrolled. State updates only when actively watched. Simple and quick to configure with outstanding efficiency.
Table of Contents
- Quick start
- Highlights
- Features
- Core concepts
- Managing values
- Managing form state
- Managing field states
- Managing errors
- Grouping fields (nested object)
- Utilities
- APIs
- Examples
- Contact
Quick start
import { useForm } from "react-simple-formkit";
const { control } = useForm();
const handleSubmit = (data) => {
alert(JSON.stringify(data));
};
return (
<Form control={control} onSubmit={handleSubmit} onChange={console.log}>
<button type="submit">Submit</button>
<input required name="email" placeholder="email" />
<input required name="password" type="password" placeholder="password" />
{Array.from({ length: 10 }).map((_, index) => (
<input name={`input${index + 1}`} placeholder={`input ${index + 1}`} />
))}
</Form>
);Highlights
- 🪶 Lightweight — ~20KB, zero dependencies, supports React 17, 18, and 19
- 📦 Easy data collection — Automatically collects all input values including nested objects, just by using the
nameattribute - 📊 Comprehensive state management — Track isDirty, isTouched, errors at both form level and individual field level
- 🧠 Unified Observation — One
watch()API for values, errors, formState, and fieldStates using dot-notation syntax - 👀 Flexible watching —
watch(),useWatch(), andsubscribe()all share the same name format, with support for bothonChangeandonBlurmodes - 🏷️ Unlimited custom states — Add any custom state to fields and forms (hidden, disabled, verificationStatus, ...)
- 🔄 Flexible set & reset — Set/get individual field values, reset the entire form or per field, supports callback in setValue
- 🌳 Nested objects — Group fields into nested structures with dot-notation (
address.line1), with event bubble & trickle support
Features
Managing Values
- Auto collection — All
<input>,<select>,<textarea>with anameattribute are automatically collected on change, blur, submit. - Flexible get/set —
actions.getValues(),actions.setValue("field", value), oractions.setValue("field", prev => prev + 1)with callback support - Default values — Pass
defaultValueson initialization, automatically populates into inputs - Reset —
actions.reset()to default values, oractions.reset(newValues)to update defaults (e.g. after a successful save)
Managing Form State
isDirty— Has the form changed from its default values?isError— Does the form have any validation errors?dirtyFields— List of fields that have been modifiedtouchedFields— List of fields that have been interacted witherrorFields— List of fields that currently have errors- Custom form states — Add any custom state:
actions.setFormState("step", 2),actions.setFormState("lastSaved", new Date())
Managing Field States
isDirty,isTouched,errorfor each individual field- Custom field states — Unlimited:
actions.setFieldState("email", "hidden", true),actions.setFieldState("email", "verificationStatus", "pending") - Lazy subscription —
Controlleronly subscribes to fieldState when you actually access it in the render function
Managing Errors
- Custom errors —
actions.setError("email", "Email already exists") - Clear errors —
actions.clearError("email")oractions.clearErrors()to clear all
Watching
Three ways to observe, all sharing the same name format, with support for both onChange and onBlur modes:
| Method | Re-render | Best for |
|:-------|:----------|:---------|
| watch(name, mode) | At the component containing useForm | Simple, quick access |
| useWatch({ name, mode }) | Only at the component calling the hook | Performance isolation |
| subscribe(name, callback, mode) | No re-render | Side effects |
All three use the same unified dot-notation name format to observe values, errors, form state, and field states. See The Power of watch (Unified Observation) for details.
Grouping Fields (Nested Object)
- Automatically groups fields into nested structures using dot-notation:
<input name="address.line1" /> watch,setValue,setError,setFieldStateall work seamlessly with nested paths- Supports event bubble (propagate to parent watchers)
Core concepts
Input Field Modes
1. Uncontrolled:
Best for: High Performance.
Use Cases:
- Standard HTML inputs (e.g.
<input>,<textarea>,<select>...) - UI library components that act as simple wrappers around native elements and follow standard browser behavior
- Standard HTML inputs (e.g.
How it works: The browser manages form by default. However, you still maintain full visibility: you can watch the entire form state, field states, and values.
onBlurBehavior: Works automatically by default.
Warning: The library automatically captures changes in the
Formcomponent through bubbled events from input fields. However, if in some cases it cannot capture changes (e.g., due to a custom input ore.stopPropagation()), please useControllerso the form can work correctly.
2. Controlled:
Best for: Absolute control over input or complex UIs.
- Use Cases:
- Advanced UI components with complex INPUTS AND OUTPUTS. For example, a multiple select component expects an array as its input/output, but your form state might need to store the value as a string.
By using Controller, you can transform the value in both directions:
- On
input: split the stored string into an array to pass to the UI component. - On
change: join the selected array back into a string before updating the form state.
Note: You should explicitly pass the
onBlurprop during rendering to use blur tracking features. whenever you want to control the value of a field (e.g. by callingactions.setValue), you must wrap that field with aController. WithoutController, the field will not respond to external value changes.
Example:
<Controller
name="multipleSelect"
render={({ value = "", onChange, onBlur, name }) => {
return (
<Select
multiple
name={name}
onBlur={onBlur}
value={value.split(",")}
onChange={(e) => {
const value = e.target.value;
// value as array by default but on autofill value as string
onChange(typeof value === "string" ? value : value.join(","));
}}
>
<MenuItem value="10">Ten</MenuItem>
<MenuItem value="20">Twenty</MenuItem>
<MenuItem value="30">Thirty</MenuItem>
</Select>
);
}}
/>Example fieldState:
- Only when you get
fieldStatefrom render props,Controllerwill auto subscribe to fieldState changes. - If you need to set custom
fieldState, you can useactions.setFieldState.
<Controller
name="multipleSelect"
render={({ value = "", onChange, onBlur, name, fieldState }) => {
if (fieldState.hidden) return null;
return (
<FormControl error={Boolean(fieldState.error)}>
<Select
multiple
name={name}
onBlur={onBlur}
value={value.split(",")}
error
onChange={(e) => {
const value = e.target.value;
// value as array by default but on autofill value as string
onChange(typeof value === "string" ? value : value.join(","));
}}
>
<MenuItem value="10">Ten</MenuItem>
<MenuItem value="20">Twenty</MenuItem>
<MenuItem value="30">Thirty</MenuItem>
</Select>
<FormHelperText>{fieldState.error}</FormHelperText>
</FormControl>
);
}}
/>Watching for updates
State updates only when observed via watch(), useWatch(), subscribe(), or by tracking changes through the Form component's onChange, onBlur callbacks. You can watch Values, Form States, Field States, and Errors through the same name interface.
- watch(name, mode) will trigger a re-render at the form level.
- useWatch({ name, mode }) will trigger a re-render only in the component where it is called.
- actions.subscribe(name, callback, mode) is just a handler callback that is called when watched values change.
- onChange is just a handler callback that is called when field values change.
- onBlur is just a handler callback that is called when field blurred.
- actions.subscribe('onChange', callback) subscribe onChange callback instead of passing
onChangein Form component. - actions.subscribe('onBlur', callback) subscribe onBlur callback instead of passing
onBlurin Form component. - actions.trigger(name, {bubble, trickle}) trigger watchers (e.g.
watch,useWatch,subscribe) re-update values if needed.
Rule*: By default, onBlur works automatically for uncontrolled fields. However, for controlled fields, you must explicitly pass the onBlur prop when rendering the field. Or it can be triggered manually using
actions.triggerFieldBlur(). actions.setValue() makes onChange by default, if you call it in onChange callback so it will make infinite loop.
The Power of watch (Unified Observation)
The watch function (and useWatch, subscribe) is the "brain" of your form. Instead of having multiple hooks for different states, you can observe anything in the form using a unified dot-notation syntax:
| Target | Syntax Example |
| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| All Values | watch() |
| Specific Value | watch("email") |
| Form State | watch("formState") watch("formState.isDirty") watch("formState.isError") watch("formState.dirtyFields") watch("formState.touchedFields") watch("formState.errorFields") |
| Field States | watch("fieldStates") watch("fieldStates.email") watch("fieldStates.email.isDirty") watch("fieldStates.email.isTouched") watch("fieldStates.email.isError") watch("fieldStates.email.customState") |
| Errors | watch("errors") watch("errors.email") |
| Multiple values | watch(["email", "errors.email", "fieldStates.email", "formState.isDirty"]) |
| onBlur mode | watch("email", "onBlur") |
💡Why it matters*: This unified approach ensures you only need to learn one API to track the entire lifecycle of your form.
Managing values
Get value
const fieldName = watch("fieldName");
const fieldName2 = watch("fieldName2");
// Or
const { fieldName2, fieldName } = watch(["fieldName", "fieldName2"]);
// Or
const values = watch();
// useWatch
useWatch({ name: "fieldName" });
useWatch({ name: ["fieldName", "fieldName2"] });
useWatch({ compute: (values) => (values.number > 10 ? true : false) });
// actions.getValues()
const { actions } = useForm();
console.log(actions.getValues());Note*: watch(), useWatch(), and subscribe() share the same name format.
Set value
actions.setValue() can help to control value of a field. But that field must be controlled by Controller.
const { control, actions, watch } = useForm();
const number = watch("number");
return (
<Form control={control} onChange={console.log}>
<Controller
name="number"
render={({ name, value, onChange }) => (
<input name={name} value={value} onChange={(e) => onChange(e.target.value)} />
)}
/>
<button type="button" onClick={() => actions.setValue("number", Number(number || 0) + 1)}>
Increase
</button>
<button type="submit" disabled={!isDirty}>
Submit
</button>
</Form>
);Default values and reset
const dummyFields = Array.from({ length: 10 }).reduce(
(acc, _, index) => ({ ...acc, [`input${index + 1}`]: `input${index + 1}` }),
{},
);
const { control, watch, actions } = useForm({ defaultValues: dummyFields });
const handleSubmit = async (newValues) => {
// update to server
await new Promise((res) => setTimeout(res, 1000));
// reset with new defaultValues
actions.reset(newValues);
};
return (
<Form control={control} onSubmit={handleSubmit} onChange={console.log}>
{Object.keys(dummyFields).map((name) => (
<input name={name} placeholder={name} />
))}
<button type="reset" disabled={!isDirty}>
Reset
</button>
<button type="submit" disabled={!isDirty}>
Submit
</button>
</Form>
);Managing form state
Set form state
actions.setFormState("step", 2);
actions.setFormState("lastSaved", new Date());Get form state
const isFormDirty = watch("formState.isDirty");
const isFormError = watch("formState.isError");
const dirtyFields = watch("formState.dirtyFields");
const touchedFields = watch("formState.touchedFields");
const errorFields = watch("formState.errorFields");
const customFormState = watch("formState.custom.hello");
// actions.getFormState()
const { actions } = useForm();
console.log(actions.getFormState());
console.log(actions.getFormState("isDirty"));
console.log(actions.getFormState("custom.hello"));
console.log(actions.getFormState(["isDirty", "isError"]));Note*: watch(), useWatch(), and subscribe() share the same name format.
Managing field states
Set field states
actions.setFieldState("email", "hidden", true);
actions.setFieldState("email", "verificationStatus", "pending");Get field states
const { fieldName: {...}, fieldName2: {...} } = watch("fieldStates")
const { isDirty, isTouched } = watch("fieldStates.fieldName")
const isFieldDirty = watch("fieldStates.fieldName.isDirty")
const fieldCustomState = watch("fieldStates.fieldName.hidden")
// actions.getFieldStates()
const { actions } = useForm()
console.log(actions.getFieldStates())
console.log(actions.getFieldStates("fieldStates.fieldName.isDirty"))
console.log(actions.getFieldStates("fieldStates.fieldName.hidden"))
console.log(actions.getFieldStates(["fieldStates.fieldName.isDirty", "fieldStates.fieldName.isError"]))Note*: watch(), useWatch(), and subscribe() share the same name format.
Managing errors
Get field error
const isFormError = watch("formState.isError");
const { fieldName, fieldName2 } = watch("errors");
const fieldError = watch("errors.fieldName");
// actions.getErrors()
const { actions } = useForm();
console.log(actions.getErrors());Note*: watch(), useWatch(), and subscribe() share the same name format.
Set field error
// actions.setError()
const { control, actions, watch } = useForm();
const { isError, "errors.input1": input1Error } = watch(["isError", "errors.input1"]);
return (
<Form control={control} onChange={console.log}>
<input name="input1" />
{input1Error && <span className="error-message">{input1Error}</span>}
<button type="button" onClick={() => actions.setError("input1", "This is error message")}>
Set Error
</button>
<button type="submit" disabled={!isDirty}>
Submit
</button>
</Form>
);Grouping fields (nested object)
Use cases: When you need to group multiple fields into an object. By registering these fields with dot notation, you can manage these fieldStates, errors, values as a nested object.
Example: Suppose an address group has two fields, line1 and line2. You want to track their individual states (fieldStates, errors, values) separately, but you also need to group them under a single address object for easier management and form submission.
Registering
- Auto register when using inputs or controllers with dot notation
// uncontrolled
<input name="address.line1" />
<input name="address.line2" />
<input name="address.line3" />
// controlled
<Controller
name="address.line4"
render={({ value = "", onChange }) => {
return <input value={value} onChange={onChange} />;
}}
/>- Register manually
// 'groups' option
const { control, actions } = useForm({ groups: ["address"] });
// actions.addGroups
actions.addGroups(["address"]);Rule*: If a group isn't registered, the field will be treated as a regular field with an object type value.
Watching
const addressValue = watch("address");
const addressLine1 = watch("address.line1");
const addressLine1Error = watch("errors.address.line1");
const addressLine1FieldState = watch("fieldStates.address.line1");Updating
const { actions } = useForm();
actions.setValue("address.line1", "hello");
actions.setError("address.line1", "error message");
actions.setFieldState("address.line1", "hidden", true);
actions.setFieldState("address.line1", "disabled", true);
actions.setFieldState("address.line1", "custom", { hello: "world" });Rule*: If a
groupis registered, all the fieldState, error, and value will be stored in the bottom level fields (leaf nodes). You cannot set fieldState, error, and value for agroup(e.g. "address").
Utilities
restoreFromDotNotation(object)
Convert dot notation objects to nested objects
import { restoreFromDotNotation } from "react-simple-formkit";
const dotNotationObject = watch(["errors.email", "errors.password"]);
const nestedObject = restoreFromDotNotation(dotNotationObject);
// Output:
// {
// "errors": {
// "email": "Email is required",
// "password": "Password is required",
// }
// }APIs
useForm
Generic props:
defaultValues:ObjectExampleshouldUnRegister:BooleanDefault is false,groups:Arrayto register field groups, Example
Return:
control: contains methods and utilities to control the form.watch(name, mode):(name: String | Array | undefined, mode: "onChange" | "onBlur") => ObjectExampleactionsis an object that contains utilitiesactions.reset():(newDefaultValues?: Object, options?: { clearCustomFormStates?: Boolean, clearCustomFieldStates?: Boolean }) => voidExampleactions.getValues():(name?: String | Array) => ObjectExampleactions.getErrors():(name?: String | Array) => ObjectExampleactions.getFormState():(name?: String | Array) => ObjectExampleactions.getFieldStates():(name?: String | Array) => ObjectExampleactions.setValue():(name: String, value: Any, options?: { shouldDirty?: Boolean, shouldTouched?: Boolean }) => voidExampleactions.setError():(name: String, error: Any) => voidExampleactions.setFieldState():(name: String, property: String, value: Any) => voidExampleactions.setFormState():(name: String, value: Any) => voidactions.triggerFieldBlur(name: String, value?: Any) => void`. Used to trigger the blur event for a field manually.actions.resetFieldState():(name: String) => voidactions.resetField():(name: String) => voidactions.clearError():(name: String | Array) => voidactions.clearErrors():() => voidactions.subscribeChange():(callback) => unsubscribe: Function()actions.subscribeBlur():(callback) => unsubscribe: Function()actions.getNumberFields():() => Arrayactions.getDefaultValues():() => Objectactions.trigger(name, options):(name: String | Array, options?: {bubble?: Boolean, trickle?: Boolean}) => voidtrigger watchers (e.g.watch,useWatch,subscribe) re-update values if needed.bubble:Boolean. If true, it will trigger all parent events (e.g. trigger "address.line1" will trigger watch("address"), watch()).trickle:Boolean. If true, it will trigger all child events (e.g. trigger "address" will trigger watch("address.line1"), watch("address.line2"), etc.).
actions.addGroups():(name: String | Array) => voidExample
Form
Generic props:
control: received from useForm()onSubmit:(currentValues) => {}called when the form is submitted via a button withtype='submit'onChange:(name, value, currentValues) => {}called when any field value changesonBlur:(name, value, currentValues) => {}called when any field blurred
Controller
Generic props:
name:StringdefaultValue:AnyshouldUnRegister:BooleanDefault is falsecontrol: received from useForm(). Pass it if use Controller outside<Form>render({name, value, onChange, onBlur, fieldState})
useController
Generic props:
name:StringdefaultValue:AnyshouldUnRegister:BooleanDefault is falsecontrol: received from useForm(). Pass it if use Controller outside<Form>
Return: render arguments in <Controller>
useWatch
Generic props:
name:String | Array | undefined. If undefined, it will return all input valuesmode:"onChange" | "onBlur". Default isonChangecompute:Functionthat will calculate from form values and return a value. It will make re-render when the result changescontrol: received from useForm(). Pass it if use Controller outside<Form>
Return:
- if compute is passed: the computed value
- if name is string: value of the field
- if name is array: object of values of the fields
- if name is undefined: object of all input values
useFormContext
Return:
watch(name, mode)actions: same withuseForm().actions
watch
Arguments: watch(name, mode)
name:String | Array | undefined. If undefined, it will return all input valuesmode:"onChange" | "onBlur". Default isonChange
Return:
- same with useWatch()
subscribe
Arguments: subscribe(name, callback, mode)
name:String | Array | undefined. If undefined, it will return all input valuescallback():Functionreceive value based on name parameter. Only be called when the value changesmode:"onChange" | "onBlur". Default isonChange
Return:
unregister():Functionthat will unregister the callback
Examples
- https://codesandbox.io/p/sandbox/react-simple-formkit-examples-rhmhjj
Contact
For any ideas or issues, please contact me at [email protected]
