TechLead

Server Actions y Mutaciones

Maneja envíos de formularios y mutaciones de datos con Server Actions

¿Qué son los Server Actions?

Los Server Actions son funciones asíncronas que se ejecutan en el servidor. Pueden usarse en Server y Client Components para manejar envíos de formularios y mutaciones de datos. ¡No hay necesidad de crear rutas API para operaciones CRUD básicas!

⚡ Beneficios de Server Actions

  • ✅ Mejora progresiva - funciona sin JavaScript
  • ✅ Type-safe con TypeScript
  • ✅ Integrado con cacheo de Next.js
  • ✅ No se necesita código repetitivo de API
  • ✅ Validación automática de formularios

Server Action Básico

Los Server Actions se ejecutan en el servidor, así que puedes validar entrada, escribir en la base de datos y revalidar o redirigir después de una mutación.

// app/actions.ts
'use server'; // Marcar todo el archivo como server actions

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // Validar
  if (!title || title.length < 3) {
    return { error: 'El título debe tener al menos 3 caracteres' };
  }
  
  // Crear en base de datos
  const post = await db.post.create({
    data: { title, content },
  });
  
  // Revalidar y redirigir
  revalidatePath('/posts');
  redirect(`/posts/${post.id}`);
}

// Usando en un Server Component
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Título" required />
      <textarea name="content" placeholder="Contenido" />
      <button type="submit">Crear Post</button>
    </form>
  );
}

useActionState para Estado de Formulario

useActionState permite a un Client Component recibir el resultado del action, lo cual es útil para mostrar errores de validación o mensajes de éxito.

'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions';

// Action con estado
// app/actions.ts
'use server';
export async function createUser(
  prevState: { error?: string; success?: boolean },
  formData: FormData
) {
  const email = formData.get('email') as string;
  
  if (!email.includes('@')) {
    return { error: 'Dirección de email inválida' };
  }
  
  try {
    await db.user.create({ data: { email } });
    return { success: true };
  } catch (e) {
    return { error: 'El email ya existe' };
  }
}

// Client Component con estado de formulario
export default function SignupForm() {
  const [state, formAction, isPending] = useActionState(createUser, {});
  
  return (
    <form action={formAction}>
      <input 
        name="email" 
        type="email" 
        placeholder="Email"
        disabled={isPending}
      />
      
      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      
      {state.success && (
        <p className="text-green-500">¡Usuario creado!</p>
      )}
      
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creando...' : 'Registrarse'}
      </button>
    </form>
  );
}

useFormStatus para Estado Pendiente

useFormStatus expone el estado pendiente para el formulario más cercano, habilitando botones deshabilitados y retroalimentación de carga durante el envío.

'use client';
import { useFormStatus } from 'react-dom';

// Componente de botón de envío
function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button 
      type="submit" 
      disabled={pending}
      className={pending ? 'opacity-50' : ''}
    >
      {pending ? 'Enviando...' : 'Enviar'}
    </button>
  );
}

// Componente de formulario
export default function ContactForm() {
  return (
    <form action={sendMessage}>
      <input name="message" placeholder="Mensaje" />
      <SubmitButton /> {/* Debe estar dentro del formulario */}
    </form>
  );
}

Actualizaciones Optimistas

La UI optimista muestra actualizaciones inmediatamente mientras se ejecuta el server action, luego reconcilia con el resultado real.

'use client';
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';

interface Todo {
  id: string;
  text: string;
  pending?: boolean;
}

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state: Todo[], newTodo: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newTodo, pending: true },
    ]
  );

  async function formAction(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimisticTodo(text); // Actualizar UI inmediatamente
    await addTodo(text);     // Luego persistir en servidor
  }

  return (
    <div>
      <form action={formAction}>
        <input name="text" placeholder="Agregar todo..." />
        <button type="submit">Agregar</button>
      </form>
      
      <ul>
        {optimisticTodos.map(todo => (
          <li 
            key={todo.id}
            className={todo.pending ? 'opacity-50' : ''}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Validación con Zod

Valida y coerce entrada en el servidor para asegurar datos consistentes antes de escribir en la base de datos.

// app/actions.ts
'use server';
import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
  category: z.enum(['tech', 'lifestyle', 'news']),
});

export async function createPost(
  prevState: { errors?: z.ZodError['errors'] },
  formData: FormData
) {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  };

  const result = PostSchema.safeParse(rawData);

  if (!result.success) {
    return { errors: result.error.errors };
  }

  // ¡Datos validados y tipados!
  const { title, content, category } = result.data;
  
  await db.post.create({
    data: { title, content, category },
  });

  revalidatePath('/posts');
  redirect('/posts');
}

Llamar Actions Programáticamente

Usa useTransition para activar actions desde event handlers sin bloquear actualizaciones de UI.

'use client';
import { deletePost, likePost } from '@/app/actions';
import { useTransition } from 'react';

export function PostActions({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();

  const handleDelete = () => {
    if (!confirm('¿Eliminar este post?')) return;
    
    startTransition(async () => {
      await deletePost(postId);
    });
  };

  const handleLike = () => {
    startTransition(async () => {
      await likePost(postId);
    });
  };

  return (
    <div>
      <button 
        onClick={handleLike}
        disabled={isPending}
      >
        ❤️ Me gusta
      </button>
      <button 
        onClick={handleDelete}
        disabled={isPending}
        className="text-red-500"
      >
        🗑️ Eliminar
      </button>
    </div>
  );
}

📖 Documentación de Server Actions →

✅ Mejores Prácticas de Server Actions

  • • Siempre validar entrada en el servidor
  • • Usar useActionState para retroalimentación de formulario
  • • Usar useFormStatus para estados de carga
  • • Usar useOptimistic para actualizaciones instantáneas de UI
  • • Revalidar caché después de mutaciones
  • • Manejar errores con gracia