useForm en Laravel Inertia con Vue 3: formularios, validación y actualizaciones optimistas

- Andrés Cruz - EN In english

Video thumbnail

Los formularios son los componentes empleados por excelencia para obtener datos por parte del usuario, y son una de las características principales que tenemos en Laravel Inertia: podemos usarlos manteniendo el esquema clásico de Laravel en el servidor mientras aprovechamos Vue con Inertia en el cliente.

Trabajar con un formulario en Laravel Inertia es una de las mejores maneras de combinar la solidez del backend de Laravel con la fluidez de Vue en el cliente.
Cuando empecé a usar Inertia, algo que realmente me sorprendió fue lo natural que resulta manejar formularios: se sienten como los formularios tradicionales de Laravel, pero con las ventajas de una SPA —sin recargas completas y sin tener que construir una API desde cero.

En esta guía te muestro exactamente cómo implementar un formulario tanto para crear como para editar registros, incluyendo:

  • Controladores en Laravel
  • Validación de datos
  • props e Inertia
  • Manejo de errores de validación

Los formularios son una herramienta fundamental para recolectar información del usuario. Cuando hablamos del proceso de actualización, solemos partir desde un listado en el que el usuario elige qué registro desea modificar.
Al seleccionar un elemento, la aplicación lo redirige al formulario de edición, el cual ya incluye el identificador del registro. Ese identificador normalmente viaja en la URL y permite al backend saber qué datos deben cargarse desde la base de datos.

Tal como en cualquier formulario HTML, podemos incluir cualquier tipo de campo: input, select, textarea, inputs de tipo archivo, etc.
La diferencia clave con Inertia es que todo fluye como una SPA —sin recargas completas—, manteniendo la simplicidad del ciclo de vida típico de Laravel.

Una vez que el usuario ha editado los datos, puede enviar el formulario haciendo clic en un botón "Actualizar" o "Enviar" para que Laravel se encargue de las validaciones y del posterior guardado en la base de datos, o bien muestre los errores de validación si algo no está correcto.

Laravel Inertia no es más que un scaffolding o esqueleto para Laravel que agrega funcionalidades a un proyecto para poder integrar Vue como tecnología del cliente, manteniendo Laravel como tecnología en el servidor.

1. ¿Qué es Laravel Inertia y por qué facilita el manejo de formularios?

Inertia es un puente que elimina el típico divorcio entre backend y frontend. Técnicamente no es un framework, sino un scaffolding que permite usar Vue, React o Svelte como vistas, mientras Laravel sigue controlando rutas, controladores y validación.

Cómo se integra Vue y Laravel en un mismo flujo

En mi caso, describo Inertia como "Vue con esteroides", porque puedo seguir usando mis controladores de Laravel tal cual, pero en vez de devolver un view(), devuelvo un inertia(), que renderiza un componente Vue con los datos ya incluidos.

Ventajas reales al crear formularios dinámicos

  • Puedes usar v-model como en cualquier app Vue.
  • Puedes enviar formularios con router.post, router.put o router.delete sin recargar la página.
  • Laravel sigue manejando la validación con FormRequest.
  • El estado del formulario (errores, processing, etc.) lo gestiona automáticamente el helper useForm.

Inertia facilita enormemente el proceso de manejo de formularios con Vue, ya que no necesitamos implementar una REST API por separado; el objeto useForm simplifica el proceso de validaciones y el manejo de estado del formulario.

2. Preparar el controlador en Laravel para manejar formularios

En Laravel, dentro de un controlador que trabaja con categorías, creamos los métodos necesarios para presentar el componente en Vue mediante la función inertia(), en vez de la función clásica view(), que renderiza un componente Vue con los datos ya disponibles.

Método create() y envío de datos al componente

El método create() se encarga únicamente de renderizar el componente Vue del formulario de creación. Si necesitas pasar datos al componente —por ejemplo, una lista de categorías padre—, los agregas como segundo argumento del inertia():

app/Http/Controllers/Dashboard/CategoryController.php

<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
   public function create()
   {
       return inertia("Dashboard/Category/Create");
   }
}

Método store() para guardar registros con Inertia

El método store() recibe los datos enviados desde el formulario Vue. En mis proyectos suelo procesarlo así:

class CategoryController extends Controller
{
   public function store(Request $request)
   {
       Category::create(
           [
               'title' => request('title'),
               'slug' => request('slug'),
           ]
       );
       dd($request->all());
   }
}

Nota: el dd($request->all()) es solo para depuración durante el desarrollo. En producción deberías retornar un redirect() o una respuesta inertia() según corresponda.

3. Crear un formulario básico en Inertia con Vue

Creamos el formulario con sus directivas v-model respectivas y manejamos el evento submit para enviar los datos al método anterior del controlador:

resources/js/Pages/Dashboard/Category/Create.vue

<template>
  <form @submit.prevent="submit">
    <label for="">Title</label>
    <input type="text" v-model="form.title" />
    <label for="">Slug</label>
    <input type="text" v-model="form.slug" />
    <button type="submit">Send</button>
  </form>
</template>

Para el apartado de script, usamos la función useForm(), un helper que facilita el manejo de errores y el estado del formulario en general. Al hacer el dd() en el servidor, verás que junto a los datos del formulario llegan también propiedades internas de Inertia:

array:10 [
  "title" => "Test"
  "slug" => "test-slug"
  "isDirty" => true
  "errors" => []
  "hasErrors" => false
  "processing" => false
  "progress" => null
  "wasSuccessful" => false
  "recentlySuccessful" => false
  "__rememberable" => true
]

Enviar datos usando useForm

Finalmente, usamos los métodos get, post, put, patch o delete disponibles en el objeto form de Inertia para enviar el formulario al servidor. También podemos usar el objeto router directamente:

<script>
import { router } from '@inertiajs/vue3'
import { useForm } from "@inertiajs/vue3";
export default {
  setup() {
    const form = useForm({
      title: null,
      slug: null,
    });
    function submit() {
      router.post(route("category.store"), form);
    }
    return { form, submit };
  },
};
</script>

Nota: en versiones recientes de Inertia v2 y v3, el import de useForm se hace directamente desde '@inertiajs/vue3', no desde '@inertiajs/inertia-vue3' (paquete legacy).

Manejo de estados: processing, errors y wasSuccessful

El helper useForm expone automáticamente las siguientes propiedades reactivas:

form.processing
form.errors
form.wasSuccessful

Estas propiedades se sincronizan sin que tengas que gestionar ningún estado manualmente. Por ejemplo, puedes usar form.processing para deshabilitar el botón de envío mientras la petición está en curso, y form.errors para mostrar los mensajes de validación de Laravel directamente en el template.

Editar registros en base a evento click

Video thumbnail

Editar un elemento directamente en el listado —sin usar modales ni pantallas adicionales— ofrece una experiencia muy fluida para el usuario y es extremadamente sencillo de implementar con Vue e Inertia.

La idea es que cuando el usuario haga clic sobre el nombre de un to-do, ese <span> sea reemplazado por un <input> para permitir la edición inline. Al presionar Enter, se envía la actualización al servidor utilizando Inertia.

A continuación te explico paso a paso cómo implementarlo.

1. Modificar el listado para habilitar el modo edición

Archivo: resources/js/Pages/Todo/Index.vue

<li v-for="t in todos" class="border py-3 px-4 mt-2" :key="t">
  <span v-show="!t.editMode" @click="t.editMode = true">
    {{ t.name }}
  </span>
  <TextInput
    v-show="t.editMode"
    v-model="t.name"
    @keyup.enter="update(t)"
  />
  <button @click="remove" class="float-right">
    ***
  </button>
</li>
Explicación

A cada to-do le agregamos una propiedad adicional llamada editMode, la cual no existe en el modelo de la base de datos, sino que se añade dinámicamente del lado del cliente.

  • editMode === true → Se muestra el <input> para editar.
  • editMode === false → Se muestra el <span> con el nombre del to-do.

Gracias a la reactividad de Vue, cambiar editMode o el name se refleja automáticamente en la interfaz sin necesidad de código adicional.

Comportamiento

Cuando el usuario hace clic en el <span>, se activa el modo edición.

Cuando presiona Enter dentro del <input>, se ejecuta el método update(t).

2. Método update en el cliente (Vue + Inertia)

update(todo) {
  todo.editMode = false;
  router.put(route("todo.update", todo.id), {
    name: todo.name,
  });
},

¿Qué ocurre aquí?

  • Se desactiva el modo edición (editMode = false).
  • Se envía una petición PUT al backend utilizando router.put de Inertia.
  • Se envía únicamente el nuevo nombre del to-do.
  • Esto permite una experiencia fluida y sin recargas completas de página.

3. Controlador en Laravel

Archivo: app/Http/Controllers/TodoController.php

public function update(Todo $todo, Request $request)
{
   $request->validate([
       'name' => 'required|string|max:255'
   ]);
   Todo::where("id", $todo->id)
       ->where('user_id', auth()->id())
       ->update([
           'name' => $request->name,
       ]);
   return back();
}

Puntos importantes:

  • Se valida el campo name con las reglas de Laravel.
  • Se garantiza que solo el usuario dueño del to-do pueda editarlo mediante where('user_id', auth()->id()).
  • Se actualiza únicamente el campo name.

4. Definir la ruta de actualización

Archivo: routes/web.php

Route::group([
   'prefix' => 'todo',
   'middleware' => ['auth:sanctum', config('jetstream.auth_session'), 'verified']
], function () {
   Route::get('/', [TodoController::class, 'index'])->name('todo.index');
   Route::post('store', [TodoController::class, 'store'])->name('todo.store');
   Route::put('update/{todo}', [TodoController::class, 'update'])->name('todo.update');
});

Router Methods

También puedes usar el router directamente para tener un control más preciso sobre las peticiones:

import { router } from '@inertiajs/vue3'
router.get(url, data, options)
router.post(url, data, options)
router.put(url, data, options)
router.patch(url, data, options)
router.delete(url, options)

Opciones de Configuración del Router

Browser History

Por defecto, Inertia agrega una nueva entrada al historial del navegador. Puedes usar la opción replace para reemplazar la entrada actual en lugar de crear una nueva:

router.get('/post', data, { replace: true })

Component State

Por defecto, las visitas a la misma página fuerzan una nueva instancia del componente, lo que borra cualquier estado local: datos del formulario, posición de scroll y estados de foco.

En ciertas situaciones, es necesario preservar el estado del componente. Por ejemplo, al enviar un formulario con errores de validación, necesitamos que los datos ingresados por el usuario no desaparezcan.

Para preservar el estado del componente entre visitas a la misma página, usa la opción preserveState:

router.get('/post', data, { preserveState: true })

Preserve Scroll Position

Al navegar entre páginas, Inertia imita el comportamiento predeterminado del navegador y resetea automáticamente la posición de scroll al inicio del documento. Para evitar esto, usa la opción preserveScroll:

Inertia resetea el scroll automáticamente al navegar; usa preserveScroll: true para evitarlo.

router.put('/update', data, { preserveScroll: true })

Custom Headers

La opción headers te permite añadir cabeceras HTTP personalizadas a cualquier petición de Inertia:

router.post('/post', data, {
    headers: {
        'Custom-Header': 'value',
    },
})

FormData para Archivos

Cuando necesitas enviar archivos, debes indicarle a Inertia que use FormData en lugar del formato JSON predeterminado. Para ello, usa la opción forceFormData:

router.post('/upload', {
    image: file,
}, {
    forceFormData: true,
})

El Componente <Form> de Inertia v3

Inertia v3 introduce el componente <Form>, que maneja automáticamente el estado del formulario, los errores de validación y los estados de procesamiento, eliminando gran parte del boilerplate manual.

<template>
    <Form
        action="/users"
        method="post"
        #default="{
            errors,
            hasErrors,
            processing,
            progress,
            wasSuccessful,
            recentlySuccessful,
            setError,
            clearErrors,
            resetAndClearErrors,
            defaults,
            isDirty,
            reset,
            submit,
        }"
    >
        <input type="text" name="name" />
        <div v-if="errors.name">{{ errors.name }}</div>
        <button type="submit" :disabled="processing">
            {{ processing ? 'Saving...' : 'Save' }}
        </button>
        <span v-if="recentlySuccessful">Saved successfully!</span>
    </Form>
</template>

Slot Props

  • errors - Objeto con los errores de validación (usa notación de punto para campos anidados)
  • hasErrors - Booleano que indica si existen errores
  • processing - Booleano activo mientras la petición está en curso
  • progress - Progreso de la subida de archivos
  • wasSuccessful - Booleano que se activa tras una respuesta exitosa
  • recentlySuccessful - Booleano que se activa brevemente tras éxito (ideal para feedback visual)
  • isDirty - Booleano que indica si hay cambios sin guardar
  • defaults - Método para actualizar los valores por defecto del formulario
  • setError(field, message) - Establece un error manualmente en un campo
  • clearErrors() - Limpia todos los errores del formulario
  • reset() - Restaura los valores al estado por defecto
  • submit() - Envía el formulario de forma programática

Form Context

Si necesitas acceder al estado del formulario desde componentes hijos sin pasar props, puedes usar el composable useFormContext:

import { useFormContext } from '@inertiajs/vue3'
const form = useFormContext()
// Disponible solo dentro de un componente <Form>
<div v-if="form">
    <span v-if="form.isDirty">Unsaved changes</span>
    <button @click="form.submit()">Send</button>
</div>

Event Callbacks

Los callbacks se ejecutan en diferentes etapas del ciclo de vida de la petición HTTP. Son muy útiles para mostrar loaders, notificaciones o para limpiar estado:

router.post('/post', data, {
    onBefore: (visit) => {},
    onStart: (visit) => {},
    onProgress: (progress) => {},
    onSuccess: (page) => {},
    onError: (errors) => {},
    onCancel: () => {},
    onFinish: (visit) => {},
})
  • onBefore: se ejecuta antes de que la petición inicie.
  • onProgress: se ejecuta mientras la petición está en curso (útil para barras de progreso).
  • onSuccess: se ejecuta cuando el servidor devuelve una respuesta exitosa.
  • onError: se ejecuta cuando ocurre un problema (errores de validación o de servidor).

Cambios en Inertia v3

En Inertia v3, algunos eventos globales fueron renombrados para mayor claridad:

  • invalid → httpException
  • exception → networkError
  • cancel() → cancelAll()

useHttp Hook (Inertia v3)

El hook useHttp es una novedad de Inertia v3 que ofrece una forma moderna de realizar peticiones HTTP independientes de la navegación —ideal para llamadas a la API sin que Inertia gestione la respuesta como una visita de página:

import { useHttp } from '@inertiajs/vue3'
const http = useHttp({
    name: '',
    email: '',
})
// Peticiones básicas
http.get('/api/users')
http.post('/api/users', { name: 'John' })
// Con opciones y callbacks
http.post('/api/users', { name: 'John' }, {
    onSuccess: () => console.log('User created'),
})

Actualizaciones Optimistas en Inertia.js 3

Video thumbnail

Las actualizaciones optimistas son una técnica de diseño de interfaces que prioriza la velocidad percibida.

En lugar de esperar a que el servidor confirme que los datos se guardaron, la interfaz se actualiza instantáneamente asumiendo que la operación tendrá éxito.

1. El Enfoque Tradicional (Pesimista)

Normalmente, el flujo es el siguiente: el usuario hace clic, se muestra un indicador de carga, esperamos la respuesta del servidor (latencia, base de datos, envío de correos) y, finalmente, actualizamos la interfaz en el callback onSuccess:

resources/js/pages/todo/Index.vue

