formmeld
v0.1.2
Published
Uncontrolled form ownership for React and React Native-compatible applications
Readme
Formmeld
Formmeld gives one object ownership of form values while inputs remain uncontrolled. It works with React DOM and React Native-compatible input adapters, without putting DOM or React Native APIs in its value core.
Install
npm install formmeldReact 18 or 19 must be installed by the consuming application.
Ownership model
FormInputs is the source of truth for collected and programmatically assigned
values. A mounted field registers an imperative applyValue adapter:
- If the form already owns the name, registration applies that value to the
field immediately unless it is
Object.isequal to the field'sinitialValue. This avoids rewriting an uncontrolled field during React StrictMode's setup/cleanup/setup replay. - Otherwise,
initialValueinitializes form ownership without applying it back to the registering field. form.setValue(name, value)stores the value and applies it to every mounted registration. Programmatically assigned values remain owned across field unmounts and are applied if a field later registers that name.- A registration's
setValue(value)represents user input. It updates the form and peer registrations while skipping its own applier. unregister()is idempotent. Writes through an unregistered handle do nothing. Unregistering one of several same-name fields preserves the shared value. Unregistering the final field removes a value last written by a field, but preserves a value last written throughform.setValue.unsetValue(name)removes ownership and appliesundefinedto mounted fields.
Multiple mounted fields with the same name are supported and synchronized in registration order.
API
import {
Form,
FormContext,
FormInputs,
useFieldRegistration,
useForm,
useOptionalFieldRegistration,
useOptionalForm
} from "formmeld"FormInputs
getValue(name)returns the currently owned value.setValue(name, value)stores and programmatically applies a value.setValueWithHidden(name, value)uses the same storage and propagation path, then returnsReact.createElement("input", {name, type: "hidden", value}). Nullish values become""in the element. This explicit compatibility helper is universal: it does not inspect the platform, mount the element, or require React DOM or React Native.unsetValue(name)removes and programmatically unsets a value.asObject()converts bracketed names into an object usingform-data-objectizersemantics. For example,project[contributors][0][name]becomes nested objects with a"0"key. Flat fields named__proto__,constructor, orprototypeare emitted as safe own properties. Those names inside bracket paths are rejected byform-data-objectizerto prevent prototype mutation.submit()calls the currentonSubmitcallback and returns its result.registerField(name, {applyValue, initialValue})returns{setValue, unregister}.
React API
Passing a directly-owned instance to <Form form={form}> is the preferred
modern path. useForm() reads it, and throws when used outside Form.
useFieldRegistration() handles registration cleanup and returns a stable
{setValue} handle. Registration uses a layout effect, so a later layout effect
in the same component can write through the handle on mount and after a
name/form replacement. Layout effects do not run during server rendering, and
registration does not require DOM APIs. For compatibility, omitting form
makes Form create and retain one local FormInputs instance for its mounted
lifetime.
Reusable inputs that may render outside a form can use useOptionalForm(),
which returns the current FormInputs or null. They can register through
useOptionalFieldRegistration(name, options), where name may be a string,
null, or undefined. The returned {setValue} handle remains stable and its
write is a safe no-op until both a form and a non-empty string name are present.
It automatically activates, unregisters, and retargets as the surrounding form
or name changes. Use the strict hooks when form membership and a valid name are
required.
Form also supports the migration props from API Maker:
formObjectRefis assigned the selectedFormInputsafter commit and reset tonullduring cleanup.setForm(form)runs after commit.formRefis forwarded when an HTML form is rendered.htmlFormPropsis spread onto that HTML form.onSubmitbecomes the selectedFormInputssubmit callback.
Set useHtmlForm={true} to render a web <form>. Its submit event is prevented
and delegated to form.submit(). useHtmlForm defaults strictly to false;
otherwise Form renders only its context provider, which is suitable for React
Native-compatible trees. Calling setValueWithHidden does not change this
rendering behavior.
Form and FormProps are generic over the optional rendered form element.
React DOM consumers can use Form<HTMLFormElement>-compatible refs and HTML
form props, while the default element type is unknown so declarations remain
importable in projects without the DOM library.
API Maker does not provide a compatibility re-export. Existing consumers must
add formmeld and import the form API from this package directly.
React Native TextInput adapter
The adapter remains uncontrolled: defaultValue initializes the native
control, while Formmeld owns collected values.
import {useRef} from "react"
import {TextInput} from "react-native"
import {useOptionalFieldRegistration} from "formmeld"
function NameInput({defaultValue = "", name}) {
const inputRef = useRef(null)
const field = useOptionalFieldRegistration(name, {
initialValue: defaultValue,
applyValue(value) {
inputRef.current?.setNativeProps({
text: value == null ? "" : String(value)
})
}
})
return (
<TextInput
defaultValue={defaultValue}
onChangeText={field.setValue}
ref={inputRef}
/>
)
}Programmatic changes such as
form.setValue("profile[name]", "Kasper") update the mounted native control.
Typing uses the registration handle, so the originating control is not written
back to and its cursor is not reset.
Web input adapter
import {useRef} from "react"
import {useOptionalFieldRegistration} from "formmeld"
function EmailInput({defaultValue = "", name}) {
const inputRef = useRef(null)
const field = useOptionalFieldRegistration(name, {
initialValue: defaultValue,
applyValue(value) {
if (inputRef.current) {
inputRef.current.value = value == null ? "" : String(value)
}
}
})
return (
<input
defaultValue={defaultValue}
name={name}
onInput={(event) => field.setValue(event.currentTarget.value)}
ref={inputRef}
/>
)
}Create and own the form instance above the component:
const form = useMemo(() => new FormInputs(), [])
return (
<Form form={form} onSubmit={() => save(form.asObject())} useHtmlForm>
<EmailInput name="account[email]" />
<button type="submit">Save</button>
</Form>
)License
MIT
