Last active
July 24, 2025 14:12
-
-
Save 4lun/b91cdc4b82c1ff3b5506b47cb29dcfd4 to your computer and use it in GitHub Desktop.
A (React) utility hook that is a drop in replacement for the useForm hook from Inertia.js, but for use with general API endpoints. Any page updates or navigation needs to be handled manually via onSuccess callback.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* A utility hook that is a drop in replacement for the useForm hook from | |
* Inertia.js, for use with general API endpoints. Any page updates or | |
* navigation needs to be handled manually via onSuccess callback. | |
* | |
* Based on a combination of | |
* - https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe | |
* - https://gist.github.com/mattiasghodsian/9b4ee07e805547aa13795dc3a28a206d | |
* | |
* Version 0.2 | |
* | |
* Changes: | |
* - v0.1 Rewritten to use React from Vue | |
* - v0.1 Added TypeScript types | |
* - v0.2 Fixed state updates of processing, progress, wasSuccessful, etc | |
*/ | |
import { useForm } from "@inertiajs/react"; | |
import type { InertiaFormProps } from "@inertiajs/react"; | |
import cloneDeep from "lodash.clonedeep"; | |
import axios, { AxiosRequestConfig, AxiosProgressEvent } from "axios"; | |
import { | |
FormDataConvertible, | |
FormDataKeys, | |
Method, | |
VisitOptions, | |
} from "@inertiajs/core"; | |
import type { Response } from "@inertiajs/core/types/response"; | |
import { useState } from "react"; | |
type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; | |
type FormDataType = Record<string, FormDataConvertible>; | |
type FormOptions = Omit<VisitOptions, "data">; | |
interface SubmitOptions<TResponse = Response> { | |
headers?: Record<string, string>; | |
onBefore?: () => void; | |
onStart?: () => void; | |
onProgress?: (event: AxiosProgressEvent) => void; | |
onSuccess?: (response: TResponse) => void; | |
onError?: (error: unknown) => void; | |
onFinish?: () => void; | |
} | |
type APIForm<TForm extends FormDataType> = InertiaFormProps<TForm> & { | |
transform: (callback: (data: TForm) => object) => void; | |
submit: ( | |
...args: | |
| [Method, string, FormOptions?] | |
| [{ url: string; method: Method }, FormOptions?] | |
) => void; | |
processing: boolean; | |
progress: AxiosProgressEvent | null; | |
wasSuccessful: boolean; | |
recentlySuccessful: boolean; | |
}; | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
export function hasFiles(data: any): boolean { | |
return ( | |
data instanceof File || | |
data instanceof Blob || | |
(data instanceof FileList && data.length > 0) || | |
(data instanceof FormData && | |
Array.from(data.values()).some((value) => hasFiles(value))) || | |
(typeof data === "object" && | |
data !== null && | |
Object.values(data).some((value) => hasFiles(value))) | |
); | |
} | |
export function useAPIForm<TForm extends FormDataType>( | |
rememberKeyOrInitialValues: string | TForm, | |
initialValues?: TForm, | |
): APIForm<TForm> { | |
const form = | |
initialValues === undefined | |
? useForm<TForm>(rememberKeyOrInitialValues as TForm) | |
: useForm<TForm>( | |
rememberKeyOrInitialValues as string, | |
initialValues, | |
); | |
const [processing, setProcessing] = useState(false); | |
const [progress, setProgress] = useState<AxiosProgressEvent | null>(null); | |
const [wasSuccessful, setWasSuccessful] = useState(false); | |
const [recentlySuccessful, setRecentlySuccessful] = useState(false); | |
let recentlySuccessfulTimeoutId: ReturnType<typeof setTimeout> | null = | |
null; | |
let transformFn = (data: TForm): object => data; | |
const submit = ( | |
method: HttpMethod, | |
url: string, | |
options: SubmitOptions = {}, | |
) => { | |
setWasSuccessful(false); | |
setRecentlySuccessful(false); | |
form.clearErrors(); | |
if (recentlySuccessfulTimeoutId) | |
clearTimeout(recentlySuccessfulTimeoutId); | |
options.onBefore?.(); | |
setProcessing(true); | |
options.onStart?.(); | |
const rawData = transformFn(form.data); | |
const config: AxiosRequestConfig = { | |
method, | |
url, | |
headers: { | |
...options.headers, | |
"Content-Type": hasFiles(rawData) | |
? "multipart/form-data" | |
: "application/json", | |
}, | |
onUploadProgress: (event) => { | |
setProgress(event); | |
options.onProgress?.(event); | |
}, | |
}; | |
if (method === "get") { | |
config.params = rawData; | |
} else { | |
config.data = rawData; | |
} | |
axios(config) | |
.then((response) => { | |
setProcessing(false); | |
setProgress(null); | |
form.clearErrors(); | |
setWasSuccessful(true); | |
setRecentlySuccessful(true); | |
recentlySuccessfulTimeoutId = setTimeout(() => { | |
setRecentlySuccessful(false); | |
}, 2000); | |
options.onSuccess?.(response.data); | |
form.setDefaults(cloneDeep(form.data)); | |
form.isDirty = false; | |
}) | |
.catch((error) => { | |
setProcessing(false); | |
setProgress(null); | |
form.clearErrors(); | |
if ( | |
axios.isAxiosError(error) && | |
error.response?.status === 422 && | |
error.response.data.errors | |
) { | |
const errors = error.response.data.errors; | |
(Object.keys(errors) as Array<FormDataKeys<TForm>>).forEach( | |
(key) => { | |
form.setError(key, errors[key][0]); | |
}, | |
); | |
} | |
options.onError?.(error); | |
}) | |
.finally(() => { | |
setProcessing(false); | |
setProgress(null); | |
options.onFinish?.(); | |
}); | |
}; | |
const overriders = { | |
transform: | |
(receiver: (data: TForm) => object) => | |
(callback: (data: TForm) => object) => { | |
transformFn = callback; | |
return receiver; | |
}, | |
submit: () => submit, | |
post: () => submit.bind(null, "post"), | |
get: () => submit.bind(null, "get"), | |
put: () => submit.bind(null, "put"), | |
patch: () => submit.bind(null, "patch"), | |
delete: () => submit.bind(null, "delete"), | |
}; | |
return new Proxy(form, { | |
get: (target, prop, receiver) => { | |
if (prop in overriders) { | |
return overriders[prop as keyof typeof overriders](receiver); | |
} | |
if (prop === "processing") return processing; | |
if (prop === "progress") return progress; | |
if (prop === "wasSuccessful") return wasSuccessful; | |
if (prop === "recentlySuccessful") return recentlySuccessful; | |
return (target as APIForm<TForm>)[prop as keyof APIForm<TForm>]; | |
}, | |
}) as APIForm<TForm>; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment