Volver al blog

TanStack en 2026: El Ecosistema Unificado Que Está Dominando React

Hola HaWkers, si trabajas con React en 2026, probablemente ya escuchaste hablar de TanStack. Lo que comenzó como React Query se transformó en un ecosistema completo que está cambiando la forma como construimos aplicaciones.

Vamos a explorar cada parte de este ecosistema y cómo usarlo en la práctica.

El Ecosistema TanStack

Visión General en 2026

// Lo que TanStack ofrece hoy

const tanstackEcosystem = {
  query: {
    purpose: 'Server state management',
    status: 'Maduro y estable',
    adoption: 'Estándar del mercado'
  },

  router: {
    purpose: 'Type-safe routing',
    status: 'Alternativa seria a React Router',
    feature: 'File-based + type safety'
  },

  table: {
    purpose: 'Tablas y grids complejos',
    status: 'Líder en la categoría',
    feature: 'Headless, extensible'
  },

  form: {
    purpose: 'Gestión de formularios',
    status: 'Creciendo rápidamente',
    feature: 'Type-safe, performante'
  },

  store: {
    purpose: 'Client state management',
    status: 'Alternativa liviana a Redux/Zustand',
    feature: 'Signals-based'
  },

  db: {
    purpose: 'Local-first database',
    status: 'Nuevo en 2025-2026',
    feature: 'Sync, offline, real-time'
  },

  start: {
    purpose: 'Full-stack framework',
    status: 'Beta avanzado',
    feature: 'SSR, file routing, API routes'
  },

  ai: {
    purpose: 'AI/LLM integrations',
    status: 'Experimental',
    feature: 'Streaming, hooks para AI'
  }
};

TanStack Query

El Corazón del Ecosistema

// TanStack Query - Server State Management

import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

// Setup
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutos
      gcTime: 1000 * 60 * 30, // 30 minutos (antes era cacheTime)
      retry: 3,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  );
}

// Uso básico
interface User {
  id: number;
  name: string;
  email: string;
}

function useUser(userId: number) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async (): Promise<User> => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      return res.json();
    },
  });
}

function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useUser(userId);

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Mutations y Optimistic Updates

// Mutations con optimistic updates

interface UpdateUserData {
  name?: string;
  email?: string;
}

function useUpdateUser(userId: number) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: UpdateUserData) => {
      const res = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      if (!res.ok) throw new Error('Failed to update');
      return res.json();
    },

    // Optimistic update
    onMutate: async (newData) => {
      // Cancela queries en curso
      await queryClient.cancelQueries({ queryKey: ['user', userId] });

      // Snapshot del valor anterior
      const previousUser = queryClient.getQueryData<User>(['user', userId]);

      // Actualiza optimísticamente
      queryClient.setQueryData<User>(['user', userId], (old) => ({
        ...old!,
        ...newData,
      }));

      // Retorna contexto para rollback
      return { previousUser };
    },

    // Si hay error, rollback
    onError: (err, newData, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(['user', userId], context.previousUser);
      }
    },

    // Siempre refetch después de mutation
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
}

// Uso
function EditUserForm({ userId }: { userId: number }) {
  const { data: user } = useUser(userId);
  const updateUser = useUpdateUser(userId);
  const [name, setName] = useState(user?.name ?? '');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    updateUser.mutate({ name });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button disabled={updateUser.isPending}>
        {updateUser.isPending ? 'Guardando...' : 'Guardar'}
      </button>
    </form>
  );
}

TanStack Router

Type-Safe Routing

// TanStack Router - Routing con type safety total

import {
  createRootRoute,
  createRoute,
  createRouter,
  RouterProvider,
  Link,
  Outlet,
} from '@tanstack/react-router';

// Define la root route
const rootRoute = createRootRoute({
  component: () => (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/users">Users</Link>
        <Link to="/users/$userId" params={{ userId: '1' }}>
          User 1
        </Link>
      </nav>
      <Outlet />
    </div>
  ),
});

// Home route
const homeRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: () => <h1>Home</h1>,
});

// Users list route
const usersRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/users',
  component: UsersPage,
  // Loader para prefetch de datos
  loader: async () => {
    const users = await fetchUsers();
    return { users };
  },
});

