Skip to content

Instantly share code, notes, and snippets.

@empresarrollo
Created September 18, 2025 16:24
Show Gist options
  • Save empresarrollo/73470ae560b5049eb209acc2da2a515f to your computer and use it in GitHub Desktop.
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.
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