Hace unos días construiste una Pokédex en React aplicando componentes, props, listas y conditional rendering. Hoy vas un paso más allá: vas a montar una mini-app para explorar los 73 capítulos de Juego de Tronos introduciendo estado (useState) y elevándolo a un componente padre (lifting state up).
El objetivo es practicar:
- Componentes y composición (descomponer UI en piezas).
- Props (read-only) y lifting state up.
useStatepara estado local en el componente raíz.- Listas con
.map()ykey. - Formularios controlados (
<input>convalue+onChange). - Conditional rendering (ternario,
&&).
La aplicación tendrá:
- Un listado con todos los capítulos.
- Un buscador por nombre del capítulo.
- Un filtro por temporada (1–8 o "Todas").
- Una sección de capítulos favoritos.
- La posibilidad de marcar capítulos como vistos.
⚠️ El proyecto que recibes es solo el esqueleto: todos los componentes están vacíos conTODOs. Tú implementas el JSX, el estado y la lógica.
- Haz fork de este repo a tu cuenta de GitHub.
- Clona el fork a tu máquina local.
- Abre el proyecto en VS Code.
- Instala dependencias y arranca el dev server:
npm install
npm run devVerás la página en http://localhost:5173 con un mensaje placeholder. Tu trabajo es construir el resto.
Al terminar:
git add .
git commit -m "done"
git push origin mainCrea un Pull Request de tu fork hacia el repositorio original.
lab-react-got-episodes/
├── index.html # carga Bootstrap 5.3 por CDN
├── package.json
├── vite.config.js
└── src/
├── main.jsx # punto de entrada (no tocar)
├── App.jsx # componente raíz — aquí vive el estado
├── data.js # array con los 73 capítulos (no modificar)
├── index.css # estilos personalizados (puedes añadir aquí)
└── components/
├── Navbar.jsx # skeleton — implementar
├── Filters.jsx # skeleton — compone SearchBar + SeasonFilter
├── SearchBar.jsx # skeleton — input controlado
├── SeasonFilter.jsx # skeleton — botones de temporada
├── EpisodeCard.jsx # skeleton — card de un capítulo
├── EpisodeList.jsx # skeleton — grid de cards
└── FavoriteEpisodes.jsx # skeleton — sección de favoritos
Bootstrap 5.3 ya está cargado por CDN en
index.html. Tu trabajo es maquetar el contenido usando sus clases (container,row,col,card,btn,form-control,navbar, …). No necesitas instalar nada extra.
Cada capítulo en src/data.js tiene esta forma:
{
id: 4952,
url: "http://www.tvmaze.com/episodes/4952/game-of-thrones-1x01-winter-is-coming",
name: "Winter is Coming",
season: 1,
number: 1,
airdate: "2011-04-17",
airtime: "21:00",
airstamp: "2011-04-18T01:00:00+00:00",
runtime: 60,
image: {
medium: "http://static.tvmaze.com/uploads/images/medium_landscape/1/2668.jpg",
original: "http://static.tvmaze.com/uploads/images/original_untouched/1/2668.jpg"
},
summary: "<p>Lord Eddard Stark, ruler of the North, is summoned to court ...</p>",
_links: { /* ... */ }
}- Total: 73 capítulos, 8 temporadas.
summaryviene como HTML (envuelto en<p>...</p>).image.mediumes una URL utilizable directamente en un<img src>.
Antes de pintar los 73, empieza por uno solo para verificar que los datos llegan bien.
En src/App.jsx:
- Descomenta
import episodes from "./data";. - Borra el placeholder y dentro del
<main>renderiza estáticamente el primer capítulo: su imagen (image.medium), su título (name) y su temporada (season).
💡 Recuerda: dentro de JSX las expresiones JS van entre llaves
{ }. Yimage.mediumes una propiedad anidada (episodes[0].image.medium).
Renderizar un capítulo directamente en App no escala. Vamos a extraerlo a su propio componente.
Abre src/components/EpisodeCard.jsx y haz que:
- Reciba un capítulo por props:
episode. - Devuelva una card de Bootstrap con la imagen, el título, la temporada, el número de episodio (
number), la fecha (airdate) y el resumen (summary).
Después, en App.jsx, importa EpisodeCard y úsalo para renderizar episodes[0]:
<EpisodeCard episode={episodes[0]} />💡 Pistas:
- Las clases de Bootstrap para una card simple:
card,card-img-top,card-body,card-title,card-text.- El
summaryviene como HTML. Para renderizarlo como tal, usadangerouslySetInnerHTML={{ __html: episode.summary }}(y entiende por qué se llama "dangerously"). Como alternativa segura, puedes mostrar solo el texto sin etiquetas con un.replace(/<[^>]+>/g, '').- Las props son read-only: el componente no modifica
episode, solo lo pinta.
Ahora que la card funciona, vamos a pintar los 73 capítulos. Pero no en App: vamos a crear un contenedor.
-
Abre
src/components/EpisodeList.jsx. Debe:- Recibir
episodespor props. - Hacer
.map()sobreepisodesy renderizar un<EpisodeCard />por cada uno. - Envolver el mapeo en un
<section className="row g-3">y meter cada card en un<div className="col-12 col-md-6 col-lg-4">(o las columnas que prefieras) para que se vea como rejilla.
- Recibir
-
En
App.jsx, importaEpisodeListy úsalo:
<EpisodeList episodes={episodes} />💡 Recuerda la prop especial
keyen cada hijo del.map(). Debe ser única entre hermanos.episode.ides perfecto. Si abres la consola y olvidas lakey, React te avisará.
Vamos a introducir el primer estado: el texto del buscador. Como queremos que el filtro afecte al listado, el estado vive en App.jsx (su padre común) y baja por props (lifting state up).
-
En
src/App.jsx:- Importa
useStatedesde React. - Crea un estado:
const [search, setSearch] = useState(""); - Filtra los episodios antes de pasarlos a
<EpisodeList />:const visibleEpisodes = episodes.filter((ep) => ep.name.toLowerCase().includes(search.toLowerCase()) );
- Pasa
visibleEpisodesa<EpisodeList episodes={visibleEpisodes} />.
- Importa
-
En
src/components/SearchBar.jsx:- Recibe
searchysetSearchpor props. - Renderiza un
<input className="form-control">controlado:<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar capítulo..." />
- Recibe
-
En
src/components/Filters.jsx:- Importa
SearchBar. - Recibe
searchysetSearchpor props y se los pasa a<SearchBar />.
- Importa
-
En
App.jsx, monta<Filters search={search} setSearch={setSearch} />encima del<EpisodeList />.
💡 Pista: input controlado = el
valuelo manda React, no el DOM. Por eso siempre que cambias elvalue, tienes que actualizar el estado cononChange.
Mismo patrón que el buscador, pero con un valor numérico (o "all" para "Todas").
-
En
App.jsx:- Añade un segundo estado:
const [season, setSeason] = useState("all"); - Cambia el filtro para combinar ambos:
const visibleEpisodes = episodes.filter((ep) => { const matchesName = ep.name.toLowerCase().includes(search.toLowerCase()); const matchesSeason = season === "all" || ep.season === season; return matchesName && matchesSeason; });
- Añade un segundo estado:
-
En
src/components/SeasonFilter.jsx:- Recibe
seasonysetSeasonpor props. - Renderiza un botón por temporada (1..8) + un botón "Todas".
- Al hacer click, llama a
setSeason(numero)osetSeason("all"). - Resalta visualmente la temporada activa (clase distinta de Bootstrap, ej.
btn-primaryvsbtn-outline-primary).
- Recibe
-
En
Filters.jsx, importaSeasonFiltery compón ambos hijos. Pasa tambiénseasonysetSeasonpor props.
💡 Pistas:
- Puedes hardcodear
[1, 2, 3, 4, 5, 6, 7, 8], o derivarlo del dataset con[...new Set(episodes.map(e => e.season))].sort().- Cuidado con los tipos: los botones devuelven strings por defecto si usas
e.target.value. Asegúrate de pasar números asetSeasonpara que la comparaciónep.season === seasonfuncione (o convierte conNumber(...)).
Ahora vamos a introducir acciones: el usuario hace click en un botón de una card y eso modifica el estado del padre.
-
En
App.jsx:- Añade un estado:
const [favs, setFavs] = useState([]);(array de IDs). - Crea la función:
function addFav(id) { if (!favs.includes(id)) { setFavs([...favs, id]); } }
- Pásala a
<EpisodeList />como proponFavjunto confavs.
- Añade un estado:
-
En
EpisodeList.jsx, recibefavsyonFavy pásaselos a cada<EpisodeCard />. A la card, en lugar del array completo, pásale solo si este episodio está marcado:isFav={favs.includes(episode.id)}. -
En
EpisodeCard.jsx:- Recibe
isFavyonFavpor props. - Añade un botón
<button className="btn btn-sm btn-outline-warning">♥ Favorito</button>que llame aonFav(episode.id). - Si
isFavestrue, cambia el estilo del botón (por ejemplobtn-warningsólido).
- Recibe
-
En
src/components/FavoriteEpisodes.jsx:- Recibe la lista de capítulos favoritos ya filtrada por props.
- Si la lista está vacía, muestra
"Aún no has marcado favoritos". - Si tiene elementos, lista los títulos (puedes reutilizar
<EpisodeCard />o hacer un resumen tipoS01·E01 — Winter is Coming).
-
En
App.jsx, encima del<EpisodeList />, monta:<FavoriteEpisodes favoriteEpisodes={episodes.filter((ep) => favs.includes(ep.id))} />
💡 Pistas:
- Nunca mutes el array con
favs.push(id). Crea uno nuevo con[...favs, id]. React detecta cambios por referencia.- Si quieres permitir desmarcar, conviértelo en
toggleFavy usafavs.filter(favId => favId !== id)cuando ya esté.
Mismo patrón que favoritos, pero con un toggle y un efecto visual en la card.
-
En
App.jsx:- Añade el estado:
const [watched, setWatched] = useState([]); - Crea:
function toggleWatched(id) { if (watched.includes(id)) { setWatched(watched.filter((w) => w !== id)); } else { setWatched([...watched, id]); } }
- Añade el estado:
-
Pasa
watchedytoggleWatchedpor el árbol igual que hiciste confavs:EpisodeListrecibewatchedyonToggleWatched.- Cada
EpisodeCardrecibeisWatched={watched.includes(episode.id)}yonToggleWatched.
-
En
EpisodeCard.jsx:- Añade un botón
"✓ Visto"que llame aonToggleWatched(episode.id). - Si
isWatchedestrue, aplica un estilo distinto a toda la card:- Una clase extra, por ejemplo
watched, conopacity: 0.6en tuindex.css. - O un badge "Visto" en la esquina con
<span className="badge bg-success">Visto</span>.
- Una clase extra, por ejemplo
- Añade un botón
💡 Reflexión: fíjate en cómo los componentes "tontos" (
EpisodeCard,EpisodeList) no cambian entre la iteración 6 y la 7 más allá de recibir una prop más. Esa es la idea: separar dónde vive el estado (en el padre) de dónde se pinta (en los hijos).
Si tienes tiempo:
- Renderizar
summarycomo HTML real: usadangerouslySetInnerHTML={{ __html: episode.summary }}y entiende los riesgos (XSS si los datos vinieran del usuario; aquí son seguros porque controlamos el dataset). - Formatear
airdatea un formato legible (new Date(episode.airdate).toLocaleDateString("es-ES")). - Ordenar los capítulos por
airstampascendente o descendente. - Persistir
favsywatchedenlocalStoragepara que sobrevivan a refrescos (useEffect+JSON.stringify/JSON.parse). - Modo "solo no vistos": un checkbox que filtre los capítulos que aún no has visto.
- React docs: State: A Component's Memory
- React docs: Sharing State Between Components (Lifting state up)
- React docs: Rendering Lists
- React docs: Reacting to Input with State (formularios)
- Bootstrap 5.3: Components
Happy coding! ❤️