// User detail route con parámetro tipado
const userRoute = createRoute({
  getParentRoute: () => usersRoute,
  path: '$userId',
  component: UserDetail,
  // Validación de parámetros
  parseParams: (params) => ({
    userId: parseInt(params.userId),
  }),
  stringifyParams: (params) => ({
    userId: String(params.userId),
  }),
  // Loader con acceso a params tipados
  loader: async ({ params }) => {
    const user = await fetchUser(params.userId);
    return { user };
  },
});

// Route tree
const routeTree = rootRoute.addChildren([
  homeRoute,
  usersRoute.addChildren([userRoute]),
]);

// Router instance
const router = createRouter({ routeTree });

// Type registration para type safety global
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

// App
function App() {
  return <RouterProvider router={router} />;
}

// Componentes usando datos del loader
function UsersPage() {
  const { users } = usersRoute.useLoaderData();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <Link to="/users/$userId" params={{ userId: user.id }}>
            {user.name}
          </Link>
        </li>
      ))}
    </ul>
  );
}

function UserDetail() {
  const { user } = userRoute.useLoaderData();
  const { userId } = userRoute.useParams();

  return (
    <div>
      <h2>{user.name}</h2>
      <p>ID: {userId}</p>
    </div>
  );
}

TanStack Form

Formularios Type-Safe

// TanStack Form - Gestión de formularios

import { useForm } from '@tanstack/react-form';
import { z } from 'zod';

// Schema de validación
const userSchema = z.object({
  name: z.string().min(2, 'Nombre debe tener al menos 2 caracteres'),
  email: z.string().email('Email inválido'),
  age: z.number().min(18, 'Debe ser mayor de 18'),
  role: z.enum(['admin', 'user', 'guest']),
});

type UserFormData = z.infer<typeof userSchema>;

function UserForm() {
  const form = useForm<UserFormData>({
    defaultValues: {
      name: '',
      email: '',
      age: 18,
      role: 'user',
    },
    onSubmit: async ({ value }) => {
      // value está totalmente tipado
      await saveUser(value);
    },
    validators: {
      onChange: userSchema,
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
    >
      <form.Field
        name="name"
        children={(field) => (
          <div>
            <label>Nombre</label>
            <input
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.length > 0 && (
              <span className="error">
                {field.state.meta.errors.join(', ')}
              </span>
            )}
          </div>
        )}
      />

      <form.Field
        name="email"
        children={(field) => (
          <div>
            <label>Email</label>
            <input
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.length > 0 && (
              <span className="error">
                {field.state.meta.errors.join(', ')}
              </span>
            )}
          </div>
        )}
      />

      <form.Field
        name="age"
        children={(field) => (
          <div>
            <label>Edad</label>
            <input
              type="number"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(parseInt(e.target.value))}
            />
          </div>
        )}
      />

      <form.Field
        name="role"
        children={(field) => (
          <div>
            <label>Rol</label>
            <select
              value={field.state.value}
              onChange={(e) =>
                field.handleChange(e.target.value as UserFormData['role'])
              }
            >
              <option value="user">User</option>
              <option value="admin">Admin</option>
              <option value="guest">Guest</option>
            </select>
          </div>
        )}
      />

      <form.Subscribe
        selector={(state) => [state.canSubmit, state.isSubmitting]}
        children={([canSubmit, isSubmitting]) => (
          <button type="submit" disabled={!canSubmit || isSubmitting}>
            {isSubmitting ? 'Guardando...' : 'Guardar'}
          </button>
        )}
      />
    </form>
  );
}

TanStack Table

Tablas Complejas

// TanStack Table - Tablas headless

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  createColumnHelper,
  SortingState,
} from '@tanstack/react-table';

interface User {
  id: number;
  name: string;
  email: string;
  role: string;
  createdAt: Date;
}

const columnHelper = createColumnHelper<User>();

