Volver al blog

React 19 Server Components: Guía Práctica Completa en 2025

Hola HaWkers, React Server Components (RSC) cambiaron fundamentalmente cómo construimos aplicaciones React. En 2025, con React 19 estable, RSC son el patrón predeterminado en Next.js y otros frameworks.

¿Aún estás confundido sobre cuándo usar Server vs Client Components? Esta guía práctica va a clarificar todo con ejemplos reales.

Qué Son React Server Components

Server Components son componentes que se ejecutan exclusivamente en el servidor. Ellos pueden:

  • Acceder directamente a bases de datos
  • Leer archivos del sistema
  • Hacer fetch de APIs sin exponer claves
  • Enviar solo HTML para el cliente (zero JavaScript)
// Este componente NUNCA va para el navegador
async function UserProfile({ userId }: { userId: string }) {
  // Acceso directo a la base de datos
  const user = await db.users.findUnique({
    where: { id: userId },
    include: { posts: true },
  });

  return (
    <div className="profile">
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      <PostsList posts={user.posts} />
    </div>
  );
}

Server vs Client Components

Cuándo Usar Cada Uno

Necesitas... Usa
Fetch de datos Server Component
Acceso a backend (DB, filesystem) Server Component
Secretos/API keys Server Component
Reducir bundle size Server Component
useState, useEffect Client Component
Event listeners (onClick, etc) Client Component
APIs del browser (localStorage, etc) Client Component
Hooks customizados con estado Client Component

Regla de Oro

Server Component = Datos + Presentación estática
Client Component = Interactividad

// page.tsx (Server Component)
import { ClientButton } from './client-button';

export default async function Page() {
  const data = await fetchData(); // Ejecuta en el servidor

  return (
    <div>
      <h1>{data.title}</h1> {/* Estático */}
      <ClientButton /> {/* Interactivo */}
    </div>
  );
}
// client-button.tsx (Client Component)
'use client';

import { useState } from 'react';

export function ClientButton() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Clicado {count} veces
    </button>
  );
}

Patrones de Composición

1. Server Component como Container

// users-page.tsx (Server)
import { UsersList } from './users-list';
import { UserFilters } from './user-filters';

export default async function UsersPage() {
  const users = await db.users.findMany();

  return (
    <div className="users-page">
      <h1>Usuarios</h1>
      <UserFilters /> {/* Client - interactividad */}
      <UsersList users={users} /> {/* Server - solo render */}
    </div>
  );
}
// user-filters.tsx (Client)
'use client';

import { useRouter, useSearchParams } from 'next/navigation';

export function UserFilters() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const handleFilterChange = (filter: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('filter', filter);
    router.push(`?${params.toString()}`);
  };

  return (
    <select onChange={(e) => handleFilterChange(e.target.value)}>
      <option value="all">Todos</option>
      <option value="active">Activos</option>
      <option value="inactive">Inactivos</option>
    </select>
  );
}

2. Pasando Server Data para Client Component

// product-page.tsx (Server)
import { ProductDetails } from './product-details';
import { AddToCartButton } from './add-to-cart-button';

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.products.findUnique({
    where: { id: params.id },
  });

  return (
    <div>
      <ProductDetails product={product} /> {/* Server */}
      <AddToCartButton
        productId={product.id}
        price={product.price}
      /> {/* Client con datos del server */}
    </div>
  );
}
// add-to-cart-button.tsx (Client)
'use client';

import { useState } from 'react';
import { addToCart } from './actions';

interface Props {
  productId: string;
  price: number;
}

export function AddToCartButton({ productId, price }: Props) {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Agregando...' : `Agregar por ${price}€`}
    </button>
  );
}

3. Children Pattern

// modal-wrapper.tsx (Client)
'use client';

import { useState } from 'react';

