Hasan's Journal

Stories, lessons, and scars from production.

Mehedi Hasan
Back to blog

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.

#DNS#WebDev#DevOps#Architecture

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