Packages
React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.
Retired package: Release invalid - Not production-ready
Current section
Files
Jump to
Current section
Files
assets/useLiveForm.ts
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { useLiveReact } from "./context";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface FormField {
value: any;
errors: string[];
errorMessage: string | undefined;
isDirty: boolean;
isTouched: boolean;
inputProps: {
name: string;
id: string;
value: any;
checked?: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
onBlur: () => void;
"aria-invalid": boolean;
};
}
export interface UseLiveFormReturn {
field: (path: string) => FormField;
submit: () => void;
isDirty: boolean;
isValid: boolean;
}
// ---------------------------------------------------------------------------
// Server form shape (Phoenix.HTML.Form encoded as JSON)
// ---------------------------------------------------------------------------
interface ServerForm {
id: string;
name: string;
action: string | null;
errors: Record<string, string[]>;
data: Record<string, any>;
params?: Record<string, any>;
fields: string[];
}
// ---------------------------------------------------------------------------
// Nested value helpers for dotted/bracket paths
// ---------------------------------------------------------------------------
function getNestedValue(obj: any, path: string): any {
const parts = path.split(/[.\[\]]+/).filter(Boolean);
let current = obj;
for (const part of parts) {
if (current == null) return undefined;
current = current[part];
}
return current;
}
function setNestedValue(obj: Record<string, any>, path: string, value: any): Record<string, any> {
const parts = path.split(/[.\[\]]+/).filter(Boolean);
const result = { ...obj };
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
current[part] = current[part] != null ? { ...current[part] } : {};
current = current[part];
}
current[parts[parts.length - 1]] = value;
return result;
}
// ---------------------------------------------------------------------------
// Name / ID helpers for Phoenix-style nested field names
// ---------------------------------------------------------------------------
/**
* Build a Phoenix-style nested form field name.
*
* Examples:
* buildFieldName("user", "email") => "user[email]"
* buildFieldName("user", "profile.email") => "user[profile][email]"
* buildFieldName("user", "tags[0]") => "user[tags][0]"
*/
export function buildFieldName(formName: string, path: string): string {
const parts = path.split(/[.\[\]]+/).filter(Boolean);
if (parts.length === 0) return formName;
return `${formName}[${parts.join("][")}]`;
}
/**
* Build a form field ID from the form ID and a dotted/bracketed path.
*
* Examples:
* buildFieldId("user", "email") => "user_email"
* buildFieldId("user", "profile.email") => "user_profile_email"
* buildFieldId("user", "tags[0]") => "user_tags_0"
*/
export function buildFieldId(formId: string, path: string): string {
const parts = path.split(/[.\[\]]+/).filter(Boolean);
return `${formId}_${parts.join("_")}`;
}
// ---------------------------------------------------------------------------
// Value coercion helpers
// ---------------------------------------------------------------------------
/**
* Coerce string booleans from Phoenix form params back to real booleans.
* Phoenix sends checkbox values as "true"/"false" strings in params.
*/
function coerceValue(value: any): any {
if (value === "true") return true;
if (value === "false") return false;
return value;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useLiveForm(
serverForm: ServerForm,
options: {
changeEvent?: string;
submitEvent?: string;
} = {},
): UseLiveFormReturn {
const { pushEvent } = useLiveReact();
const changeEvent = options.changeEvent ?? "validate";
const submitEvent = options.submitEvent ?? "save";
// Local overrides for field values (written on change, cleared when server
// form data catches up).
const [localValues, setLocalValues] = useState<Record<string, any>>({});
const [touchedFields, setTouchedFields] = useState<Set<string>>(new Set());
// Track previous server form to detect server-side updates.
// We compare a serialized key instead of object identity because
// applyPatch mutates props in place — the reference may not change.
const prevFormKey = useRef("");
useEffect(() => {
const formKey = JSON.stringify({
errors: serverForm?.errors,
data: serverForm?.data,
action: serverForm?.action,
});
if (prevFormKey.current !== "" && prevFormKey.current !== formKey) {
// Only clear local values on successful save (no errors + non-validate action).
// During validation round-trips, preserve local overrides so the user's
// in-progress keystrokes are not lost.
const hasErrors = serverForm?.errors && Object.keys(serverForm.errors).length > 0;
const isValidating = serverForm?.action === "validate";
if (!hasErrors && !isValidating) {
setLocalValues({});
setTouchedFields(new Set());
}
}
prevFormKey.current = formKey;
}, [serverForm?.errors, serverForm?.data, serverForm?.action]);
// ------ derived state ------
const isDirty = Object.keys(localValues).length > 0;
const isValid = useMemo(() => {
const errors = serverForm?.errors;
if (!errors) return true;
return Object.keys(errors).every((k) => {
const errs = errors[k];
return !errs || errs.length === 0;
});
}, [serverForm?.errors]);
// ------ helpers ------
/** Build the full form payload (server data merged with local overrides).
* localValues stores flat dotted-path keys; we convert everything to nested on emit. */
const buildPayload = useCallback((): Record<string, any> => {
let data: Record<string, any> = {};
for (const [k, v] of Object.entries(serverForm?.data || {})) {
data = setNestedValue(data, k, v);
}
for (const [k, v] of Object.entries(serverForm?.params || {})) {
data = setNestedValue(data, k, v);
}
for (const [k, v] of Object.entries(localValues)) {
data = setNestedValue(data, k, v);
}
return { [serverForm.name]: data };
}, [serverForm?.data, serverForm?.params, serverForm?.name, localValues]);
// ------ field() ------
const field = useCallback(
(path: string): FormField => {
const serverValue = coerceValue(
getNestedValue(serverForm?.params, path) ??
getNestedValue(serverForm?.data, path) ??
""
);
const value = path in localValues ? localValues[path] : serverValue;
// Try both nested (errors.profile.email) and flat (errors["profile.email"]) paths
const rawErrors =
getNestedValue(serverForm?.errors, path) ??
serverForm?.errors?.[path] ??
[];
const fieldErrors: string[] = Array.isArray(rawErrors) ? rawErrors : [rawErrors];
const errorMessage = fieldErrors.length > 0 ? fieldErrors[0] : undefined;
const fieldIsDirty = path in localValues;
const fieldIsTouched = touchedFields.has(path);
const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const target = e.target;
let newValue: any;
if (target instanceof HTMLInputElement) {
if (target.type === "checkbox") {
newValue = target.checked;
} else if (target.type === "number" || target.type === "range") {
const num = target.valueAsNumber;
newValue = Number.isNaN(num) ? "" : num;
} else {
newValue = target.value;
}
} else {
// HTMLSelectElement or HTMLTextAreaElement
newValue = target.value;
}
setLocalValues((prev) => ({ ...prev, [path]: newValue }));
// Eagerly push the change event so LiveView can validate.
// localValues stores flat dotted-path keys; convert everything to nested on emit.
let mergedData: Record<string, any> = {};
for (const [k, v] of Object.entries(serverForm?.data || {})) {
mergedData = setNestedValue(mergedData, k, v);
}
for (const [k, v] of Object.entries(serverForm?.params || {})) {
mergedData = setNestedValue(mergedData, k, v);
}
for (const [k, v] of Object.entries(localValues)) {
mergedData = setNestedValue(mergedData, k, v);
}
mergedData = setNestedValue(mergedData, path, newValue);
const payload = { [serverForm.name]: mergedData };
pushEvent(changeEvent, payload);
};
const onBlur = () => {
setTouchedFields((prev) => {
const next = new Set(prev);
next.add(path);
return next;
});
};
// For boolean values (checkboxes), use checked instead of value
const isBoolean = typeof value === "boolean";
const baseInputProps: FormField["inputProps"] = {
name: buildFieldName(serverForm.name, path),
id: buildFieldId(serverForm.id, path),
value: isBoolean ? undefined : value,
checked: isBoolean ? (value as boolean) : undefined,
onChange,
onBlur,
"aria-invalid": fieldErrors.length > 0,
};
return {
value,
errors: fieldErrors,
errorMessage,
isDirty: fieldIsDirty,
isTouched: fieldIsTouched,
inputProps: baseInputProps,
};
},
[
serverForm,
localValues,
touchedFields,
changeEvent,
pushEvent,
],
);
// ------ submit() ------
const submit = useCallback(() => {
pushEvent(submitEvent, buildPayload());
// DON'T clear state here — let the server response drive cleanup.
// When the server replies with new form state (action/data/errors change),
// the useEffect above will clear localValues and, if there are no errors,
// also clear touchedFields.
}, [submitEvent, pushEvent, buildPayload]);
return { field, submit, isDirty, isValid };
}