Skip to content

Instantly share code, notes, and snippets.

@4lun
Last active July 24, 2025 14:12
Show Gist options
  • Save 4lun/b91cdc4b82c1ff3b5506b47cb29dcfd4 to your computer and use it in GitHub Desktop.
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.
/**
* 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