learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Proyecto: Team Builder en React

El gran proyecto del curso: reconstruye el Team Builder como una app React + TypeScript que junta routing, data-fetching, estado global, formularios y patrones de componentes.

Has llegado al final del nivel. No hay concepto nuevo: este capítulo es la prueba de que puedes coger todo lo que has aprendido —componentes, props, estado, efectos, hooks, context, routing, data-fetching, formularios y patrones— y montar con ello una app completa, de principio a fin.

Qué construyes#

El Team Builder, pero esta vez como una aplicación React real, no un ejercicio aislado. Una SPA con dos pantallas:

  • Roster (/): carga los héroes del servidor, los filtra por rol y los añade a tu equipo.
  • Mi equipo (/equipo): muestra el equipo seleccionado y un formulario para añadir un héroe propio.

Y en el tier excelente, una tercera: la ficha de un héroe (/heroe/:id).

Lo construyes desde cero con Vite (lo viste en el Nivel 4), porque ya has hecho cada pieza por separado a lo largo del nivel. Aquí solo las juntas.

La arquitectura#

Separa responsabilidades en ficheros con un papel claro, en vez de amontonar todo en App.tsx:

  • dominio.ts — el contrato de datos: el schema Zod del héroe y el tipo derivado con z.infer, más el cálculo del winrate.
  • api.ts — la “red”: una función que devuelve los héroes en una promesa (como haría un fetch).
  • store.ts (o EquipoContext.tsx en el tier ok) — el estado del equipo, compartido entre rutas.
  • Páginas (RosterPage, EquipoPage, HeroePage) — una por ruta.
  • Componentes (Tabs) — piezas de UI reutilizables.
  • useHeroes.ts (tier excelente) — la lógica de datos extraída a un custom hook.
  • App.tsx — el layout y las rutas. main.tsx — el punto de entrada y los providers.

El plan de ataque#

No intentes escribirlo todo de golpe. Ve capa por capa, y arranca con la versión más simple (tier ok) antes de subir.

1. El dominio y los datos#

Empieza por lo que no depende de React. El schema Zod es la fuente de verdad del tipo:

typescript
// dominio.ts — el tipo se deriva del schema (capítulo de formularios y Nivel 5).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);
export const HeroeSchema = z.object({
  id: z.number(),
  nombre: z.string(),
  rol: RolSchema,
  partidas: z.number(),
  victorias: z.number(),
});
export type Heroe = z.infer<typeof HeroeSchema>;

Y api.ts simula la red con una promesa, como en el capítulo de data-fetching.

2. Las rutas y los providers#

App define el layout y las rutas; main monta los providers. El orden de los providers importa:

tsx
// main.tsx (tier mejor): Query para los datos, Router para las rutas.
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </QueryClientProvider>
  </StrictMode>,
);

3. Los datos: de useEffect a useQuery#

En el tier ok, cargas con useEffect + useState (capítulo de hooks fundamentales). En el tier mejor, lo cambias por useQuery (capítulo de data-fetching), que te da caché y estados de carga/error sin escribirlos a mano:

tsx
// tier mejor: una línea sustituye al useEffect + dos useState.
const { data: heroes = [], isLoading, isError } = useQuery({
  queryKey: ["heroes"],
  queryFn: cargarHeroes,
});

4. El estado del equipo: de context a Zustand#

El equipo lo modifica el Roster y lo lee la página de Equipo: es estado compartido entre rutas. En el tier ok vive en un context (capítulo de custom hooks); en el tier mejor sube a un store de Zustand (capítulo de gestión de estado), que no necesita Provider:

typescript
// store.ts (tier mejor): create define el estado y las acciones.
export const useEquipo = create<EquipoStore>((set) => ({
  equipo: [],
  anadir: (heroe) => set((estado) => ({ equipo: [...estado.equipo, heroe] })),
  quitar: (id) => set((estado) => ({ equipo: estado.equipo.filter((h) => h.id !== id) })),
}));

5. El filtro: de botones a un componente compuesto#

En el tier ok, el filtro por rol son botones con un useState. En el tier mejor, lo conviertes en un componente compuesto <Tabs> (capítulo anterior): el contenedor guarda la pestaña activa por context y las piezas la leen.

6. El formulario#

El formulario de añadir héroe es react-hook-form + Zod (capítulo de formularios), con valueAsNumber en los campos numéricos. Su schema solo tiene los campos que el usuario rellena; el id lo generas tú al añadir:

tsx
// El id no lo teclea el usuario: lo añade la app al componer el héroe.
function onSubmit(datos: DatosForm) {
  const heroe: Heroe = { id: proximoId++, ...datos };
  anadir(heroe);
}

7. El pulido (tier excelente)#

Cuando funcione, sube: extrae la consulta a un custom hook useHeroes(), añade la ruta de detalle /heroe/:id con useParams, haz el formulario accesible con aria-invalid/aria-describedby, muestra un dato derivado (el winrate medio del equipo) y comprueba que todo aguanta a 375px.

Comprueba lo que sabes#

Pregunta 1 de 4

El equipo seleccionado lo modifica la página del Roster y lo lee la de Equipo, en rutas distintas. ¿Por qué en el tier 'mejor' se sube ese estado a un store de Zustand en vez de dejarlo en un context?

Tu turno#

Este proyecto se monta en local, con un proyecto Vite real. Sigue el README de la carpeta del ejercicio: crea el proyecto, instala las dependencias del nivel y construye la app capa por capa, empezando por el tier ok. Cuando un tier funcione, compáralo con su solución de referencia antes de subir al siguiente.

Ejercicio · hazlo en local

Team Builder en React + TypeScript

Reconstruye el Team Builder como una app React real. Monta un proyecto Vite con React y TypeScript, instala las dependencias del nivel (react-router, zustand, @tanstack/react-query, react-hook-form, @hookform/resolvers, zod) y construye la app siguiendo el plan de ataque. Dos rutas: el Roster (cargar héroes, filtrar por rol, añadir al equipo) y Mi equipo (ver el equipo y añadir un héroe propio con un formulario validado). Sube de tier según las herramientas que uses y el pulido que apliques.

Paso 1: La app funciona, con las herramientas más simples

  • Dos rutas con react-router (Roster y Equipo) y navegación con NavLink que marca el enlace activo.
  • El roster se carga del 'servidor' con useEffect + useState y muestra un estado de carga.
  • El estado del equipo (compartido entre rutas) vive en un context, con un hook guardián que falla si se usa fuera del provider.
  • El filtro por rol funciona (botones) y añadir respeta las reglas: máximo 6, sin duplicados.
  • El formulario de añadir héroe usa react-hook-form + Zod con valueAsNumber en los campos numéricos.

Cómo hacerlo en local

Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el index.html en tu navegador. Toda tu solución va en solucion.js.

git clone <repo>
cd exercises/nivel-6/proyecto-team-builder-react
# abre index.html en el navegador y edita solucion.js
Ver soluciones
// ── dominio.ts ──────────────────────────────────────────────

import { z } from "zod";

// El esquema Zod es la fuente de verdad del dato Heroe: valida en runtime Y deriva el tipo.
// z.enum cierra el conjunto de roles válidos (en proyecto real, no en el playground).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);

// HeroeSchema describe la forma completa de un héroe.
export const HeroeSchema = z.object({
  id: z.number(),
  nombre: z.string(),
  rol: RolSchema,
  partidas: z.number(),
  victorias: z.number(),
});

// El tipo Heroe se deriva del esquema: una sola fuente de verdad (si cambia el schema, cambia el tipo).
export type Heroe = z.infer<typeof HeroeSchema>;

// El tipo del rol, reutilizable en filtros y formularios.
export type Rol = z.infer<typeof RolSchema>;

// El winrate es un dato derivado: se calcula donde se necesita, no se guarda.
export function winrate(heroe: Heroe): number {
  // Evitamos dividir por cero si un héroe no tiene partidas.
  if (heroe.partidas === 0) {
    return 0;
  }
  return Math.round((heroe.victorias / heroe.partidas) * 100);
}


// ── api.ts ──────────────────────────────────────────────────

import type { Heroe } from "./dominio";

// El roster que "vive en el servidor". En una app real esto sería una base de datos.
const ROSTER: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { id: 2, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
  { id: 3, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
  { id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
  { id: 5, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
  { id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];

// Simula una llamada de red: devuelve una promesa que resuelve tras 600ms,
// como haría un fetch real. El componente la trata como una petición asíncrona.
export function cargarHeroes(): Promise<Heroe[]> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(ROSTER), 600);
  });
}


// ── EquipoContext.tsx ───────────────────────────────────────

import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
import type { Heroe } from "./dominio";

// El equipo seleccionado es estado COMPARTIDO entre páginas (Roster lo modifica, Equipo lo lee).
// En el tier OK lo guardamos en un context: un canal directo por el árbol, sin prop drilling.
interface EquipoContexto {
  equipo: Heroe[];
  anadir: (heroe: Heroe) => void;
  quitar: (id: number) => void;
  estaEnEquipo: (id: number) => boolean;
}

// Valor por defecto null: detectamos el uso fuera del provider.
const EquipoContext = createContext<EquipoContexto | null>(null);

// Hook guardián: lee el context y falla con un error claro si no hay provider encima.
export function useEquipo(): EquipoContexto {
  const ctx = useContext(EquipoContext);
  if (ctx === null) {
    throw new Error("useEquipo debe usarse dentro de <EquipoProvider>");
  }
  return ctx;
}

// El provider guarda el estado del equipo y expone las operaciones para modificarlo.
export function EquipoProvider({ children }: { children: ReactNode }) {
  const [equipo, setEquipo] = useState<Heroe[]>([]);

  // anadir respeta las reglas de negocio: máximo 6 héroes y sin duplicados.
  function anadir(heroe: Heroe) {
    setEquipo((prev) => {
      // Si ya hay 6, no añadimos: devolvemos el mismo array sin cambios.
      if (prev.length >= 6) {
        return prev;
      }
      // Si el héroe ya está, tampoco: evita duplicados.
      if (prev.some((h) => h.id === heroe.id)) {
        return prev;
      }
      // Añadimos de forma inmutable: un array nuevo, no mutamos el anterior.
      return [...prev, heroe];
    });
  }

  // quitar elimina el héroe por id, también de forma inmutable (filter crea un array nuevo).
  function quitar(id: number) {
    setEquipo((prev) => prev.filter((h) => h.id !== id));
  }

  // estaEnEquipo es un dato derivado: lo calculamos al vuelo, no lo guardamos.
  function estaEnEquipo(id: number): boolean {
    return equipo.some((h) => h.id === id);
  }

  return (
    <EquipoContext.Provider value={{ equipo, anadir, quitar, estaEnEquipo }}>
      {children}
    </EquipoContext.Provider>
  );
}


// ── RosterPage.tsx ──────────────────────────────────────────

import { useEffect, useState } from "react";
import type { Heroe, Rol } from "./dominio";
import { winrate } from "./dominio";
import { cargarHeroes } from "./api";
import { useEquipo } from "./EquipoContext";

// Los filtros disponibles: "Todos" más los tres roles.
const FILTROS: (Rol | "Todos")[] = ["Todos", "Daño", "Apoyo", "Tanque"];

