Skip to content
All essays
WebMarch 27, 202416 min

React Server Actions Deep Dive

Master React Server Actions for seamless client-server communication.

Ü
Ümit Uz
Mobile & Full Stack Developer

React Server Actions enable client components to call server-side functions directly, simplifying data mutations and improving user experience.

What Are Server Actions?

Server Actions are asynchronous functions that run on the server but can be called from client components.

javascript
// Server Action
'use server';
export async function createTodo(formData) {
  const title = formData.get('title');
  await db.todo.create({ data: { title } });
  revalidatePath('/todos');
}

// Client Component
'use client';
import { createTodo } from './actions';

function TodoForm() {
  return (
    <form action={createTodo}>
      <input name="title" type="text" />
      <button type="submit">Add</button>
    </form>
  );
}

Defining Server Actions

Basic Action

javascript
// actions/server.ts
'use server';

export async function updateProfile(formData) {
  const name = formData.get('name');
  const email = formData.get('email');

  await db.user.update({
    where: { id: userId },
    data: { name, email },
  });

  return { success: true };
}

With Explicit Parameters

javascript
'use server';

export async function deleteTodo(id: string) {
  await db.todo.delete({ where: { id } });

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

Using Server Actions

With Forms

javascript
'use client';
import { createTodo } from './actions';

function TodoForm() {
  return (
    <form action={createTodo}>
      <input name="title" required />
      <button type="submit">Create Todo</button>
    </form>
  );
}

With Buttons

javascript
'use client';
import { deleteTodo } from './actions';

function TodoItem({ id, title }) {
  return (
    <div>
      <span>{title}</span>
      <form action={deleteTodo.bind(null, id)}>
        <button type="submit">Delete</button>
      </form>
    </div>
  );
}

With Custom Handlers

javascript
'use client';
import { createTodo } from './actions';

function TodoForm() {
  const handleSubmit = async (formData) => {
    await createTodo(formData);
    // Additional client-side logic
    trackEvent('todo_created');
  };

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

State Management

useActionState

javascript
'use client';
import { useActionState } from 'react';

function TodoForm() {
  const [state, formAction, isPending] = useActionState(
    createTodo,
    { error: null, success: false }
  );

  return (
    <form action={formAction}>
      <input name="title" />
      {state.error && <div className="error">{state.error}</div>}
      {state.success && <div className="success">Created!</div>}
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create'}
      </button>
    </form>
  );
}

useFormStatus

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

function SubmitButton() {
  const { pending, data } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

function MyForm() {
  return (
    <form action={submitData}>
      <input name="email" />
      <SubmitButton />
    </form>
  );
}

Error Handling

Server-Side Errors

javascript
'use server';

export async function createUser(formData) {
  const email = formData.get('email');

  const existing = await db.user.findUnique({ where: { email } });
  if (existing) {
    return { error: 'User already exists' };
  }

  const user = await db.user.create({
    data: { email, name: formData.get('name') },
  });

  return { success: true, user };
}

Client-Side Error Display

javascript
'use client';
import { useActionState } from 'react';

function SignupForm() {
  const [state, formAction] = useActionState(createUser, null);

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <input name="name" />
      {state?.error && (
        <div className="error">{state.error}</div>
      )}
      <button type="submit">Sign Up</button>
    </form>
  );
}

Optimistic Updates

useOptimistic

javascript
'use client';
import { useOptimistic } from 'react';

function TodoList({ initialTodos }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    initialTodos,
    (state, newTodo) => [...state, newTodo]
  );

  return (
    <div>
      <form action={async (formData) => {
        const newTodo = {
          id: Date.now(),
          title: formData.get('title'),
          pending: true,
        };

        addOptimisticTodo(newTodo);
        await createTodo(formData);
        revalidatePath('/todos');
      }}>
        <input name="title" />
        <button type="submit">Add</button>
      </form>

      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.pending && ' (Saving...)'}
          </li>
        ))}
      </ul>
    </div>
  );
}

Validation

Server-Side Validation

javascript
'use server';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
});

export async function createUser(formData) {
  const validated = schema.safeParse({
    email: formData.get('email'),
    name: formData.get('name'),
  });

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

  const user = await db.user.create({
    data: validated.data,
  });

  return { success: true, user };
}

Client-Side Validation

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

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      disabled={pending}
      formNoValidate={false}
    >
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

function SignupForm() {
  return (
    <form action={createUser}>
      <input
        name="email"
        type="email"
        required
        pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
      />
      <input
        name="name"
        required
        minLength={2}
      />
      <SubmitButton />
    </form>
  );
}

Revalidation

revalidatePath

javascript
'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData) {
  await db.post.create({
    data: {
      title: formData.get('title'),
      content: formData.get('content'),
    },
  });

  // Revalidate the posts page
  revalidatePath('/posts');
  revalidatePath('/admin/posts');
}

revalidateTag

javascript
'use server';
import { revalidateTag } from 'next/cache';

export async function updateProduct(id, data) {
  await db.product.update({
    where: { id },
    data,
  });

  // Revalidate all cached data with this tag
  revalidateTag('products');
}

// In fetch
async function Products() {
  const products = await fetch('https://api.com/products', {
    next: { tags: ['products'] },
  }).then(r => r.json());

  return <ProductList products={products} />;
}

Redirects

After Action

javascript
'use server';
import { redirect } from 'next/navigation';

export async function deletePost(id) {
  await db.post.delete({ where: { id } });

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

Conditional Redirect

javascript
'use server';
import { redirect } from 'next/navigation';

export async function updatePost(id, formData) {
  const post = await db.post.update({
    where: { id },
    data: { title: formData.get('title') },
  });

  if (formData.get('redirectTo') === 'preview') {
    redirect(`/posts/${id}/preview`);
  } else {
    redirect(`/posts/${id}`);
  }
}

Progressive Enhancement

Server Actions as Fallback

javascript
'use client';
import { useState } from 'react';
import { createTodo } from './actions';

function TodoForm() {
  const [todos, setTodos] = useState([]);

  const handleSubmit = async (formData) => {
    // Optimistic update
    const optimisticTodo = {
      id: Date.now(),
      title: formData.get('title'),
    };
    setTodos(prev => [...prev, optimisticTodo]);

    // Server action
    await createTodo(formData);

    // Refresh data
    mutate('/api/todos');
  };

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">Add</button>
    </form>
  );
}

Best Practices

DOs

  • Use Server Actions for mutations
  • Validate data on the server
  • Handle errors gracefully
  • Provide optimistic updates
  • Revalidate relevant paths
  • Use type safety
  • Implement proper authentication

DON'Ts

  • Fetch data with Server Actions (use Server Components)
  • Ignore error handling
  • Skip validation
  • Forget revalidation
  • Overuse for simple operations
  • Mix client and server logic
  • Expose sensitive operations

Security

CSRF Protection

Server Actions automatically include CSRF tokens.

javascript
// No additional code needed
// Server Actions are protected by default

Input Validation

javascript
'use server';
import { z } from 'zod';

const schema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
});

export async function createPost(formData) {
  const validated = schema.safeParse(Object.fromEntries(formData));

  if (!validated.success) {
    return { error: 'Invalid input' };
  }

  // Process validated data
}

Real-World Example

Complete CRUD

javascript
// actions/todos.ts
'use server';

export async function getTodos() {
  return await db.todo.findMany();
}

export async function createTodo(formData) {
  const title = formData.get('title');
  await db.todo.create({ data: { title } });
  revalidatePath('/todos');
}

export async function updateTodo(id, formData) {
  const title = formData.get('title');
  await db.todo.update({ where: { id }, data: { title } });
  revalidatePath('/todos');
}

export async function deleteTodo(id) {
  await db.todo.delete({ where: { id } });
  revalidatePath('/todos');
}
javascript
// components/TodoApp.tsx
'use client';
import { useActionState } from 'react';
import { createTodo, deleteTodo } from './actions';

function TodoApp({ initialTodos }) {
  const [state, formAction] = useActionState(createTodo, null);

  return (
    <div>
      <form action={formAction}>
        <input name="title" />
        <button type="submit">Add</button>
      </form>

      <ul>
        {initialTodos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            <form action={deleteTodo.bind(null, todo.id)}>
              <button type="submit">×</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  );
}

Conclusion

Server Actions simplify client-server communication in React. Use them for mutations, leverage built-in hooks for better UX, and always validate on the server. They're the future of data mutations in React!

Enjoyed this essay? Share it.

Related essays

Next essay
React 19 New Features and Improvements