Created
September 18, 2025 16:24
-
-
Save empresarrollo/73470ae560b5049eb209acc2da2a515f to your computer and use it in GitHub Desktop.
Inertia.js Form Helper for Axios/API calls. It wraps Inertia's useForm OR use axios for api calls if you pass {isInertia: false} in the options object.
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
import { useForm } from '@inertiajs/vue3' | |
import type { InertiaForm } from '@inertiajs/vue3' | |
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' | |
import { cloneDeep } from 'lodash-es' | |
import { onUnmounted } from 'vue' | |
// Types | |
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | |
// Base options that work in both Inertia and Axios modes | |
export interface CommonFormOptions { | |
headers?: Record<string, string> | |
onBefore?: (visit: { method: Method; url: string; data: any }) => void | |
onStart?: (visit: { method: Method; url: string; data: any }) => void | |
onProgress?: (progress: { percentage: number }) => void | |
onSuccess?: <T = any>(response: T) => void | |
onError?: (errors: Record<string, string> | Error) => void | |
onCancel?: () => void | |
onFinish?: () => void | |
} | |
// Options specific to Inertia mode only | |
export interface InertiaOnlyOptions { | |
preserveState?: boolean | |
preserveScroll?: boolean | ((props: any) => boolean) | |
only?: string[] | |
errorBag?: string | |
queryStringArrayFormat?: 'indices' | 'brackets' | |
} | |
// Options specific to Axios mode only | |
export interface AxiosOnlyOptions { | |
forceFormData?: boolean | |
resetOnSuccess?: boolean | |
} | |
// Combined options interface | |
export interface EnhancedFormOptions extends CommonFormOptions, InertiaOnlyOptions, AxiosOnlyOptions { | |
/** | |
* Choose between Inertia.js (default) or Axios for form submission | |
* - true (default): Uses Inertia.js - supports preserveState, preserveScroll, only, errorBag | |
* - false: Uses Axios - supports forceFormData, resetOnSuccess, better error handling | |
*/ | |
isInertia?: boolean | |
} | |
// Helper types for better parameter handling | |
type FormDataInput<TForm> = TForm | string | |
type OptionsInput<TForm> = TForm | EnhancedFormOptions | null | |
// File detection function with better implementation | |
export function hasFiles(data: unknown): boolean { | |
if (data instanceof File || data instanceof Blob) return true | |
if (data instanceof FileList && data.length > 0) return true | |
if (data instanceof FormData) { | |
// More conservative: only return true if we can verify files exist | |
// FormData with only text fields is still FormData | |
return false // Let axios handle FormData detection | |
} | |
if (Array.isArray(data)) { | |
return data.some((item) => hasFiles(item)) | |
} | |
if (data && typeof data === 'object') { | |
return Object.values(data).some((value) => hasFiles(value)) | |
} | |
return false | |
} | |
// Helper functions for parameter detection | |
function isPlainObject(value: unknown): value is Record<string, any> { | |
if (typeof value !== 'object' || value === null) return false | |
if (Array.isArray(value)) return false | |
if (value instanceof File || value instanceof Blob || value instanceof FormData) return false | |
// Check if it's likely a plain data object (not an options object) | |
const optionKeys = ['isInertia', 'onSuccess', 'onError', 'headers', 'preserveState'] | |
return !optionKeys.some(key => key in value) | |
} | |
function isOptions(value: unknown): value is EnhancedFormOptions { | |
return typeof value === 'object' && value !== null && | |
('isInertia' in value || 'onSuccess' in value || 'headers' in value || 'preserveState' in value) | |
} | |
// Main composable with overloaded signatures | |
export function useFormSimple<TForm extends Record<string, any>>( | |
data: TForm, | |
options?: EnhancedFormOptions | |
): InertiaForm<TForm> | |
export function useFormSimple<TForm extends Record<string, any>>( | |
rememberKey: string, | |
data: TForm, | |
options?: EnhancedFormOptions | |
): InertiaForm<TForm> | |
export function useFormSimple<TForm extends Record<string, any>>( | |
rememberKeyOrData: string | TForm, | |
maybeDataOrOptions?: TForm | EnhancedFormOptions, | |
maybeOptions?: EnhancedFormOptions | |
): InertiaForm<TForm> { | |
// Parse parameters with better validation | |
let rememberKey: string | undefined | |
let actualData: TForm | |
let actualOptions: EnhancedFormOptions = {} | |
if (typeof rememberKeyOrData === 'string') { | |
// Signature: (rememberKey, data, options?) | |
rememberKey = rememberKeyOrData | |
actualData = maybeDataOrOptions as TForm || {} as TForm | |
actualOptions = maybeOptions || {} | |
} else if (isPlainObject(rememberKeyOrData)) { | |
// Signature: (data, options?) | |
actualData = rememberKeyOrData as TForm | |
actualOptions = (isOptions(maybeDataOrOptions) ? maybeDataOrOptions : {}) as EnhancedFormOptions | |
} else { | |
throw new Error('Invalid parameters: expected (data, options?) or (rememberKey, data, options?)') | |
} | |
const { isInertia = true, ...formOptions } = actualOptions | |
// Create base form | |
const form = rememberKey | |
? useForm(rememberKey, actualData) | |
: useForm(actualData) | |
// INERTIA MODE - Simple and efficient | |
if (isInertia) { | |
// If no options provided, return original form (most efficient) | |
if (Object.keys(formOptions).length === 0) { | |
return form as InertiaForm<TForm> | |
} | |
// Otherwise, extend form with default options for method calls | |
const extendedForm = form as any | |
const originalMethods = { | |
get: extendedForm.get, | |
post: extendedForm.post, | |
put: extendedForm.put, | |
patch: extendedForm.patch, | |
delete: extendedForm.delete, | |
submit: extendedForm.submit, | |
} | |
// Override methods to merge default options | |
Object.entries(originalMethods).forEach(([methodName, originalMethod]) => { | |
extendedForm[methodName] = (...args: any[]) => { | |
// Only merge if last argument is not already options | |
const lastArg = args[args.length - 1] | |
const hasOptionsArg = args.length >= 2 && isOptions(lastArg) | |
if (hasOptionsArg) { | |
// Merge with existing options (call-specific options take precedence) | |
args[args.length - 1] = { ...formOptions, ...lastArg } | |
} else if (args.length >= 1) { | |
// Add default options | |
args.push(formOptions) | |
} | |
return originalMethod.apply(extendedForm, args) | |
} | |
}) | |
return extendedForm as InertiaForm<TForm> | |
} | |
// AXIOS MODE - Proxy implementation | |
let transform = (data: TForm): any => data | |
let recentlySuccessfulTimeoutId: ReturnType<typeof setTimeout> | null = null | |
let activeAbortController: AbortController | null = null | |
// Cleanup function | |
const cleanup = () => { | |
if (recentlySuccessfulTimeoutId) { | |
clearTimeout(recentlySuccessfulTimeoutId) | |
recentlySuccessfulTimeoutId = null | |
} | |
if (activeAbortController) { | |
activeAbortController.abort() | |
activeAbortController = null | |
} | |
} | |
// Auto-cleanup on unmount if in Vue component context | |
try { | |
onUnmounted(cleanup) | |
} catch { | |
// Not in Vue component context, cleanup must be manual | |
} | |
const axiosSubmit = async (method: Method, url: string, submitOptions: CommonFormOptions = {}) => { | |
// Merge options | |
const mergedOptions = { ...formOptions, ...submitOptions } | |
// Cancel previous request if exists (like Inertia does) | |
if (activeAbortController) { | |
activeAbortController.abort() | |
} | |
// Create new abort controller for this request | |
const thisRequestController = new AbortController() | |
activeAbortController = thisRequestController | |
// Prepare data | |
const requestData = transform(form.data()) | |
// Reset form state | |
form.wasSuccessful = false | |
form.recentlySuccessful = false | |
form.clearErrors() | |
// Clear any previous timeout | |
if (recentlySuccessfulTimeoutId) { | |
clearTimeout(recentlySuccessfulTimeoutId) | |
recentlySuccessfulTimeoutId = null | |
} | |
// BEFORE callback | |
mergedOptions.onBefore?.({ method, url, data: requestData }) | |
// START processing | |
form.processing = true | |
mergedOptions.onStart?.({ method, url, data: requestData }) | |
try { | |
// Configure axios request | |
const config: AxiosRequestConfig = { | |
method: method.toLowerCase(), | |
url, | |
signal: thisRequestController.signal, | |
headers: { | |
'X-Requested-With': 'XMLHttpRequest', | |
...mergedOptions.headers, | |
}, | |
onUploadProgress: (progressEvent) => { | |
if (progressEvent.total) { | |
const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total) | |
// Set form.progress to match Inertia's expected type | |
form.progress = { percentage } as any | |
// Pass full progress data to callback | |
mergedOptions.onProgress?.({ percentage }) | |
} | |
} | |
} | |
// Handle request data based on method | |
if (['get', 'delete'].includes(method.toLowerCase())) { | |
config.params = requestData | |
} else { | |
if (hasFiles(requestData) || mergedOptions.forceFormData) { | |
config.data = objectToFormData(requestData) | |
} else { | |
config.data = requestData | |
config.headers!['Content-Type'] = 'application/json' | |
} | |
} | |
// Make request | |
const response: AxiosResponse = await axios(config) | |
// Only update state if this is still the active request | |
if (activeAbortController === thisRequestController) { | |
// Success handling | |
form.processing = false | |
form.progress = null | |
form.clearErrors() | |
form.wasSuccessful = true | |
form.recentlySuccessful = true | |
// Set recentlySuccessful timeout (2 seconds like Inertia) | |
recentlySuccessfulTimeoutId = setTimeout(() => { | |
form.recentlySuccessful = false | |
}, 2000) | |
// SUCCESS callback | |
mergedOptions.onSuccess?.(response.data) | |
// Auto-reset defaults (unless explicitly disabled) | |
if (mergedOptions.resetOnSuccess !== false) { | |
form.defaults(cloneDeep(form.data())) | |
// isDirty will be updated automatically by Inertia | |
} | |
} | |
return response.data | |
} catch (error: any) { | |
// Handle cancellation - DON'T modify form state | |
if (error.name === 'AbortError' || axios.isCancel?.(error)) { | |
mergedOptions.onCancel?.() | |
return // Exit without modifying processing state | |
} | |
// Only update state if this is still the active request | |
if (activeAbortController === thisRequestController) { | |
form.processing = false | |
form.progress = null | |
// Handle validation errors (422) | |
form.clearErrors() | |
if (error.response?.status === 422 && error.response.data?.errors) { | |
// Build complete error object for single setError call | |
const processedErrors: Record<string, string> = {} | |
Object.entries(error.response.data.errors).forEach(([field, errors]) => { | |
processedErrors[field] = Array.isArray(errors) ? errors[0] : String(errors) | |
}) | |
;(form.setError as any)(processedErrors) | |
} | |
// ERROR callback | |
mergedOptions.onError?.(form.hasErrors ? form.errors : error) | |
} | |
throw error | |
} finally { | |
// Only cleanup if this is still the active request | |
if (activeAbortController === thisRequestController) { | |
activeAbortController = null | |
} | |
// FINISH callback | |
mergedOptions.onFinish?.() | |
} | |
} | |
// Create proxy for axios mode | |
const axiosOverrides = { | |
transform: (callback: (data: TForm) => any) => { | |
transform = callback | |
return proxyForm | |
}, | |
submit: (method: Method, url: string, options?: CommonFormOptions) => { | |
return axiosSubmit(method, url, options) | |
}, | |
get: (url: string, options?: CommonFormOptions) => axiosSubmit('get', url, options), | |
post: (url: string, options?: CommonFormOptions) => axiosSubmit('post', url, options), | |
put: (url: string, options?: CommonFormOptions) => axiosSubmit('put', url, options), | |
patch: (url: string, options?: CommonFormOptions) => axiosSubmit('patch', url, options), | |
delete: (url: string, options?: CommonFormOptions) => axiosSubmit('delete', url, options), | |
cancel: () => cleanup(), | |
} | |
const proxyForm = new Proxy(form, { | |
get: (target, prop) => { | |
if (prop in axiosOverrides) { | |
return (axiosOverrides as any)[prop] | |
} | |
return (target as any)[prop] | |
}, | |
}) as InertiaForm<TForm> | |
return proxyForm | |
} | |
// Optimized FormData converter | |
function objectToFormData(obj: any, formData = new FormData(), prefix = ''): FormData { | |
for (const key in obj) { | |
if (!obj.hasOwnProperty(key)) continue | |
const value = obj[key] | |
const formKey = prefix ? `${prefix}[${key}]` : key | |
if (value == null) continue | |
if (value instanceof File || value instanceof Blob) { | |
formData.append(formKey, value) | |
} else if (Array.isArray(value)) { | |
value.forEach((item, index) => { | |
const itemKey = `${formKey}[${index}]` | |
if (item && typeof item === 'object' && !(item instanceof File)) { | |
objectToFormData({ [index]: item }, formData, formKey) | |
} else { | |
formData.append(itemKey, item) | |
} | |
}) | |
} else if (typeof value === 'object') { | |
objectToFormData(value, formData, formKey) | |
} else { | |
formData.append(formKey, String(value)) | |
} | |
} | |
return formData | |
} | |
// Re-export with cleaner name | |
export { useFormSimple as useFormEnhanced } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment