svelte-simple-form
Version:
A lightweight, **type-safe**, and **reactive** form state management hook for **Svelte 5**, featuring:
625 lines (624 loc) • 23.1 kB
JavaScript
// =====================
// type
// =====================
// =====================
// helper
// =====================
function setByPath(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (/^\d+$/.test(part)) {
const index = Number(part);
if (!Array.isArray(current))
throw new Error(`Expected array at "${part}"`);
if (!current[index])
current[index] = {};
current = current[index];
}
else {
if (current[part] === undefined)
current[part] = {};
current = current[part];
}
}
const last = parts[parts.length - 1];
if (/^\d+$/.test(last))
current[Number(last)] = value;
else
current[last] = value;
}
function getValueByPath(obj, path) {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
const isIndex = /^\d+$/.test(part);
if (isIndex) {
if (!Array.isArray(current))
return undefined;
current = current[Number(part)];
}
else {
if (current == null || !(part in current))
return undefined;
current = current[part];
}
}
return current;
}
function arrayInsert(arr, index, value) {
const clone = arr.slice();
clone.splice(index, 0, value);
return clone;
}
function arrayRemove(arr, index) {
const clone = arr.slice();
clone.splice(index, 1);
return clone;
}
function arraySwap(arr, i, j) {
const clone = arr.slice();
const tmp = clone[i];
clone[i] = clone[j];
clone[j] = tmp;
return clone;
}
function arrayMove(arr, from, to) {
const clone = arr.slice();
const item = clone.splice(from, 1)[0];
clone.splice(to, 0, item);
return clone;
}
function shiftRecordKeys(record, path, shiftFn) {
const prefix = path + '.';
const out = {};
for (const key of Object.keys(record)) {
if (!key.startsWith(prefix)) {
out[key] = record[key];
continue;
}
const remainder = key.slice(prefix.length);
const match = remainder.match(/^(\d+)(.*)$/);
if (!match) {
out[key] = record[key];
continue;
}
const oldIndex = Number(match[1]);
const tail = match[2];
const newIndex = shiftFn(oldIndex);
if (newIndex === null)
continue;
const newKey = prefix + newIndex + tail;
out[newKey] = record[key];
}
return out;
}
// =====================
// core
// =====================
import { tick, untrack } from 'svelte';
// ---------- useForm ----------
export function useForm(props) {
const { initialValues, onSubmit, onReset, onSubmitError } = props;
const form = $state({
initialValues,
data: initialValues,
isSubmitting: false,
reset() {
(async () => {
form.data = structuredClone($state.snapshot(form.initialValues));
await tick();
form.isSubmitting = false;
await tick();
onReset?.();
})();
},
async submit(callback) {
form.isSubmitting = true;
try {
try {
if (callback)
await callback(form.data);
else if (onSubmit)
await onSubmit($state.snapshot(form.data));
}
catch (e) {
onSubmitError?.(e);
}
}
finally {
await tick();
form.isSubmitting = false;
}
},
handler(node) {
node.addEventListener('submit', (e) => {
e.preventDefault();
form.submit();
});
}
});
return { form };
}
// ---------- useFormControl ----------
export function useFormControl(props) {
const { initialValues, validator, validateOn = ['change', 'blur', 'submit'], validateAfter = 'touched-and-dirty', validateDebounce = 100, onSubmit, onReset, onSubmitErrorValidation, onSubmitError } = props;
// Track reset generation to prevent pending validations from overwriting reset state
let resetGeneration = $state(0);
// Track debounce timers to prevent API hammering on rapid keystrokes
const debounceTimers = {};
let activeRequests = $state(0);
const form = $state({
initialValues,
data: initialValues,
errors: {},
touched: {},
dirty: {},
isValid: true,
isValidating: false,
isSubmitting: false,
isDirty: false,
reset() {
(async () => {
// Increment resetGeneration to cancel pending validations
resetGeneration++;
form.data = structuredClone($state.snapshot(form.initialValues));
await tick();
form.errors = {};
form.touched = {};
form.dirty = {};
form.isValid = true;
form.isDirty = false;
form.isSubmitting = false;
await tick();
onReset?.();
})();
},
resetField(path) {
setByPath(form.data, path, getValueByPath(form.initialValues, path));
form.touched[path] = false;
form.dirty[path] = false;
},
async submit(callback) {
if (form.isSubmitting)
return;
form.isSubmitting = true;
try {
if (validator && validateOn.includes('submit')) {
// @ts-ignore
if (!(await validator.validateForm(form))) {
onSubmitErrorValidation?.(form.errors);
return;
}
}
// Guard: form.isValid is updated via $effect (async),
// but form.errors is mutated synchronously via $state.
// Check both to catch sync setError() + submit() patterns.
const currentErrors = form.errors;
const hasErrors = Object.values(currentErrors).some((e) => e != null && e.length > 0);
if (hasErrors || !form.isValid) {
if (hasErrors)
onSubmitErrorValidation?.(form.errors);
return;
}
try {
if (callback)
await callback(form.data);
else if (onSubmit)
await onSubmit($state.snapshot(form.data));
}
catch (e) {
onSubmitError?.(e, form.errors);
}
}
finally {
await tick();
form.isSubmitting = false;
}
},
setInitialValues: (values, props = {}) => {
const { reset = false } = props;
const v = structuredClone($state.snapshot({ ...values }));
form.initialValues = v;
if (reset)
form.reset();
},
setData: createSetData(),
setIsValid(isValid) {
form.isValid = isValid;
},
setIsValidating(isValidating) {
form.isValidating = isValidating;
},
setTouched(field, value = true) {
form.touched[field] = value;
},
removeTouched(field) {
delete form.touched[field];
},
setDirty(field, value = true) {
form.dirty[field] = value;
},
removeDirty(field) {
delete form.dirty[field];
},
arrayAdd(path, value, idx = undefined, opts = {}) {
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts;
const arr = (getValueByPath(form.data, path) || []);
const index = idx !== undefined ? idx : arr.length;
setByPath(form.data, path, arrayInsert(arr, index, value));
form.touched = shiftRecordKeys(form.touched, path, (old) => (old >= index ? old + 1 : old));
form.dirty = shiftRecordKeys(form.dirty, path, (old) => (old >= index ? old + 1 : old));
form.errors = shiftRecordKeys(form.errors, path, (old) => (old >= index ? old + 1 : old));
if (shouldTouch)
setByPath(form.touched, path, true);
if (shouldDirty)
setByPath(form.dirty, path, true);
if (validator && shouldValidate)
safeValidateField(path);
},
arrayRemove(path, index, opts = {}) {
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts;
const arr = getValueByPath(form.data, path);
setByPath(form.data, path, arrayRemove(arr, index));
form.touched = shiftRecordKeys(form.touched, path, (old) => old === index ? null : old > index ? old - 1 : old);
form.dirty = shiftRecordKeys(form.dirty, path, (old) => old === index ? null : old > index ? old - 1 : old);
form.errors = shiftRecordKeys(form.errors, path, (old) => old === index ? null : old > index ? old - 1 : old);
if (shouldTouch)
setByPath(form.touched, path, true);
if (shouldDirty)
setByPath(form.dirty, path, true);
if (validator && shouldValidate)
safeValidateField(path);
},
arraySwap(path, i, j, opts = {}) {
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts;
const arr = getValueByPath(form.data, path);
setByPath(form.data, path, arraySwap(arr, i, j));
form.touched = shiftRecordKeys(form.touched, path, (old) => old === i ? j : old === j ? i : old);
form.dirty = shiftRecordKeys(form.dirty, path, (old) => old === i ? j : old === j ? i : old);
form.errors = shiftRecordKeys(form.errors, path, (old) => old === i ? j : old === j ? i : old);
if (shouldTouch)
setByPath(form.touched, path, true);
if (shouldDirty)
setByPath(form.dirty, path, true);
if (validator && shouldValidate)
safeValidateField(path);
},
arrayMove(path, from, to, opts = {}) {
if (from === to)
return;
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts;
const arr = getValueByPath(form.data, path);
setByPath(form.data, path, arrayMove(arr, from, to));
const shiftFn = (old) => {
if (old === from)
return to;
if (from < to && old > from && old <= to)
return old - 1;
if (from > to && old >= to && old < from)
return old + 1;
return old;
};
form.touched = shiftRecordKeys(form.touched, path, shiftFn);
form.dirty = shiftRecordKeys(form.dirty, path, shiftFn);
form.errors = shiftRecordKeys(form.errors, path, shiftFn);
if (shouldTouch)
setByPath(form.touched, path, true);
if (shouldDirty)
setByPath(form.dirty, path, true);
if (validator && shouldValidate)
safeValidateField(path);
},
arrayRemoveBy(path, predicate, opts = {}) {
const arr = (getValueByPath(form.data, path) || []);
const index = arr.findIndex(predicate);
if (index !== -1) {
this.arrayRemove(path, index, opts);
}
else {
console.warn(`arrayRemoveBy: No item found matching predicate at path "${path}"`);
}
},
arrayUpdateBy(path, predicate, value, opts = {}) {
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts;
const arr = (getValueByPath(form.data, path) || []);
const index = arr.findIndex(predicate);
if (index !== -1) {
const currentItem = arr[index];
const newValue = typeof value === 'function' ? value(currentItem) : value;
const newArr = arr.slice();
newArr[index] = newValue;
setByPath(form.data, path, newArr);
const itemPath = `${path}.${index}`;
if (shouldTouch)
form.touched[itemPath] = true;
if (shouldDirty)
updatePathDirty(itemPath, newValue);
if (validator && shouldValidate)
safeValidateField(path);
}
else {
console.warn(`arrayUpdateBy: No item found matching predicate at path "${path}"`);
}
},
setErrors(errors) {
form.errors = structuredClone(errors);
},
setError(field, error) {
form.errors[field] = Array.isArray(error) ? error : [error];
},
removeError(field) {
delete form.errors[field];
},
async validateField(field, force = false) {
if (validator) {
return await validator.validateField(field, form, force, {
validateOn,
validateAfter,
validateDebounce
});
}
return true;
},
async validate() {
if (validator)
return await validator.validateForm(form);
return true;
},
handler(node) {
node.addEventListener('submit', (e) => {
e.preventDefault();
form.submit();
});
}
});
// =====================
// internal helper
// =====================
function createSetData() {
function setData(arg1, arg2, arg3) {
if (typeof arg1 === 'object') {
const { shouldValidate = false } = (arg2 || {});
form.data = structuredClone($state.snapshot({ ...arg1 }));
if (validator && shouldValidate)
validator.validateForm(form);
}
else {
const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = (arg3 || {});
//
setByPath(form.data, arg1, arg2);
if (shouldTouch)
setByPath(form.touched, arg1, true);
if (shouldDirty)
updatePathDirty(arg1, arg2);
if (validator && shouldValidate)
safeValidateField(arg1, true);
}
}
return setData;
}
function readValue(el, path) {
const type = el.type;
const tag = el.tagName.toLowerCase();
if (type === 'file') {
if (el.multiple)
return Array.from(el.files || []);
return el.files?.[0] ?? null;
}
if (tag === 'select') {
if (el.multiple) {
return Array.from(el.selectedOptions).map((o) => o.value);
}
return el.value;
}
if (type === 'checkbox') {
const val = getValueByPath(form.data, path);
if (Array.isArray(val)) {
if (el.checked) {
return val.includes(el.value) ? val : [...val, el.value];
}
else {
return val.filter((v) => v !== el.value);
}
}
return el.checked;
}
if (type === 'radio') {
return el.checked ? el.value : getValueByPath(form.data, path);
}
if (tag === 'div' && el.isContentEditable) {
return el.innerText;
}
return el.value;
}
function writeValue(el, value) {
const type = el.type;
const tag = el.tagName.toLowerCase();
if (type === 'file')
return;
if (tag === 'select') {
if (el.multiple && Array.isArray(value)) {
Array.from(el.options).forEach((opt) => {
opt.selected = value.includes(opt.value);
});
}
else {
el.value = value ?? '';
}
return;
}
if (type === 'checkbox') {
if (Array.isArray(value))
el.checked = value.includes(el.value);
else
el.checked = Boolean(value);
return;
}
if (type === 'radio') {
el.checked = el.value === value;
return;
}
if (tag === 'div' && el.isContentEditable) {
if (el.innerText !== value) {
el.innerText = value ?? '';
}
return;
}
el.value = value ?? '';
}
function updatePathDirty(path, value) {
const initial = getValueByPath(form.initialValues, path);
const isPathDirty = JSON.stringify(initial) !== JSON.stringify(value);
if (isPathDirty)
form.dirty[path] = true;
else
delete form.dirty[path];
}
function safeValidateField(path, force = false) {
if (!validator)
return;
const rules = {
dirty: () => form.dirty[path],
touched: () => form.touched[path],
'touched-or-dirty': () => form.touched[path] || form.dirty[path],
'touched-and-dirty': () => form.touched[path] && form.dirty[path]
};
const rule = rules[validateAfter];
const shouldValidate = force || (rule && rule());
if (shouldValidate) {
// Fix: Debounce validation if triggered by change event
const isChangeEvent = validateOn.includes('change') && !force;
const shouldDebounce = validateDebounce > 0 && isChangeEvent;
if (shouldDebounce) {
// Clear existing debounce timer for this field
if (debounceTimers[path]) {
clearTimeout(debounceTimers[path]);
}
// Set new debounce timer
debounceTimers[path] = setTimeout(() => {
executeValidation(path);
delete debounceTimers[path];
}, validateDebounce);
}
else {
// Validate immediately (no debounce)
executeValidation(path);
}
}
}
function executeValidation(path) {
if (!validator)
return;
const currentResetGen = resetGeneration;
activeRequests++;
form.isValidating = true;
Promise.resolve(validator.validateField(path, form, true, { validateOn, validateAfter, validateDebounce })).then(() => {
activeRequests = Math.max(0, activeRequests - 1);
if (resetGeneration !== currentResetGen) {
form.errors = {};
}
form.isValidating = activeRequests > 0;
});
}
const control = (node, data) => {
const { field: path, valueAsNumber = false, setValueAs = undefined } = (typeof data === 'string' ? { field: data } : data);
if (!path)
return;
const handleOnInput = async () => {
let value = readValue(node, path);
if (valueAsNumber) {
const str = String(value).trim();
if (str === '' || str === '-') {
value = str;
}
else if (/^-?\d+$/.test(str)) {
value = Number(str);
}
else {
value = getValueByPath(form.data, path);
}
writeValue(node, value);
}
if (setValueAs) {
setValueAs(value);
await tick();
value = getValueByPath(form.data, path);
}
else {
setByPath(form.data, path, value);
}
tick().then(async () => {
updatePathDirty(path, value);
await tick();
if (node.type === 'radio' ||
node.type === 'checkbox' ||
node.type === 'select' ||
node.type === 'file') {
form.touched[path] = true;
}
await tick();
if (getValueByPath(form.data, path) === getValueByPath(form.initialValues, path)) {
delete form.errors[path];
}
if (validator && validateOn.includes('change')) {
safeValidateField(path);
}
});
};
const handleOnBlur = () => {
form.touched[path] = true;
tick().then(() => {
if (validator && validateOn.includes('blur')) {
safeValidateField(path);
}
});
};
const addListeners = () => {
const tag = node.tagName?.toLowerCase();
const type = (node.type ?? '').toLowerCase();
const useChange = tag === 'select' || type === 'file' || type === 'checkbox' || type === 'radio';
const useInput = tag === 'input' ||
tag === 'textarea' ||
(!useChange && tag === 'div' && node.isContentEditable);
if (useInput)
node.addEventListener('input', handleOnInput);
if (useChange)
node.addEventListener('change', handleOnInput);
node.addEventListener('blur', handleOnBlur);
return () => {
if (useInput)
node.removeEventListener('input', handleOnInput);
if (useChange)
node.removeEventListener('change', handleOnInput);
node.removeEventListener('blur', handleOnBlur);
};
};
const cleanup = addListeners();
$effect(() => {
const value = getValueByPath(form.data, path);
untrack(() => {
writeValue(node, value);
});
});
return {
destroy() {
cleanup();
}
};
};
$effect(() => {
JSON.stringify(form.dirty);
untrack(() => {
form.isDirty = Object.values(form.dirty).some((v) => v === true);
});
});
$effect(() => {
JSON.stringify(form.errors);
untrack(() => {
form.isValid = Object.values(form.errors).every((v) => !v);
});
});
return { form, control };
}