ForMesh: Multi-Section Forms for React, Without the Re-render Tax
Most form libraries are built around one assumption: a form is a single flat object. That assumption holds for a login form or a signup form. It stops holding the moment you build enterprise software - an employee record that is Personal Info + Job Info + Bank Info + Documents, each section owned by a different part of the UI, all of it merging into one object at submit time. I ran into that gap repeatedly while building form-heavy modules for an 8-module ERP platform, and eventually stopped working around it and built ForMesh (published as react-formesh) instead: a small, framework-agnostic form-state core for exactly that shape of form. This post walks through the problem, the architecture decisions that came out of it, and where the project stands today. It's currently in active development and not yet published to the public npm registry - the API below reflects what's implemented and tested now.
The Problem: Real Forms Aren't Flat
Take a form that's really four sections stitched together. Wiring that by hand with plain useState means
rewriting the same plumbing in every section: a sync-to-parent effect, a type-sniffing onChange handler,
validation that only checks on submit, and - this is the one that actually hurts at scale - re-renders that
cascade across the whole form on every keystroke, because everything shares one state object or one
Context.
Context makes the wiring simpler and the performance worse: every component that reads from a form
Context re-renders on any field change, regardless of which field it actually reads. On a short form that's
invisible. On a long ERP-style form with dozens of fields across several sections, it's the difference between
a form that feels instant and one that visibly lags while you type.
Design Principles
ForMesh starts from the multi-section problem instead of retrofitting a flat-form library to handle it. Five
decisions shaped the core:
- Sections are a first-class primitive, not a convention you maintain by hand. Each section owns its slice
of state; the parent always has the complete, merged object.
- Fine-grained by construction. The store only clones the branch that changed. A component subscribed
to one field never re-renders when an unrelated field changes - no memoization and no selectors to get
right.
- Debounced sync and derived fields are one primitive, not two. The same debounced-sync wrapper
fronts a whole store, a single section, or an array row, and composes straight into the same hooks as the
un-wrapped target.
- Validation is a plain function, not a schema DSL - (value, context) => message |
undefined. Use the built-ins, or write a domain rule in one line.
- Nearly zero dependencies. No lodash, no schema-validation runtime baked into the core. The core
ships at about 3.4 KB gzip, the React bindings at about 3.9 KB - you pay only for the entry points you
import.
Architecture: Structural Sharing + useSyncExternalStore
The core is a subscribable store built on useSyncExternalStore. That choice isn't cosmetic - with
concurrent rendering, different parts of the UI can read different versions of an external store during a single
render if you build your own subscription logic on top of useEffect and useState. useSyncExternalStore is
what keeps the read consistent across the whole tree, which matters a lot once a form has enough fields
that sections render at different times.
On top of that, every write only touches the objects along its own path through the tree. Unrelated branches
keep the same object reference, so a field's re-render is gated by whether its own value actually changed -
not by anything else happening in the form.
Quick Start
import { createFormStore } from "react-formesh";
import { useForm } from "react-formesh/react";
const store = createFormStore({ firstName: "", email: "" });
function ProfileForm() {
const form = useForm(store);
const firstName = form.registerField("firstName");
const email = form.registerField("email");
return (
<form onSubmit={(e) => { e.preventDefault(); console.log(form.values); }}>
<input {...firstName} />
<input {...email} />
<button type="submit" disabled={!form.isValid}>Save</button>
</form>
);
}
Multi-Section Forms
This is the case the library was actually built for. Each section is built independently and reads/writes only its
own slice - there's no manual wiring to merge them back together.
const store = createFormStore({
employeeInfo: { firstName: "", lastName: "" },
jobInfo: { department: "", designation: "" },
bankInfo: { accountNumber: "", bankName: "" },
});
function EmployeeInfoSection() {
const form = useForm(store, { section: "employeeInfo" });
return <input {...form.registerField("firstName")} />;
}
function JobInfoSection() {
const form = useForm(store, { section: "jobInfo" });
return <input {...form.registerField("department")} />;
}
// store.getValues() -> { employeeInfo: {...}, jobInfo: {...}, bankInfo: {...} }
Validation, Debounced Sync, and Derived Fields
Validators are plain, synchronous, composable functions - built-ins and custom rules use the exact same
shape:
import { required, email } from "react-formesh/validators";
const form = useForm(store, {
validation: {
fields: {
firstName: required(),
email: [required(), email()],
sku: (value, { values }) =>
values.existingSkus.includes(value) ? "SKU already exists." : undefined,
},
},
});
Debounced sync and derived fields follow the same one-wrapper-any-target idea. A whole store, a single
section, or an array row can all be wrapped the same way, and a derived field just watches a path and
reacts to real changes:
import { useDebouncedSync, useWatch } from "react-formesh/react";
const sync = useDebouncedSync(section, { delay: 300 });
const form = useForm(sync); // same hooks, buffered target
useWatch(store, "jobInfo.department", (department) => {
store.setValue("jobInfo.designation", "");
});
What It Costs
Both numbers are per tree-shakable entry point, so a project that only imports the core store and the React
bindings pays roughly 7.3 KB gzip total - validators, file handling, and array helpers are separate entry points
you only pay for if you import them.
Trade-offs, Honestly
- Context is still simpler to reach for on a short, flat form. ForMesh earns its keep specifically on
multi-section, field-dense forms - it isn't a blanket replacement for useState or Context.
- A schema-evolution question follows naturally from ERP usage: once a form's shape can change over
time, submitted records need to stay interpretable against the schema version they were created under.
That's a real design constraint the core doesn't solve by itself yet.
- Building a form library instead of adopting one is only worth it once existing options measurably fail a real,
recurring need - in this case, independent section composition with field-level re-renders and no Context
ceremony. It's a decision to be able to defend, not a default.
Where It's Going
Core store, sections, field registration, debounced sync, watch, validation, file uploads, and field arrays are
all shipped and tested today. TypeScript path autocomplete and a FormDebugger are next on the roadmap.
Closing
ForMesh came out of a specific, recurring problem in real ERP form work, not a side-project built in isolation
- and it's generalized to be useful well beyond that context (the same shape shows up in KYC onboarding,
patient intake, or ad-campaign setup). The code and tests are open on GitHub under Apache 2.0; feedback
and issues are welcome.
GitHub: github.com/mhasansagor/react-formesh
