npm.io
0.2.0 • Published 6d ago

observer-form

Licence
MIT
Version
0.2.0
Deps
0
Size
27 kB
Vulns
0
Weekly
381

observer-form

npm version bundle size license

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. MutationObserver detects when inputs or forms leave the DOM. AbortController manages event listener lifecycles. Standard input events 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, and sideEffects: 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-form
pnpm add observer-form
yarn add observer-form

Quick 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 input event listener updates state[name] and notifies all subscribers for that field.
  • A MutationObserver watches 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: 'new@example.com' });

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 HTMLInputElement to state updates and notifications via the input event.
  • CleanupManager -- uses AbortController to tear down event listeners and MutationObserver to detect when inputs or the form are removed from the DOM, cleaning up everything automatically.

License

MIT

Keywords