Skip to content

Instantly share code, notes, and snippets.

@VaguelyOnline
Forked from mohitmamoria/README.md
Last active September 3, 2025 12:13
Show Gist options
  • Save VaguelyOnline/0dc4f3f2d014598301ce1ef7f26c84b9 to your computer and use it in GitHub Desktop.
Save VaguelyOnline/0dc4f3f2d014598301ce1ef7f26c84b9 to your computer and use it in GitHub Desktop.
Inertia.js Form Helper for Axios/API calls

Forked from: https://gist.github.com/mohitmamoria/91da6f30d9610b211248225c9e52bebe?permalink_comment_id=5155652 Why?: To add types and generics for a better DX.

Inertia.js ships with a fantastic form helper but it falls short when also using API/Axios calls in your project.

Here's a composable, built on top of the Inertia's form helper that hooks into it to replace the API calls with Axios.

To use, just replace useForm with useAPIForm.

const form = useAPIForm<{
    title: string;
}>({
    title: '',
});

Everything else just works!

<!-- Since this approach uses Sanctum Auth, we use the CSRF token. So the code in useApi.ts works, ensure this is placed in the app.blade.php file -->
<meta name="csrf" content="{{ csrf_token() }}" />
import axios, { AxiosInstance } from 'axios';
import { toast } from 'vue-sonner';
const csrf: string = document.querySelector('meta[name="csrf"]')?.getAttribute('content') || '';
/**
* Configure an axios instance that is wired up to the first-party Sanctum backend authentication.
* This is preferable to using the default Axios instance as we can load it up with the headers
* necessary to authenticate with Sanctum. For more: https://laravel.com/docs/12.x/sanctum
*/
const axiosClient = axios.create({
withCredentials: true,
withXSRFToken: true,
headers: {
'X-XSRF-TOKEN': csrf,
},
});
/**
* Some simple default handling for the common auth errors
* If we are not logged in (401), simply redirect to the
* login page. If unauthorized (403), toast a warning.
*/
axiosClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response.status === 401) {
window.location.href = '/login';
}
if (error.response.status === 403) {
toast.warning('You do not have permission for the requested action');
}
return Promise.reject(error);
},
);
function useApi(): AxiosInstance {
return axiosClient;
}
export { useApi };
import { useApi } from '@/composables/useApi';
import { FormDataConvertible } from '@inertiajs/core/types';
import { InertiaFormProps, useForm } from '@inertiajs/vue3';
import { AxiosProgressEvent } from 'axios';
import cloneDeep from 'lodash.clonedeep';
const api = useApi();
interface FormOptions {
onBefore?: () => void;
onStart?: () => void;
onProgress?: (event: AxiosProgressEvent) => void;
onSuccess?: (data: any) => void;
onError?: (error: {
status: number;
}) => void;
onFinish?: () => void;
}
export type FormDataType = Record<string, FormDataConvertible>;
export type InertiaForm<TForm extends FormDataType> = TForm & InertiaFormProps<TForm>;
export function useApiForm<TForm extends FormDataType>(initialData?: TForm | (() => TForm)): InertiaForm<TForm> {
const form = useForm(initialData || {});
let transform = (data: TForm) => data;
let recentlySuccessfulTimeoutId: number | null = null;
const overriders = {
transform: (receiver: any) => (callback: (data: TForm) => any) => {
transform = callback;
return receiver;
},
submit:
() =>
(method: string, url: string, options: FormOptions = {}) => {
form.wasSuccessful = false;
form.recentlySuccessful = false;
form.clearErrors();
if (recentlySuccessfulTimeoutId) clearTimeout(recentlySuccessfulTimeoutId);
if (options.onBefore) {
options.onBefore();
}
form.processing = true;
if (options.onStart) {
options.onStart();
}
const data = transform(form.data() as any);
// @ts-expect-error Error in accessing method using square brackets
api[method](url, data, {
headers: {
'Content-Type': hasFiles(data) ? 'multipart/form-data' : 'application/json',
},
onUploadProgress: (event: any) => {
form.progress = event;
if (options.onProgress) {
options.onProgress(event);
}
},
})
.then((response: any) => {
form.processing = false;
form.progress = null;
form.clearErrors();
form.wasSuccessful = true;
form.recentlySuccessful = true;
recentlySuccessfulTimeoutId = setTimeout(() => (form.recentlySuccessful = false), 2000);
if (options.onSuccess) {
options.onSuccess(response.data);
}
form.defaults(cloneDeep(form.data()));
form.isDirty = false;
})
.catch((error: any) => {
form.processing = false;
form.progress = null;
form.clearErrors();
if (error.response?.status === 422) {
Object.keys(error.response.data.errors).forEach((key) => {
// @ts-expect-error types mismatch
form.setError(key, error.response.data.errors[key][0]);
});
}
if (options.onError) {
options.onError(error);
}
})
.finally(() => {
form.processing = false;
form.progress = null;
if (options.onFinish) {
options.onFinish();
}
});
},
};
return new Proxy(form, {
get: (target, prop, receiver) => {
if (Object.keys(overriders).indexOf(prop.toString()) < 0) {
// @ts-expect-error Using prop
return target[prop];
}
return overriders[prop as keyof typeof overriders](receiver);
},
}) as any;
}
function hasFiles(data: any): boolean {
return data instanceof FormData;
}
@scramatte
Copy link

scramatte commented Aug 27, 2025

Hi,

I'm trying to use your code @/composables/useApi is missing and '@inertiajs/core/types' module is not found.
To get 'lodash.clonedeep' working, I've installed packages 'lodash.clonedeep' + npm '@types/lodash.clonedeep'