export function RosterPage() {
  // Estado de la carga: los héroes que llegan del servidor y si seguimos cargando.
  const [heroes, setHeroes] = useState<Heroe[]>([]);
  const [cargando, setCargando] = useState(true);
  // Estado de UI local: el rol por el que filtramos.
  const [filtro, setFiltro] = useState<Rol | "Todos">("Todos");
  // Operaciones del equipo, leídas del context compartido.
  const { anadir, estaEnEquipo } = useEquipo();

  // useEffect carga los héroes al montar la página (array de deps vacío: solo una vez).
  useEffect(() => {
    cargarHeroes().then((datos) => {
      // Cuando la promesa resuelve, guardamos los datos y apagamos el "cargando".
      setHeroes(datos);
      setCargando(false);
    });
  }, []);

  // Mientras la promesa no resuelve, mostramos un aviso de carga.
  if (cargando) {
    return <p className="cargando">Cargando roster...</p>;
  }

  // El filtrado es un dato derivado: se calcula en el render a partir de heroes y filtro.
  const visibles = filtro === "Todos" ? heroes : heroes.filter((h) => h.rol === filtro);

  return (
    <section>
      <div className="filtros">
        {FILTROS.map((rol) => (
          <button
            key={rol}
            // El botón activo lleva la clase principal; el resto, la secundaria.
            className={filtro === rol ? "boton" : "boton boton--secundario"}
            onClick={() => setFiltro(rol)}
          >
            {rol}
          </button>
        ))}
      </div>
      <div className="lista">
        {visibles.map((h) => {
          // estaEnEquipo decide si el héroe ya está añadido (para bloquear el botón).
          const yaEsta = estaEnEquipo(h.id);
          return (
            <article key={h.id} className="tarjeta">
              <h2 className="tarjeta__nombre">{h.nombre}</h2>
              <p className="tarjeta__rol">{h.rol}</p>
              {/* winrate es un dato derivado, calculado en dominio.ts. */}
              <p className="tarjeta__stats">{winrate(h)}% winrate</p>
              <button
                className="boton"
                // Si ya está en el equipo, el botón se desactiva.
                disabled={yaEsta}
                onClick={() => anadir(h)}
              >
                {yaEsta ? "En el equipo" : "Añadir"}
              </button>
            </article>
          );
        })}
      </div>
    </section>
  );
}


// ── EquipoPage.tsx ──────────────────────────────────────────

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { RolSchema, winrate } from "./dominio";
import type { Heroe } from "./dominio";
import { useEquipo } from "./EquipoContext";

// El formulario pide solo lo que el usuario rellena; el id lo generamos nosotros al añadir.
const FormSchema = z.object({
  nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
  rol: RolSchema,
  partidas: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(1, "Al menos 1 partida"),
  victorias: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(0, "No puede ser negativo"),
});

// El tipo del formulario se deriva del schema: una sola fuente de verdad.
type DatosForm = z.infer<typeof FormSchema>;

// Contador para ids de héroes creados por el usuario (no chocan con los del servidor).
let proximoId = 100;

export function EquipoPage() {
  // Estado del equipo, leído del context compartido.
  const { equipo, quitar, anadir } = useEquipo();

  // useForm tipado con DatosForm y validado con el schema vía zodResolver.
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<DatosForm>({
    resolver: zodResolver(FormSchema),
  });

  // onSubmit solo se llama si Zod acepta los datos: aquí ya son seguros.
  function onSubmit(datos: DatosForm) {
    // Componemos el héroe con un id generado y lo añadimos al equipo.
    const heroe: Heroe = { id: proximoId++, ...datos };
    anadir(heroe);
  }

  return (
    <section>
      <h2 className="seccion__titulo">Mi equipo ({equipo.length} / 6)</h2>
      {equipo.length === 0 ? (
        <p className="vacio">El equipo está vacío. Añade héroes desde el roster.</p>
      ) : (
        <div className="lista">
          {equipo.map((h) => (
            <article key={h.id} className="tarjeta">
              <h3 className="tarjeta__nombre">{h.nombre}</h3>
              <p className="tarjeta__rol">{h.rol}</p>
              <p className="tarjeta__stats">{winrate(h)}% winrate</p>
              <button className="boton boton--secundario" onClick={() => quitar(h.id)}>
                Quitar
              </button>
            </article>
          ))}
        </div>
      )}

      <h2 className="seccion__titulo">Añadir un héroe propio</h2>
      {/* handleSubmit hace preventDefault, valida con Zod y llama a onSubmit solo si pasa. */}
      <form className="formulario" onSubmit={handleSubmit(onSubmit)}>
        <div className="campo">
          <label htmlFor="nombre">Nombre</label>
          <input id="nombre" type="text" {...register("nombre")} placeholder="Nombre del héroe" />
          {errors.nombre && <span className="error-msg">{errors.nombre.message}</span>}
        </div>
        <div className="campo">
          <label htmlFor="rol">Rol</label>
          <select id="rol" {...register("rol")}>
            <option value="">Elige un rol</option>
            <option value="Daño">Daño</option>
            <option value="Apoyo">Apoyo</option>
            <option value="Tanque">Tanque</option>
          </select>
          {errors.rol && <span className="error-msg">{errors.rol.message}</span>}
        </div>
        <div className="campo">
          <label htmlFor="partidas">Partidas</label>
          {/* valueAsNumber convierte el string del input a number antes de validar. */}
          <input id="partidas" type="number" min={1} {...register("partidas", { valueAsNumber: true })} placeholder="0" />
          {errors.partidas && <span className="error-msg">{errors.partidas.message}</span>}
        </div>
        <div className="campo">
          <label htmlFor="victorias">Victorias</label>
          <input id="victorias" type="number" min={0} {...register("victorias", { valueAsNumber: true })} placeholder="0" />
          {errors.victorias && <span className="error-msg">{errors.victorias.message}</span>}
        </div>
        <button type="submit" className="boton">
          Añadir al equipo
        </button>
      </form>
    </section>
  );
}


// ── App.tsx ─────────────────────────────────────────────────

import { Routes, Route, NavLink } from "react-router";
import { RosterPage } from "./RosterPage";
import { EquipoPage } from "./EquipoPage";

// App define el layout común (cabecera + navegación) y las rutas de la SPA.
export function App() {
  return (
    <div className="app">
      <header className="cabecera">
        <h1 className="titulo">Team Builder</h1>
        <nav className="nav">
          {/* NavLink marca el enlace activo según la ruta actual (con aria-current). */}
          {/* end evita que "/" salga activo también en "/equipo". */}
          <NavLink to="/" end className="nav__link">
            Roster
          </NavLink>
          <NavLink to="/equipo" className="nav__link">
            Mi equipo
          </NavLink>
        </nav>
      </header>
      <main>
        {/* Routes pinta el primer Route cuyo path coincide con la URL. */}
        <Routes>
          <Route path="/" element={<RosterPage />} />
          <Route path="/equipo" element={<EquipoPage />} />
        </Routes>
      </main>
    </div>
  );
}


// ── main.tsx ────────────────────────────────────────────────

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import { EquipoProvider } from "./EquipoContext";
import "./styles.css";

// El punto de entrada: monta la app en el #root del index.html (lo crea Vite).
// El orden de los providers importa: BrowserRouter envuelve todo (las rutas viven dentro),
// y EquipoProvider envuelve App para que ambas páginas compartan el estado del equipo.
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <BrowserRouter>
      <EquipoProvider>
        <App />
      </EquipoProvider>
    </BrowserRouter>
  </StrictMode>,
);

Por qué este nivel

  • El estado del equipo vive en un context (EquipoContext) con un hook guardián useEquipo() que lanza un error claro si se usa fuera del provider. Es la herramienta del capítulo de custom hooks: suficiente para compartir estado entre dos rutas.
  • Los datos se cargan con useEffect + useState: la promesa de cargarHeroes resuelve, guardamos los héroes y apagamos el 'cargando'. Funciona, pero no hay caché: cada vez que se monta la página, vuelve a pedir.
  • El formulario ya es react-hook-form + Zod con valueAsNumber: el patrón del capítulo de formularios, reutilizado tal cual.