// *** Actualizaciones normales (enfoque pesimista)
const status = (todo: any) => {
    // Actualiza DESPUÉS de recibir la respuesta del servidor (más lento)
    router.post(todoStatus(todo.id).url, {
        status: todo.status == '1' ? '0' : '1',
    },
        {
            onError: (errors) => {
                add('Error de validación o del servidor');
            },
            onSuccess: () => {
                // Actualiza AL CONFIRMAR los cambios en el servidor
                todo.status = todo.status == '1' ? '0' : '1';
            }
        },
    );
};

Si simulamos un retraso con un sleep(2) en el servidor, la experiencia resulta frustrante: el usuario hace clic y debe esperar dos segundos para ver el cambio. Esto no se siente natural:

app/Http/Controllers/TodoController.php

class TodoController extends Controller
{
    ***
    function status(Todo $todo)
    {
         sleep(2);
        $randomError = rand(1, 10) <= 7;
        if ($randomError) {
            throw ValidationException::withMessages([
            'status' => 'Random error occurred - optimistic update will rollback',
        ]);
        }
        Todo::where("id", $todo->id)->where("user_id", auth()->id())->update([
            'status' => request('status') == '1'
        ]);
        return redirect(route('todo.index'));
    }
}    

Implementación con router.optimistic

Inertia.js facilita las actualizaciones optimistas mediante el método encadenado optimistic(). Vamos a analizar cómo funciona en un componente de tareas (To-Do) donde queremos cambiar el estado de "completado".

Anteriormente, cambiábamos el estado manualmente antes de la respuesta del servidor, lo que podía dejar la interfaz en un estado inválido si el servidor fallaba:

resources/js/pages/todo/Index.vue

const status = (todo: any) => {
    // Actualiza ANTES de recibir la respuesta, pero NO revierte si hay error
    todo.status = todo.status == '1' ? '0' : '1';

    router.post(todoStatus(todo.id).url, {
        status: todo.status,
    },

Con el nuevo método optimistic(), Inertia gestiona el rollback automático por nosotros:

resources/js/pages/todo/Index.vue

const status = (todo: any) => {
    const newStatus = todo.status == '1' ? '0' : '1';
    router
    .optimistic((props) => ({
        todos: props.todos.map((t) =>
            t.id === todo.id ? { ...t, status: newStatus } : t
        ),
    }))
    .post(todoStatus(todo.id).url, {
        status: newStatus,
    }, {
        onError: (errors) => {
            add('Error de validación o lógica', 'error');
        },
        onFinish: () => {
            // Revertir loaders si los hubiera
        }
    });
};

Aquí, el bloque:

.optimistic((props) => ({
     todos: props.todos.map((t) =>
          t.id === todo.id ? { ...t, status: newStatus } : t
      ),
}))

Aplica el cambio en la UI de manera inmediata, asumiendo que no habrá errores en el servidor. Pero, al igual que sucede con las migraciones en Laravel, Inertia sabe exactamente qué debe revertir si ocurre un error. La analogía es perfecta: el método optimistic() define el up() del cambio, y si falla, Inertia ejecuta el down() automáticamente:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            ***
        });
    }
    
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

¿Cómo funciona la "magia"?

  • Clic del usuario: Se ejecuta inmediatamente lo que está dentro de optimistic(). La interfaz cambia al instante (el icono cambia de estado).
  • Petición en segundo plano: El servidor procesa la actualización mientras el usuario ya ve el resultado esperado.
  • Manejo de errores (Rollback): Si el servidor devuelve un error (por ejemplo, un 422 o un 500), Inertia realiza automáticamente el rollback. No necesitas programar manualmente la lógica de "volver atrás"; el estado regresa a su valor original basándose en los datos del servidor:
    • // Error de servidor directo
      abort(422, 'Random error occurred - optimistic update will rollback');
      
      // Error de servidor con JSON
      return response()->json([
                 'message' => 'Random error occurred - optimistic update will rollback'
                  ], 422);
      
      // Error de validación de Laravel
      throw ValidationException::withMessages([
           'status' => 'Random error occurred - optimistic update will rollback',
      ]);

Uso en Formularios con useForm

