Managed value history
Import createHistoryMiddleware from form-please/history when users need to
go back to an earlier schema input. History is optional and does not create
another live form store. React Hook Form (RHF) remains the owner of current values and
form state. See Ownership.
Configure one history feature
Create the feature outside the component, put that exact reference in the
definition's fixed middleware list, and pass the form and feature to
useHistory:
const historyFeature = createHistoryMiddleware({ limit: 50 })
const historyDefinition = nativeFormKit.defineForm(
historySchema,
(ui) => [
ui.field("name", { control: "text", label: "Name" }),
ui.array("projects", {
children: (project) => [
project.field("title", {
control: "text",
label: "Project title",
}),
],
itemDefault: { title: "" },
label: "Projects",
}),
],
{ middleware: [historyFeature] },
)
function useProfileHistory() {
const form = nativeFormKit.useForm(historyDefinition, {
defaultValues: { name: "Ada Lovelace", projects: [] },
})
const history = useHistory(form, historyFeature)
return { form, history }
}The returned handle includes a reactive snapshot with canUndo, canRedo,
index, and length. It also keeps the underlying getSnapshot() and
subscribe() external-store operations.
One feature can serve several forms. Each form can configure only one history
feature. useHistory(form, feature) and the lower-level feature.handle(form)
reject a form that does not use that exact feature.
Know what history records
History records successful managed value updates from generated controls,
generated array actions, and form.update. It retains complete independent
schema-input snapshots rather than patches or events.
Initial values, native reset, direct form.api mutations, and
application-owned useFieldArray operations bypass history. When raw values
diverge, the next managed update or history operation makes those current
values a new non-undoable boundary and discards the older branches.
History never retains errors, touched state, validation, submission, focus, context, or the default-value baseline. Restore keeps the current RHF metadata, recalculates dirty state against the original defaults, and may regenerate private field-array row IDs.
Group and retain positions
Consecutive control edits to one path share a group for 750 milliseconds by
default. Another source, another path, or an expired window starts a group. Set
groupWindow: 0 to make every managed update separately undoable.
limit defaults to 100 retained groups. Set another non-negative integer or
Infinity. A limit of zero retains only the current position.
Navigate through middleware
undo(), redo(), seek(index), and import(value) return promises. Their
managed transaction source has type: "history" and an undo, redo, seek,
or import action. beforeUpdate and middleware can cancel or transform the
target, and afterUpdate observes the final commit.
Call undo() and redo() from your own controls. Use snapshot.canUndo and
snapshot.canRedo to disable a control that has no target. The example waits
for each operation and shows its result or its rejection:
async function describeOperation(
label: string,
operation: Promise<HistoryOperationResult>,
): Promise<string> {
try {
return `${label}: ${await operation}`
} catch (error) {
if (error instanceof Error) {
return `${label} failed: ${error.message}`
}
return `${label} failed`
}
}
function UndoRedoButtons({
history,
onStatus,
}: {
readonly history: UseHistoryResult<HistoryInput>
readonly onStatus: (status: string) => void
}) {
const { snapshot } = history
return (
<>
<button
disabled={!snapshot.canUndo}
onClick={() =>
void describeOperation("Undo", history.undo()).then(onStatus)
}
type="button"
>
Undo
</button>
<button
disabled={!snapshot.canRedo}
onClick={() =>
void describeOperation("Redo", history.redo()).then(onStatus)
}
type="button"
>
Redo
</button>
</>
)
}Operations report "applied", "unavailable", "cancelled", or
"transformed". A transformed restore becomes a new group and removes its
redo branch. A post-commit failure rejects the operation without rolling back
the committed values or history position.
Export and import an in-memory journal
Export from one handle and import into another handle:
export async function copyHistoryJournal(
source: HistoryHandle<HistoryInput>,
target: HistoryHandle<HistoryInput>,
): Promise<HistoryOperationResult> {
const journal = source.export()
return target.import(journal)
}HistoryJournal<Input> version 1 contains complete input entries and a numeric
current index. Import validates its protocol, index, entry roots, and configured
retention limit. It does not require Standard Schema success because editable
history can contain temporarily invalid values.
The journal is an in-memory navigation artifact, not a JSON persistence format
or audit log. Date and RegExp leaves are detached. Browser-owned values such
as File and Blob preserve their identity. Applications own transport,
serialization, storage, and trust boundaries.