Skip to content

Instantly share code, notes, and snippets.

@4lun
Last active August 29, 2025 14:05
Show Gist options
  • Select an option

  • Save 4lun/b91cdc4b82c1ff3b5506b47cb29dcfd4 to your computer and use it in GitHub Desktop.

Select an option

Save 4lun/b91cdc4b82c1ff3b5506b47cb29dcfd4 to your computer and use it in GitHub Desktop.

Revisions

  1. 4lun revised this gist Aug 29, 2025. 1 changed file with 3 additions and 0 deletions.
    3 changes: 3 additions & 0 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -3,6 +3,9 @@
    * Inertia.js, for use with general API endpoints. Any page updates or
    * navigation needs to be handled manually via onSuccess callback.
    *
    * Author: https://github.com/4lun
    * Source: https://gist.github.com/4lun/b91cdc4b82c1ff3b5506b47cb29dcfd4
    *
    * Based on a combination of
    * - https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe
    * - https://gist.github.com/mattiasghodsian/9b4ee07e805547aa13795dc3a28a206d
  2. 4lun revised this gist Aug 29, 2025. 1 changed file with 6 additions and 6 deletions.
    12 changes: 6 additions & 6 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -7,29 +7,29 @@
    * - https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe
    * - https://gist.github.com/mattiasghodsian/9b4ee07e805547aa13795dc3a28a206d
    *
    * Version 0.2
    * Version 0.3
    *
    * Changes:
    * - v0.1 Rewritten to use React from Vue
    * - v0.1 Added TypeScript types
    * - v0.2 Fixed state updates of processing, progress, wasSuccessful, etc
    * - v0.3 Updated to new FormDataType<TForm> generic type
    */

    import { useForm } from "@inertiajs/react";
    import type { InertiaFormProps } from "@inertiajs/react";
    import cloneDeep from "lodash.clonedeep";
    import { cloneDeep } from "lodash-es";
    import axios, { AxiosRequestConfig, AxiosProgressEvent } from "axios";
    import {
    FormDataConvertible,
    FormDataKeys,
    FormDataType,
    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> {
    @@ -42,7 +42,7 @@ interface SubmitOptions<TResponse = Response> {
    onFinish?: () => void;
    }

    type APIForm<TForm extends FormDataType> = InertiaFormProps<TForm> & {
    type APIForm<TForm extends FormDataType<TForm>> = InertiaFormProps<TForm> & {
    transform: (callback: (data: TForm) => object) => void;
    submit: (
    ...args:
    @@ -69,7 +69,7 @@ export function hasFiles(data: any): boolean {
    );
    }

    export function useAPIForm<TForm extends FormDataType>(
    export function useAPIForm<TForm extends FormDataType<TForm>>(
    rememberKeyOrInitialValues: string | TForm,
    initialValues?: TForm,
    ): APIForm<TForm> {
  3. 4lun revised this gist Jul 24, 2025. No changes.
  4. 4lun revised this gist Jul 24, 2025. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -1,7 +1,7 @@
    /**
    * A utility hook for that wraps 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
  5. 4lun revised this gist Jul 24, 2025. 1 changed file with 47 additions and 32 deletions.
    79 changes: 47 additions & 32 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -1,15 +1,18 @@
    /**
    * 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.
    * A utility hook for that wraps 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.
    *
    * Based on a combination of
    * - https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe
    * - https://gist.github.com/mattiasghodsian/9b4ee07e805547aa13795dc3a28a206d
    *
    * Version 0.2
    *
    * Changes:
    * - Rewritten to use React from Vue
    * - Added TypeScript types
    * - 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";
    @@ -23,6 +26,7 @@ import {
    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>;
    @@ -43,14 +47,12 @@ type APIForm<TForm extends FormDataType> = InertiaFormProps<TForm> & {
    submit: (
    ...args:
    | [Method, string, FormOptions?]
    | [
    {
    url: string;
    method: Method;
    },
    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
    @@ -66,6 +68,7 @@ export function hasFiles(data: any): boolean {
    Object.values(data).some((value) => hasFiles(value)))
    );
    }

    export function useAPIForm<TForm extends FormDataType>(
    rememberKeyOrInitialValues: string | TForm,
    initialValues?: TForm,
    @@ -78,24 +81,28 @@ export function useAPIForm<TForm extends FormDataType>(
    initialValues,
    );

    let transformFn = (data: TForm): object => data;
    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 = {},
    ) => {
    form.wasSuccessful = false;
    form.recentlySuccessful = false;
    setWasSuccessful(false);
    setRecentlySuccessful(false);
    form.clearErrors();
    if (recentlySuccessfulTimeoutId) {
    if (recentlySuccessfulTimeoutId)
    clearTimeout(recentlySuccessfulTimeoutId);
    }

    options.onBefore?.();
    form.processing = true;
    setProcessing(true);
    options.onStart?.();

    const rawData = transformFn(form.data);
    @@ -110,7 +117,7 @@ export function useAPIForm<TForm extends FormDataType>(
    : "application/json",
    },
    onUploadProgress: (event) => {
    form.progress = event;
    setProgress(event);
    options.onProgress?.(event);
    },
    };
    @@ -123,22 +130,22 @@ export function useAPIForm<TForm extends FormDataType>(

    axios(config)
    .then((response) => {
    form.processing = false;
    form.progress = null;
    setProcessing(false);
    setProgress(null);
    form.clearErrors();
    form.wasSuccessful = true;
    form.recentlySuccessful = true;
    setWasSuccessful(true);
    setRecentlySuccessful(true);
    recentlySuccessfulTimeoutId = setTimeout(() => {
    form.recentlySuccessful = false;
    setRecentlySuccessful(false);
    }, 2000);

    options.onSuccess?.(response.data);
    form.setDefaults(cloneDeep(form.data));
    form.isDirty = false;
    })
    .catch((error) => {
    form.processing = false;
    form.progress = null;
    setProcessing(false);
    setProgress(null);
    form.clearErrors();

    if (
    @@ -157,8 +164,8 @@ export function useAPIForm<TForm extends FormDataType>(
    options.onError?.(error);
    })
    .finally(() => {
    form.processing = false;
    form.progress = null;
    setProcessing(false);
    setProgress(null);
    options.onFinish?.();
    });
    };
    @@ -179,9 +186,17 @@ export function useAPIForm<TForm extends FormDataType>(
    };

    return new Proxy(form, {
    get: (target, prop, receiver) =>
    prop in overriders
    ? overriders[prop as keyof typeof overriders](receiver)
    : (target as APIForm<TForm>)[prop as keyof APIForm<TForm>],
    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>;
    }
  6. 4lun revised this gist Jul 24, 2025. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -1,7 +1,7 @@
    /**
    * A utility hook for that wraps 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
  7. 4lun created this gist Jul 24, 2025.
    187 changes: 187 additions & 0 deletions useAPIForm.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,187 @@
    /**
    * A utility hook for that wraps 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.
    *
    * Based on a combination of
    * - https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe
    * - https://gist.github.com/mattiasghodsian/9b4ee07e805547aa13795dc3a28a206d
    *
    * Changes:
    * - Rewritten to use React from Vue
    * - Added TypeScript types
    */

    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";

    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;
    };

    // 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,
    );

    let transformFn = (data: TForm): object => data;
    let recentlySuccessfulTimeoutId: ReturnType<typeof setTimeout> | null =
    null;

    const submit = (
    method: HttpMethod,
    url: string,
    options: SubmitOptions = {},
    ) => {
    form.wasSuccessful = false;
    form.recentlySuccessful = false;
    form.clearErrors();
    if (recentlySuccessfulTimeoutId) {
    clearTimeout(recentlySuccessfulTimeoutId);
    }

    options.onBefore?.();
    form.processing = 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) => {
    form.progress = event;
    options.onProgress?.(event);
    },
    };

    if (method === "get") {
    config.params = rawData;
    } else {
    config.data = rawData;
    }

    axios(config)
    .then((response) => {
    form.processing = false;
    form.progress = null;
    form.clearErrors();
    form.wasSuccessful = true;
    form.recentlySuccessful = true;
    recentlySuccessfulTimeoutId = setTimeout(() => {
    form.recentlySuccessful = false;
    }, 2000);

    options.onSuccess?.(response.data);
    form.setDefaults(cloneDeep(form.data));
    form.isDirty = false;
    })
    .catch((error) => {
    form.processing = false;
    form.progress = 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(() => {
    form.processing = false;
    form.progress = 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) =>
    prop in overriders
    ? overriders[prop as keyof typeof overriders](receiver)
    : (target as APIForm<TForm>)[prop as keyof APIForm<TForm>],
    }) as APIForm<TForm>;
    }