const columns = [
  columnHelper.accessor('id', {
    header: 'ID',
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor('name', {
    header: 'Nombre',
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor('email', {
    header: 'Email',
    cell: (info) => (
      <a href={`mailto:${info.getValue()}`}>{info.getValue()}</a>
    ),
  }),
  columnHelper.accessor('role', {
    header: 'Rol',
    cell: (info) => <Badge>{info.getValue()}</Badge>,
  }),
  columnHelper.accessor('createdAt', {
    header: 'Creado en',
    cell: (info) => info.getValue().toLocaleDateString(),
    sortingFn: 'datetime',
  }),
  columnHelper.display({
    id: 'actions',
    header: 'Acciones',
    cell: (info) => (
      <div>
        <button onClick={() => editUser(info.row.original)}>Editar</button>
        <button onClick={() => deleteUser(info.row.original.id)}>
          Eliminar
        </button>
      </div>
    ),
  }),
];

function UsersTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState('');

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      globalFilter,
    },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <div>
      {/* Filtro global */}
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Buscar..."
      />

      {/* Tabla */}
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  style={{ cursor: 'pointer' }}
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext()
                  )}
                  {header.column.getIsSorted() === 'asc' && ' 🔼'}
                  {header.column.getIsSorted() === 'desc' && ' 🔽'}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {/* Paginación */}
      <div>
        <button
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Anterior
        </button>
        <span>
          Página {table.getState().pagination.pageIndex + 1} de{' '}
          {table.getPageCount()}
        </span>
        <button
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Siguiente
        </button>
      </div>
    </div>
  );
}

TanStack Store

State Management Ligero

// TanStack Store - Client state con signals

import { Store, useStore } from '@tanstack/store';

// Crear store
interface AppState {
  user: {
    id: number;
    name: string;
  } | null;
  theme: 'light' | 'dark';
  notifications: string[];
}

const store = new Store<AppState>({
  user: null,
  theme: 'light',
  notifications: [],
});

// Actions como funciones
function login(user: AppState['user']) {
  store.setState((state) => ({
    ...state,
    user,
  }));
}

function logout() {
  store.setState((state) => ({
    ...state,
    user: null,
  }));
}

function toggleTheme() {
  store.setState((state) => ({
    ...state,
    theme: state.theme === 'light' ? 'dark' : 'light',
  }));
}

function addNotification(message: string) {
  store.setState((state) => ({
    ...state,
    notifications: [...state.notifications, message],
  }));
}

// Uso en componentes
function UserMenu() {
  // Selecciona solo lo que necesita
  const user = useStore(store, (state) => state.user);

  if (!user) {
    return <button onClick={() => login({ id: 1, name: 'John' })}>Login</button>;
  }

  return (
    <div>
      <span>Hola, {user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

function ThemeToggle() {
  const theme = useStore(store, (state) => state.theme);

  return (
    <button onClick={toggleTheme}>
      Tema: {theme === 'light' ? '☀️' : '🌙'}
    </button>
  );
}

function NotificationBell() {
  const count = useStore(store, (state) => state.notifications.length);

  return (
    <button>
      🔔 {count > 0 && <span className="badge">{count}</span>}
    </button>
  );
}

Integrando el Ecosistema

Ejemplo Completo

// App completa usando múltiples TanStack

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { Store } from '@tanstack/store';

// Setup global
const queryClient = new QueryClient();
const appStore = new Store({ theme: 'light' as const });

// Router con integración Query
const rootRoute = createRootRoute({
  component: () => {
    const theme = useStore(appStore, (s) => s.theme);

    return (
      <div className={theme}>
        <Header />
        <Outlet />
      </div>
    );
  },
});

const usersRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/users',
  // Prefetch con Query
  loader: async ({ context }) => {
    await context.queryClient.ensureQueryData({
      queryKey: ['users'],
      queryFn: fetchUsers,
    });
  },
  component: () => {
    const { data: users } = useQuery({
      queryKey: ['users'],
      queryFn: fetchUsers,
    });

    return <UsersTable data={users ?? []} />;
  },
});

// Router con contexto
const router = createRouter({
  routeTree: rootRoute.addChildren([usersRoute]),
  context: {
    queryClient,
  },
});

// App
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

Conclusión

TanStack en 2026 ya no es "solo React Query" - es un ecosistema completo y cohesivo para construir aplicaciones React modernas. La ventaja principal es la consistencia: APIs similares, type safety en todo, e integración perfecta entre las partes.

Cuándo usar cada parte:

  • Query: Siempre que tengas datos del servidor
  • Router: Si quieres type safety en el routing (y quieres salir de React Router)
  • Table: Tablas complejas con sort/filter/pagination
  • Form: Formularios medios a complejos
  • Store: Estado global simple (alternativa a Zustand)

El ecosistema continúa creciendo, y TanStack Start promete ser una alternativa interesante a Next.js para quien quiere máximo control con DX excelente.

Para entender más sobre el estado actual de React, consulta: React Compiler en 2026.

¡Vamos con todo! 🦅

Comentarios (0)

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

Añadir comentarios