For a long time, I treated form libraries like a black box.
I knew they were fast. I knew they somehow avoided unnecessary re-renders. But I never really understood why.
One day I asked myself a simple question:
Could I build a tiny version myself using only React?
Not because I wanted to replace React Hook Form.
Not because I planned to publish another form library.
Simply because I wanted to understand the architectural decisions behind them.
That question eventually led me down the rabbit hole of external stores, pub-sub systems, and useSyncExternalStore.
The Problem
Managing forms in React looks straightforward until the form starts growing.
A login form with two inputs is easy.
A production form with conditional fields, validation, asynchronous submission, dynamic arrays, previews, and dozens of interconnected inputs is an entirely different problem.
The obvious implementation usually starts like this:
const [values, setValues] = useState({ username: "", password: "", });
or
const [state, dispatch] = useReducer(reducer, initialState);
Both work.
Until they don't.
Every state update propagates through React's rendering model, meaning components that don't actually care about the changed field often participate in another render cycle.
For small forms this doesn't matter.
For complex forms, it absolutely does.
What I Wanted
I wasn't trying to build a production-ready form library.
I wanted something much simpler.
A form system where:
- Updating one field shouldn't re-render the entire form.
- Components should subscribe only to the state they actually need.
- Form logic should live outside the UI.
- React should become the rendering layer—not the state container.
Rethinking the Architecture
Instead of storing everything inside React state, I moved the form state into a tiny external store.
The store itself knows nothing about React.
It only knows how to:
- hold state
- update state
- notify subscribers
- reset itself
The implementation is surprisingly small.
export class Store<T> { private state: T; private listeners = new Set<() => void>(); constructor(state: T) { this.state = structuredClone(state); } getState() { return Object.freeze(this.state); } setState(updater: (prev: T) => T) { this.state = updater(this.state); this.listeners.forEach((listener) => listener()); } subscribe(listener: () => void) { this.listeners.add(listener); return () => this.listeners.delete(listener); } }
Notice something interesting here.
There is nothing React-specific.
It's just JavaScript.
Connecting the Store to React
The missing piece was allowing React components to subscribe safely.
React 18 introduced exactly the API for this:
subscribe: function <S>(selector: (state: FormState<T>) => S) { return useSyncExternalStore( store.subscribe.bind(store), () => selector(store.getState()) ); }
Instead of subscribing to the entire form, every component subscribes only to the slice of state it needs.
For example:
const value = subscribe((s) => s.values.username);
or
const isSubmitting = subscribe((s) => s.isSubmitting);
Whenever another part of the form changes, these components simply don't re-render because their selected state hasn't changed.
This is the same architectural idea you'll find in many modern state management libraries.
Building the Field Component
The Field component subscribes to only two things:
- its own value
- its own validation error
const value = subscribe((s) => s.values[name]); const error = subscribe((s) => s.errors[name]);
Updating the password field doesn't cause the username field to render again.
Each field becomes independently reactive.
Selective Rendering with Subscribe
Not every component represents a field.
Some components only care about global form state.
For example, the submit button only needs to know whether the form is submitting.
<form.Subscribe selector={(s) => s.isSubmitting}> {(isSubmitting) => ( <button disabled={isSubmitting}> {isSubmitting ? "Submitting..." : "Submit"} </button> )} </form.Subscribe>
It doesn't subscribe to field values.
It doesn't know about validation.
It only reacts when isSubmitting changes.
Putting Everything Together
The final API ended up looking surprisingly clean.
<form.Field name="username"> {({ value, onChange, error }) => ( <> <input value={value} onChange={(e) => onChange(e.target.value)} /> {error && <span>{error}</span>} </> )} </form.Field> <form.Subscribe selector={(s) => s.values.askToFillAddress}> {(show) => show && ( <form.Field name="address"> {({ value, onChange }) => ( <input value={value} onChange={(e) => onChange(e.target.value)} /> )} </form.Field> ) } </form.Subscribe>
I particularly liked how the Subscribe component made conditional rendering feel declarative while still avoiding unnecessary renders.
Patterns Behind the Implementation
Although the project is fairly small, it naturally evolved around several common software design patterns.
- External Store using
useSyncExternalStore - Publish–Subscribe for state notifications
- Selector-based subscriptions for granular rendering
- Render Props for
FieldandSubscribe - Separation of concerns between state management and presentation
None of these ideas are new individually.
What I found interesting was how naturally they fit together.
What I Learned
The biggest takeaway wasn't learning useSyncExternalStore.
It was realizing that performance is often a consequence of architecture rather than optimization.
It's tempting to reach for React.memo, useMemo, or useCallback whenever a form starts feeling slow.
But those techniques optimize an architecture that still causes unnecessary updates.
Changing the architecture so components only subscribe to the state they actually care about removes many of those problems entirely.
That shift in thinking was the most valuable part of this experiment.
What's Missing
This project intentionally stays small.
A production-ready form library would still need features like:
- Nested object support
- Dynamic field arrays
- Async validation
- Dirty & touched tracking
- Watchers
- Better batching
- Plugin architecture
- DevTools
Those problems are significantly more interesting than building the store itself, and they're what make mature form libraries so impressive.
Final Thoughts
This project was never about replacing React Hook Form.
It was about understanding why libraries like React Hook Form, Final Form, and TanStack Form are designed the way they are.
Building even a simplified version gave me a much deeper appreciation for the architectural trade-offs involved.
Sometimes the best way to understand a library isn't reading its documentation.
It's trying to build one yourself.
Source Code
The snippets in this article focus on the core architectural ideas and intentionally omit some implementation details for brevity.
If you'd like to explore the complete implementation—including the external store, custom useForm hook, TypeScript types, and the working demo—you can find everything here:
🔗 CodeSandbox: https://codesandbox.io/p/sandbox/romantic-sunset-f6rcjr