Esta técnica no se limita al router. También puedes emplearla con el hook useForm. Es muy útil, por ejemplo, en un botón de "Like":

  • Acción: El usuario da "Like".
  • Optimismo: Incrementamos el contador en +1 visualmente de inmediato.
  • Error: Si falla la red o el servidor, el contador vuelve a su número anterior automáticamente.
  <Form
    action="/todos"
    method="post"
    :optimistic="(props, data) => ({
      todos: [...props.todos, { id: Date.now(), name: data.name, done: false }],
    })"
  >

Es muy similar a las migraciones de base de datos: así como tenemos un método up() para crear y un down() para revertir, la actualización optimista asume el up() y ejecuta el down() (rollback) automáticamente si algo sale mal.

El uso de optimistic() nos evita realizar comprobaciones manuales y hace que nuestra aplicación se sienta increíblemente rápida. Recuerda que esta técnica es ideal para operaciones ligeras y frecuentes donde la probabilidad de éxito es alta (cambios de estado, likes, toggles).

El Componente <Form> de Inertia

Vamos ahora con el componente de formulario avanzado que aparece en la documentación oficial y en los mismos componentes que trae el proyecto por defecto, como el de Edit.vue.

Lo implementamos para que veas cómo simplifica todo el manejo de estados del formulario.

Importación y Estructura

Lo primero es importar el componente directamente desde el paquete de Inertia. Este componente actúa como un "wrapper" o envoltorio inteligente que gestiona el estado del formulario internamente:

<Form :action="store()" class="space-y-6">
   ***
</Form>
import { Head, useForm, Form } from '@inertiajs/vue3'

2. Uso de Slots y Destructuring

A diferencia del método manual, aquí aprovechamos el slot por defecto para obtener directamente las variables que necesitamos: errors, processing y recentlySuccessful (entre otras). La documentación oficial y los componentes de ejemplo de Inertia son una excelente referencia.

Fíjate que ya no hace falta declarar errors como un Prop, ya que el componente <Form> nos lo entrega automáticamente a través del contexto del formulario. La sintaxis moderna utiliza la almohadilla #default para acceder a estos datos, o también puedes usar la directiva v-slot:

<Form :action="store()" class="space-y-6"
    v-slot="{ errors, processing, recentlySuccessful }"
>
    ***
    <div class="flex items-center gap-4">
        <Button type="submit" :disabled="processing">
            Save
        </Button>

        <Transition enter-active-class="transition ease-in-out" enter-from-class="opacity-0"
        leave-active-class="transition ease-in-out" leave-to-class="opacity-0">
        <p v-show="recentlySuccessful" class="text-sm text-neutral-600">
            Saved.
        </p>
    </Transition>
    </div>
</Form>

Ventajas de recentlySuccessful y processing

Esta es la parte más potente del esquema moderno:

  • processing: Se vuelve true automáticamente mientras la petición viaja al servidor, lo que nos permite deshabilitar el botón de envío con :disabled="processing" sin lógica extra.
  • recentlySuccessful: Es un booleano que se activa justo después de una respuesta exitosa y se desactiva automáticamente tras unos segundos. Es perfecto para mostrar ese pequeño texto de "Guardado" con una transición suave, sin necesidad de gestionar timers manualmente.

Prueba de Funcionamiento

Si enviamos el formulario con errores, verás que la petición llega correctamente y los mensajes de validación de Laravel aparecen donde deben. Al crear una categoría correctamente (por ejemplo, "Categoría 2"), el mensaje de éxito aparecerá y desaparecerá automáticamente gracias a la animación que puedes personalizar a tu gusto con Transition de Vue.

Hemos pasado de un formulario manual a uno totalmente automatizado. Ya no declaramos props de errores ni gestionamos timers para los mensajes de éxito; el componente <Form> de Inertia y sus helpers se encargan de todo el trabajo pesado.

El siguiente paso es que aprendas cómo implementar carga de archivos con Drag and Drop en Laravel Inertia.

Aprende a manejar formularios en Laravel Inertia con Vue 3 usando useForm, el componente

de Inertia v3, validación, edición inline y actualizaciones optimistas con rollback automático.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.