Can you give me a hand ?
An example would be nice too.

I'm using laravel 12

Regards

@VaguelyOnline
Copy link
Author

VaguelyOnline commented Aug 28, 2025

Sure - no worries. I forgot to include the simple wrapper around the Axios instance that I'm using to hit the API endpoint. I've now added that to the gist, and also updated the useApiForm just in case there was anything else that changed.

  1. Add useApi.ts to your composables directory (not sure it's really a composable in the strictest sense, but live and let live!
  2. So that the sanctum auth works, ensure that your main template file (likely app.blade.php) has a tag with your CSRF token in - see the gist files above.
  3. Assuming you're using the default Inertia install - you're probably OK. Just try to compile with npm run dev and see if you get any errors.

The issue with @inertiajs/core/types may be a red-herring - check if it is just an issue with your editor, rather than the actual TS. In other words, check that it's not just a red underline in your editor. If it compiles when you run npm run dev or npm run build, then the problem may lay in your editor not being able to resolve the imports - Typescript can be a pain in the neck for this. If you already have inertia installed, then the type def for the FormDataConvertible should be resolvable - assuming that the @ alias in configured in your vite.config.ts (which it should be by default if you're using Laravel with Vue starter kit.

import { FormDataConvertible } from '@inertiajs/core/types';
resolve: {
        alias: {
            '@': path.resolve(__dirname, './resources/js'),
           

Here is a full but simple component that uses a HTTP GET API endpoint to update an 'online' status. In this example, there is no generics on the form itself, as the API endpoint doesn't take any form data. Notice that we can use the 'apiForm.processing' prop to indicate that the API call is actively underway. You also get type-hinting for the form props - for example, with setting the 'onSuccess' callback prop.

<script setup lang="ts">

import { Device } from '@/types';
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { useApiForm } from '@/composables/useApiForm';
import { motion } from 'motion-v';
import { router } from '@inertiajs/vue3';

const { device } = defineProps<{
    device: Device
}>();

const apiForm = useApiForm();
const status = ref({
    is_online: device.is_online,
});

function updateStatus() {
    apiForm.get(route('api.devices.status', device.id), {
        onSuccess: (response) => {
            status.value.is_online = response.is_online;
        }
    });
}

/**
 * Hook into the on reload event to update the status
 */
onMounted(updateStatus);


</script>

<template>
    
    <span v-if="apiForm.processing">Updating...</span>

    <motion.span
        v-if="status.is_online !== undefined"
        :initial="{ opacity: 0 }"
        :animate="{ opacity: 1 }"
        :transition="{ duration: 0.5 }"

    >
        <span
            class="text-xs animate-pulse inline-block w-3 h-3 rounded-full ml-2 mr-1"
            :class="status.is_online ? 'bg-green-500' : 'bg-red-500'" />
        <span class="hidden md:inline">
            {{ status.is_online ? 'Online' : 'Offline' }}
        </span>
    </motion.span>
</template>

<style scoped>

</style>

@VaguelyOnline
Copy link
Author

And here are some excepts from another component that uses an API endpoint to create a new user. In this example, the form is used, together with any validation errors that may be kicked back from the server endpoint (usual Laravel Form Request Validation mechanisms - https://laravel.com/docs/12.x/validation#creating-form-requests . Notice we use the same Inertia API to clear errors, and check for error states. Also notice that in this example, we get type-hinting and type-safety on the newUser object - since it is constructed with the type passed to it.

const newUser = useApiForm<{
    email: string;
    name: string;
    organisation_id: number | null;
    organisation_type_id: number | null;
}>({
    email: '',
    name: '',
    organisation_id: null,
    organisation_type_id: null,
});

// ...

function createUser(org: Organisation) {
    newUser.post(route('api.organisations.users.store', org.id), {
        onSuccess: () => {
            newUser.reset();
            organisation.value = null;
            newOrg.value = true;
            modal.value?.close();
            emits('created');
        },
    });
}

// ...
// For using the ApiForm to clear any errors on the user's name prop, whenever that name get's updated.
watch(
    () => newUser.name,
    () => newUser.clearErrors('name'),
);

<template>
...
            <div class="grid gap-2">
                <Label>Name</Label>
                <Input v-model="newUser.name" type="text" required placeholder="E.g., Jane Doe" />
                <p v-if="newUser.errors.name" class="text-sm text-red-300">
                    {{ newUser.errors.name }}
                </p>
            </div>
            
            ...
                        <Button type="submit" class="mt-6 cursor-pointer" :disabled="!formOk || newUser.processing">
                <span v-if="newUser.processing">
                    <Loader2 class="inline size-6 animate-spin" />
                </span>
                <span v-if="!formOk">Please complete the form</span>
                <span v-else> Add<span v-if="newUser.processing">ing</span> user </span>
            </Button>

Hope these examples help a little!

@VaguelyOnline
Copy link
Author

@scramatte - I've updated some comments and things above. See if that works for you now. It's working very well in the project now for me. It can also be very simple to hit an api endpoint - as simple as:

useApiForm().delete(route('photos.edit'));

@scramatte
Copy link

Hi, Great , I'm going to try during today.
Thank you very much

@scramatte
Copy link

Hi, I'm testing your changes.
About @inertiajs/core/types issue, I've added package @inertiajs/core and I replace
import { FormDataConvertible } from '@inertiajs/core/types';
by
import { FormDataConvertible } from '@inertiajs/core';

@scramatte
Copy link

Hi, it works as expected. One more thing, If I use it to login via api how do you suggest I can redirect to the initially called page or /dashboard ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment