observer-form
v0.2.0
Published
Lightweight observer-pattern form library
Readme
observer-form
A tiny, framework-agnostic form library built on the observer pattern.
Philosophy
Most form libraries are tightly coupled to a specific framework. React Hook Form needs React. Angular Reactive Forms need Angular. Formik, VeeValidate, and others each lock you into an ecosystem and ship kilobytes of runtime code to do it.
observer-form takes a different approach:
- The observer pattern, not framework magic. Fields publish changes, observers subscribe. It is a well-understood design pattern that works the same way regardless of what renders your UI.
- Zero runtime dependencies. Only TypeScript and Vite are used at build time. Nothing is shipped to the consumer beyond the library itself.
- Native browser APIs.
MutationObserverdetects when inputs or forms leave the DOM.AbortControllermanages event listener lifecycles. Standardinputevents drive state updates. No polyfills, no abstraction layers. - Automatic cleanup. When an input is removed from the DOM, its event listeners and subscriptions are torn down automatically. When the entire form is removed, everything is cleaned up. You do not need to manage lifecycle manually.
- Minimal surface area. One function (
createForm) and one concept (observers). The entire library is ~250 lines of TypeScript with an ESM-only build, tree-shaking enabled, andsideEffects: false.
The result is a form state layer that weighs almost nothing, runs anywhere there is a DOM, and gets out of your way.
Installation
npm install observer-formpnpm add observer-formyarn add observer-formQuick Start
import { createForm } from 'observer-form';
const form = createForm({
initialValues: { name: '', email: '' },
onSubmit: () => console.log('Submitted:', form.state),
config: {},
});
// Register inputs by passing DOM elements
document.querySelectorAll('form input').forEach((input) => {
form.registerField(input);
});
// Subscribe to field changes
form.subscribe('email', {
update: ({ name, value }) => {
console.log(`${name} changed to: ${value}`);
},
});
// Read state at any time
console.log(form.state.email);API Reference
createForm(options): FormApi
The single entry point. Creates a form instance and returns the API to interact with it.
import { createForm } from 'observer-form';
import type { FormOptions } from 'observer-form';
const form = createForm(options);FormOptions
| Property | Type | Required | Description |
|---|---|---|---|
| initialValues | Record<string, any> | Yes | Default values for each field, keyed by field name. |
| onSubmit | () => void | Yes | Callback invoked on form submission. |
| config | {} | Yes | Configuration object for the form. |
| validation | () => void | No | Validation function. |
| triggerValidation | () => void | No | Manually trigger validation. |
| formFields | Record<string, any> | No | Field-level configuration. |
| formState | Record<string, any> | No | Additional form state. |
FormApi
The object returned by createForm.
state
form.state; // Record<string, any>A plain object holding the current value of every registered field. Updated automatically on every input event. Read it at any time to get the latest values.
registerField(input: HTMLInputElement)
form.registerField(inputElement);Binds a DOM input element to the form. The element's name attribute determines which key in state it maps to. Once registered:
- The
inputevent listener updatesstate[name]and notifies all subscribers for that field. - A
MutationObserverwatches for the element's removal from the DOM and cleans up automatically.
subscribe(fieldName: string, observer: Observer)
form.subscribe('email', {
update: ({ name, value }) => {
// called whenever the "email" field changes
},
});Adds an observer that is called every time the specified field changes. Multiple observers can subscribe to the same field. The same observer instance will not be added twice (internally stored in a Set).
unsubscribe(fieldName: string, observer: Observer)
form.unsubscribe('email', observer);Removes a previously added observer for the specified field.
notify(data: NotifyData)
form.notify({ name: 'email', value: '[email protected]' });Manually triggers all observers for a field. Useful for programmatic updates that bypass DOM events.
Types
type Observer = {
update: (data: NotifyData) => void;
};
type NotifyData = {
name: string;
value: string;
};Framework Examples
Since observer-form works with the DOM directly, it integrates with any framework. The pattern is always the same: get a reference to the DOM input, call registerField, and clean up when the component unmounts.
Vanilla JavaScript
<form id="signup">
<input name="name" type="text" placeholder="Name" />
<input name="email" type="email" placeholder="Email" />
<button type="submit">Sign Up</button>
</form>
<p id="preview"></p>
<script type="module">
import { createForm } from 'observer-form';
const form = createForm({
initialValues: { name: '', email: '' },
onSubmit: () => console.log('Submitted:', form.state),
config: {},
});
document.querySelectorAll('#signup input').forEach((input) => {
form.registerField(input);
});
form.subscribe('name', {
update: ({ value }) => {
document.getElementById('preview').textContent = `Hello, ${value}`;
},
});
</script>React
import { useEffect, useRef } from 'react';
import { createForm } from 'observer-form';
export default function SignupForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const formRef = useRef(null);
useEffect(() => {
const form = createForm({
initialValues: { name: '', email: '' },
onSubmit: () => console.log('Submitted:', form.state),
config: {},
});
formRef.current = form;
if (nameRef.current) form.registerField(nameRef.current);
if (emailRef.current) form.registerField(emailRef.current);
form.subscribe('name', {
update: ({ value }) => console.log('Name:', value),
});
// Cleanup is automatic when inputs leave the DOM,
// but you can also read form.state on unmount if needed.
}, []);
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form state:', formRef.current?.state);
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} name="name" type="text" placeholder="Name" />
<input ref={emailRef} name="email" type="email" placeholder="Email" />
<button type="submit">Sign Up</button>
</form>
);
}How It Works
observer-form is composed of four internal modules wired together by createForm:
registerField(input)
|
v
input event fires
|
v
state[fieldName] = value
|
v
notifier.notify({ name, value })
|
v
subscriberStore.get(name)
|
v
observer.update({ name, value }) <-- your callback- SubscriberStore -- a
Map<string, Set<Observer>>that holds per-field subscriptions. Deduplicates observers automatically. - Notifier -- looks up observers by field name and calls
update()on each. - FieldRegistry -- binds each
HTMLInputElementto state updates and notifications via theinputevent. - CleanupManager -- uses
AbortControllerto tear down event listeners andMutationObserverto detect when inputs or the form are removed from the DOM, cleaning up everything automatically.