export function ModalWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  if (!isOpen) {
    return <button onClick={() => setIsOpen(true)}>Abrir Modal</button>;
  }

  return (
    <dialog open>
      {children} {/* Server Component puede ser children */}
      <button onClick={() => setIsOpen(false)}>Cerrar</button>
    </dialog>
  );
}
// page.tsx (Server)
import { ModalWrapper } from './modal-wrapper';
import { UserProfile } from './user-profile';

export default async function Page() {
  return (
    <ModalWrapper>
      {/* Server Component como children de Client Component */}
      <UserProfile userId="123" />
    </ModalWrapper>
  );
}

Server Actions

Server Actions permiten llamar funciones del servidor directamente desde el cliente.

// actions.ts
'use server';

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Validación
  if (!title || title.length < 3) {
    return { error: 'Título muy corto' };
  }

  // Crear en el banco
  const post = await db.posts.create({
    data: { title, content },
  });

  // Revalidar cache
  revalidatePath('/posts');

  // Redirigir
  redirect(`/posts/${post.id}`);
}

export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } });
  revalidatePath('/posts');
}

export async function likePost(id: string) {
  await db.posts.update({
    where: { id },
    data: { likes: { increment: 1 } },
  });
  revalidatePath(`/posts/${id}`);
}

Usando en Forms

// create-post-form.tsx (Client)
'use client';

import { useFormStatus } from 'react-dom';
import { createPost } from './actions';

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

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creando...' : 'Crear Post'}
    </button>
  );
}

export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Título" required />
      <textarea name="content" placeholder="Contenido" required />
      <SubmitButton />
    </form>
  );
}

Usando con useActionState

// form-with-state.tsx (Client)
'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

const initialState = { error: null };

export function FormWithState() {
  const [state, formAction, pending] = useActionState(
    createPost,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="title" placeholder="Título" />
      {state?.error && <p className="error">{state.error}</p>}
      <button disabled={pending}>
        {pending ? 'Enviando...' : 'Enviar'}
      </button>
    </form>
  );
}

Optimizaciones de Performance

1. Streaming con Suspense

// page.tsx (Server)
import { Suspense } from 'react';
import { SlowComponent } from './slow-component';
import { FastComponent } from './fast-component';

export default function Page() {
  return (
    <div>
      {/* Renderiza inmediatamente */}
      <FastComponent />

      {/* Streama cuando estiver listo */}
      <Suspense fallback={<LoadingSpinner />}>
        <SlowComponent />
      </Suspense>

      {/* Múltiplos Suspense para streaming paralelo */}
      <div className="grid">
        <Suspense fallback={<CardSkeleton />}>
          <RecommendedPosts />
        </Suspense>
        <Suspense fallback={<CardSkeleton />}>
          <PopularPosts />
        </Suspense>
      </div>
    </div>
  );
}

2. Parallel Data Fetching

// page.tsx (Server)
export default async function DashboardPage() {
  // MAL: Secuencial
  // const user = await fetchUser();
  // const posts = await fetchPosts();
  // const analytics = await fetchAnalytics();

  // BIEN: Paralelo
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics(),
  ]);

  return (
    <div>
      <UserHeader user={user} />
      <PostsGrid posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}

3. Preloading Data

// lib/data.ts
import { cache } from 'react';
import 'server-only';

// Cache automático para requests duplicados
export const getUser = cache(async (id: string) => {
  const user = await db.users.findUnique({ where: { id } });
  return user;
});

// Preload function
export const preloadUser = (id: string) => {
  void getUser(id);
};
// page.tsx
import { getUser, preloadUser } from '@/lib/data';

export default async function UserPage({ params }: { params: { id: string } }) {
  // Iniciar preload inmediatamente
  preloadUser(params.id);

  // Hacer otras cosas mientras carga
  const otherData = await fetchOtherData();

  // Usar los datos (ya pueden estar en cache)
  const user = await getUser(params.id);

  return <Profile user={user} />;
}

Patrones Comunes

Data Table con Sorting/Filtering

// data-table-page.tsx (Server)
import { DataTableClient } from './data-table-client';

interface Props {
  searchParams: {
    page?: string;
    sort?: string;
    filter?: string;
  };
}

export default async function DataTablePage({ searchParams }: Props) {
  const page = Number(searchParams.page) || 1;
  const sort = searchParams.sort || 'createdAt';
  const filter = searchParams.filter || '';

  const { data, total } = await db.items.findMany({
    where: filter ? { name: { contains: filter } } : {},
    orderBy: { [sort]: 'desc' },
    skip: (page - 1) * 10,
    take: 10,
  });

  return (
    <DataTableClient
      data={data}
      total={total}
      currentPage={page}
      currentSort={sort}
      currentFilter={filter}
    />
  );
}
// data-table-client.tsx (Client)
'use client';

import { useRouter, useSearchParams } from 'next/navigation';

interface Props {
  data: Item[];
  total: number;
  currentPage: number;
  currentSort: string;
  currentFilter: string;
}

export function DataTableClient({
  data,
  total,
  currentPage,
  currentSort,
  currentFilter,
}: Props) {
  const router = useRouter();
  const searchParams = useSearchParams();

  const updateParams = (updates: Record<string, string>) => {
    const params = new URLSearchParams(searchParams);
    Object.entries(updates).forEach(([key, value]) => {
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
    });
    router.push(`?${params.toString()}`);
  };

  return (
    <div>
      <input
        type="search"
        defaultValue={currentFilter}
        onChange={(e) => updateParams({ filter: e.target.value, page: '1' })}
        placeholder="Filtrar..."
      />

      <table>
        <thead>
          <tr>
            <th onClick={() => updateParams({ sort: 'name' })}>
              Nombre {currentSort === 'name' && ''}
            </th>
            <th onClick={() => updateParams({ sort: 'createdAt' })}>
              Fecha {currentSort === 'createdAt' && ''}
            </th>
          </tr>
        </thead>
        <tbody>
          {data.map((item) => (
            <tr key={item.id}>
              <td>{item.name}</td>
              <td>{item.createdAt.toLocaleDateString()}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <Pagination
        currentPage={currentPage}
        totalPages={Math.ceil(total / 10)}
        onPageChange={(page) => updateParams({ page: String(page) })}
      />
    </div>
  );
}

Errores Comunes

1. Importar Client Component sin 'use client'

// ERROR: useState en Server Component
import { useState } from 'react'; // Error!

export default function Page() {
  const [count, setCount] = useState(0); // Error!
  return <div>{count}</div>;
}

2. Pasar Funciones para Client Components

// ERROR: Funciones no son serializables
export default function Page() {
  const handleClick = () => console.log('clicked');

  return <ClientButton onClick={handleClick} />; // Error!
}

// CORRECTO: Usar Server Actions
import { serverAction } from './actions';

export default function Page() {
  return <ClientButton action={serverAction} />; // OK
}

3. Acceder a APIs del Browser en Server

// ERROR
export default function Page() {
  const width = window.innerWidth; // Error! window no existe en server
  return <div>{width}</div>;
}

// CORRECTO: Mover para Client Component
'use client';
export function WindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <div>{width}</div>;
}

Conclusión

React Server Components representan un cambio de paradigma en cómo construimos aplicaciones React. La clave es:

  1. Default para Server: Comienza con Server Components
  2. Client cuando necesario: Agrega 'use client' solo para interactividad
  3. Composición inteligente: Combina Server y Client estratégicamente
  4. Server Actions para mutaciones: Simplifica formularios y acciones

Si quieres profundizar en las herramientas modernas de React, recomiendo que veas otro artículo: Svelte vs Vue vs React en 2025 donde vas a descubrir cómo React se compara con otros frameworks.

¡Vamos a por ello! 🦅

Comentarios (0)

Este artículo aún no tiene comentarios 😢. ¡Sé el primero! 🚀🦅

Añadir comentarios