Skip to content

Instantly share code, notes, and snippets.

@wking-io
Created October 15, 2024 17:07
Show Gist options
  • Save wking-io/3df59b0cf7a589bacc75baa44988ab84 to your computer and use it in GitHub Desktop.
Save wking-io/3df59b0cf7a589bacc75baa44988ab84 to your computer and use it in GitHub Desktop.
Custom Inertia useForm for uncontrolled form management.
function CreateForm() {
const { submit, errors } = useForm();
return (
<Form onSubmit={submit}>
{/** Basic */}
<div>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" />
{errors["title"] ? <p>{errors["title"]</p> : null}
</div>
{/** Nested */}
<div>
<div>
<label htmlFor="source.name">Title</label>
<input type="text" id="source.name" name="source[name]" />
{errors["source.name"] ? <p>{errors["source.name"]</p> : null}
</div>
<div>
<label htmlFor="source.url">Title</label>
<input type="text" id="source.url" name="source[url]" />
{errors["source.url"] ? <p>{errors["source.url"]</p> : null}
</div>
</div>
{/** Nested Array */}
<div>
<fieldset>
<legend>Categories</legend>
<div>
<input type="checkbox" id="category.0.id" name="category[0][id]" />
<label htmlFor="category.0.id" />
</div>
<div>
<input type="checkbox" id="category.1.id" name="category[1][id]" />
<label htmlFor="category.1.id" />
</div>
{errors["category.*.id"] ? <p>{errors["category.*.id"]</p> : null}
</fieldset>
</Form>
}
export function Form({
children,
onSubmit,
...props
}: Omit<FormHTMLAttributes<HTMLFormElement>, "onSubmit"> &
PropsWithChildren<{ onSubmit(form: HTMLFormElement): void }>) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (e.target instanceof HTMLFormElement) {
onSubmit(e.target);
} else {
console.error("Form element not found");
}
};
return (
<form {...props} onSubmit={handleSubmit}>
{children}
</form>
);
}

Custom useForm

Inertia comes with a form helper called useForm. However, it requires controlled inputs. This is not best practice for React, but by itself is not a deal breaker. Then I ran into nested data and array data...typed nested object updates is not fun or clean.

So, I built my own version of the useForm hook that allows you to give control back to the DOM. Here is how it works:

  • No manual data updates
  • Data is pulled from the form element on submit and converted into a FormData instance.
  • FormData supports File, nested keys, and array values by default.
  • Can still reset using a key.

However, there were some compromises I needed to make and require some work still:

  • Typed error object, or at a minimum helpers to get data based on the key.
  • Saved state. I tore out the remember functionality since I don't need it, but ideally would be able to bring that back in.
import {
Errors,
Method,
Progress,
router,
VisitOptions,
} from "@inertiajs/core";
import { AxiosProgressEvent } from "axios";
import { useCallback, useEffect, useRef, useState } from "react";
type CancelToken = {
cancel(): void;
};
type OptionsWithoutMethod = Omit<VisitOptions, "method">;
export interface InertiaFormProps {
formKey: React.RefObject<string>;
errors: Errors;
getError(field: keyof Errors): string | undefined;
hasErrors: boolean;
processing: boolean;
progress: Progress | null;
wasSuccessful: boolean;
recentlySuccessful: boolean;
reset: () => void;
clearErrors: (...fields: (keyof Errors)[]) => void;
setError(field: keyof Errors, value: string): void;
setError(errors: Record<keyof Errors, string>): void;
submit: (form: HTMLFormElement, options?: OptionsWithoutMethod) => void;
cancel: () => void;
}
export default function useForm(): InertiaFormProps {
const isMounted = useRef<boolean>(false);
const formKey = useRef<string>(
`inertia-form:${Date.now()}:${Math.random()}`
);
const cancelToken = useRef<CancelToken | null>(null);
const recentlySuccessfulTimeoutId = useRef<number | null>(null);
const [errors, setErrors] = useState<Errors>({});
const [hasErrors, setHasErrors] = useState(false);
const [processing, setProcessing] = useState(false);
const [progress, setProgress] = useState<AxiosProgressEvent | null>(null);
const [wasSuccessful, setWasSuccessful] = useState(false);
const [recentlySuccessful, setRecentlySuccessful] = useState(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
const submit = useCallback(
(form: HTMLFormElement, options: OptionsWithoutMethod = {}) => {
const _options: OptionsWithoutMethod = {
...options,
forceFormData: true,
onCancelToken: (token) => {
cancelToken.current = token;
if (options.onCancelToken) {
return options.onCancelToken(token);
}
},
onBefore: (visit) => {
setWasSuccessful(false);
setRecentlySuccessful(false);
if (recentlySuccessfulTimeoutId.current) {
clearTimeout(recentlySuccessfulTimeoutId.current);
}
if (options.onBefore) {
return options.onBefore(visit);
}
},
onStart: (visit) => {
console.log("STARTING");
setProcessing(true);
if (options.onStart) {
return options.onStart(visit);
}
},
onProgress: (event) => {
if (event) {
setProgress(event);
}
if (options.onProgress) {
return options.onProgress(event);
}
},
onSuccess: (page) => {
console.log("SUCCESS");
if (isMounted.current) {
setProcessing(false);
setProgress(null);
setErrors({});
setHasErrors(false);
setWasSuccessful(true);
setRecentlySuccessful(true);
recentlySuccessfulTimeoutId.current = window.setTimeout(
() => {
if (isMounted.current) {
setRecentlySuccessful(false);
}
},
2000
);
}
if (options.onSuccess) {
return options.onSuccess(page);
}
},
onError: (errors) => {
console.log("ERRORS", errors);
if (isMounted.current) {
setProcessing(false);
setProgress(null);
setErrors(errors);
setHasErrors(true);
}
if (options.onError) {
return options.onError(errors);
}
},
onCancel: () => {
if (isMounted.current) {
setProcessing(false);
setProgress(null);
}
if (options.onCancel) {
return options.onCancel();
}
},
onFinish: (visit) => {
if (isMounted.current) {
setProcessing(false);
setProgress(null);
}
cancelToken.current = null;
if (options.onFinish) {
return options.onFinish(visit);
}
},
};
const data = new FormData(form);
const method: Method = parseMethod(form.method);
const url = form.action ?? route().current();
console.log(
"INFO FOR FORM: ",
Object.fromEntries(data.entries()),
method,
url
);
if (method === "delete") {
router.delete(url, { ..._options, data });
} else {
router[method](url, data, _options);
}
},
[setErrors]
);
return {
formKey,
errors,
getError(field: keyof Errors) {
return errors?.[field];
},
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
reset() {
formKey.current = `inertia-form:${Date.now()}:${Math.random()}`;
},
setError(fieldOrFields: keyof Errors | Errors, maybeValue?: string) {
setErrors((errors) => {
const newErrors: Errors = { ...errors };
if (typeof fieldOrFields === "string" && maybeValue) {
newErrors[fieldOrFields] = maybeValue;
} else {
Object.assign(newErrors, fieldOrFields);
}
setHasErrors(Object.keys(newErrors).length > 0);
return newErrors;
});
},
clearErrors(...fields) {
setErrors((errors) => {
const newErrors = (
Object.keys(errors) as Array<keyof Errors>
).reduce(
(carry, field) => ({
...carry,
...(fields.length > 0 && !fields.includes(field)
? { [field]: errors[field] }
: {}),
}),
{}
);
setHasErrors(Object.keys(newErrors).length > 0);
return newErrors;
});
},
submit,
cancel() {
if (cancelToken.current) {
cancelToken.current.cancel();
}
},
};
}
function parseMethod(maybeMethod: string): Method {
switch (maybeMethod.toLowerCase()) {
case "get":
return "get";
case "post":
return "post";
case "put":
return "put";
case "patch":
return "patch";
case "delete":
return "delete";
default:
console.log(`Invalid method: ${maybeMethod}. Defaulting to 'get'.`);
return "get";
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment