Proyecto final
Esto es todo. Componentes, props, estado, efectos, formularios, listas, consumo de APIs, routing — todo lo que has aprendido en las lecciones anteriores se une aquí en un solo proyecto. Vas a construir una aplicación completa desde cero.
Lo que vamos a construir
Un explorador de personajes de Rick and Morty usando la API pública https://rickandmortyapi.com/api. La aplicación tendrá:
- Página de inicio con bienvenida y accesos directos.
- Lista de personajes con búsqueda, paginación y estados de carga.
- Detalle de personaje con toda su información al hacer clic.
- Sistema de favoritos con persistencia en localStorage.
- Navegación entre páginas con React Router.
Cada paso usa conceptos que ya dominas. Si en algún momento no recuerdas algo, vuelve a la lección correspondiente — para eso están.
Paso 1 — Crear el proyecto
Crea un nuevo proyecto React con Vite:
npm create vite@latest rick-morty-explorer -- --template react
cd rick-morty-explorer
npm install
npm install react-router-dom
npm run dev
Limpia el contenido por defecto: vacía src/App.jsx y src/App.css, elimina el logo de ejemplo. Crea las carpetas que necesitarás:
mkdir -p src/views src/components src/hooks
Configurar el router
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
Paso 2 — Custom hook useCharacters
Crea el archivo src/hooks/useCharacters.js. Este hook encapsula toda la lógica de la API:
import { useState } from 'react'
function useCharacters() {
const [characters, setCharacters] = useState([])
const [character, setCharacter] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [info, setInfo] = useState(null)
async function fetchCharacters(page = 1, name = '') {
setLoading(true)
setError(null)
try {
let url = `https://rickandmortyapi.com/api/character?page=${page}`
if (name) url += `&name=${name}`
const response = await fetch(url)
if (response.status === 404) {
setCharacters([])
setInfo(null)
return
}
if (!response.ok) {
throw new Error('Error al cargar los personajes')
}
const data = await response.json()
setCharacters(data.results)
setInfo(data.info)
} catch (err) {
setError(err.message)
setCharacters([])
} finally {
setLoading(false)
}
}
async function fetchCharacter(id) {
setLoading(true)
setError(null)
try {
const response = await fetch(
`https://rickandmortyapi.com/api/character/${id}`
)
if (!response.ok) throw new Error('Personaje no encontrado')
setCharacter(await response.json())
} catch (err) {
setError(err.message)
setCharacter(null)
} finally {
setLoading(false)
}
}
return { characters, character, loading, error, info, fetchCharacters, fetchCharacter }
}
export default useCharacters
Paso 3 — Custom hook useFavorites
El sistema de favoritos usa localStorage para que persistan aunque el usuario cierre el navegador:
import { useState, useEffect } from 'react'
// Leer favoritos iniciales de localStorage
const initialFavorites = JSON.parse(
localStorage.getItem('rm_favorites') || '[]'
)
// Estado compartido fuera del hook (singleton)
let listeners = []
let favoritesState = initialFavorites
function useFavorites() {
const [favorites, setFavorites] = useState(favoritesState)
useEffect(() => {
// Registrar este componente como listener
listeners.push(setFavorites)
return () => {
listeners = listeners.filter(l => l !== setFavorites)
}
}, [])
function updateFavorites(newFavorites) {
favoritesState = newFavorites
localStorage.setItem('rm_favorites', JSON.stringify(newFavorites))
// Notificar a todos los componentes que usan el hook
listeners.forEach(setFn => setFn(newFavorites))
}
function isFavorite(id) {
return favorites.some(fav => fav.id === id)
}
function toggleFavorite(character) {
if (isFavorite(character.id)) {
updateFavorites(favorites.filter(fav => fav.id !== character.id))
} else {
updateFavorites([...favorites, {
id: character.id,
name: character.name,
image: character.image,
status: character.status,
species: character.species,
}])
}
}
return { favorites, isFavorite, toggleFavorite }
}
export default useFavorites
El estado compartido con listeners es un patrón simple de estado global. Todos los componentes que usen
useFavorites()ven la misma lista y se actualizan cuando cambia. En aplicaciones más grandes usarías una librería como Zustand o Redux, pero para este proyecto es perfecto.
Paso 4 — Layout y navegación (App.jsx)
import { Routes, Route, NavLink } from 'react-router-dom'
import useFavorites from './hooks/useFavorites'
import HomeView from './views/HomeView'
import CharactersView from './views/CharactersView'
import CharacterDetailView from './views/CharacterDetailView'
import FavoritesView from './views/FavoritesView'
import './App.css'
function App() {
const { favorites } = useFavorites()
return (
<div className="app">
<nav className="navbar">
<NavLink to="/" className="navbar__brand" end>
Rick & Morty Explorer
</NavLink>
<div className="navbar__links">
<NavLink to="/" end>Inicio</NavLink>
<NavLink to="/characters">Personajes</NavLink>
<NavLink to="/favorites">
Favoritos
{favorites.length > 0 && (
<span className="badge">{favorites.length}</span>
)}
</NavLink>
</div>
</nav>
<main className="main">
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/characters" element={<CharactersView />} />
<Route path="/character/:id" element={<CharacterDetailView />} />
<Route path="/favorites" element={<FavoritesView />} />
</Routes>
</main>
</div>
)
}
export default App
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: #1a1a2e;
border-bottom: 2px solid #61DAFB;
}
.navbar__brand {
font-size: 1.25rem;
font-weight: bold;
color: #61DAFB;
text-decoration: none;
}
.navbar__links {
display: flex;
gap: 1.5rem;
}
.navbar__links a {
color: #ccc;
text-decoration: none;
transition: color 0.2s;
}
.navbar__links a:hover,
.navbar__links a.active {
color: #61DAFB;
}
.badge {
background: #61DAFB;
color: #1a1a2e;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: bold;
margin-left: 0.25rem;
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
Paso 5 — Componente CharacterCard
import { Link } from 'react-router-dom'
import useFavorites from '../hooks/useFavorites'
import './CharacterCard.css'
function CharacterCard({ character }) {
const { isFavorite, toggleFavorite } = useFavorites()
return (
<div className="card">
<Link to={`/character/${character.id}`}>
<img
src={character.image}
alt={character.name}
className="card__image"
/>
</Link>
<div className="card__body">
<h3 className="card__name">{character.name}</h3>
<p className="card__info">
<span className={`card__status ${character.status.toLowerCase()}`} />
{character.status} — {character.species}
</p>
<button
className="card__fav"
onClick={() => toggleFavorite(character)}
>
{isFavorite(character.id) ? '❤ Quitar' : '♡ Favorito'}
</button>
</div>
</div>
)
}
export default CharacterCard
.card {
background: #16213e;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s;
}
.card:hover { transform: translateY(-4px); }
.card__image { width: 100%; display: block; }
.card__body { padding: 1rem; }
.card__name {
margin: 0 0 0.5rem;
color: #fff;
font-size: 1.1rem;
}
.card__info {
color: #aaa;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card__status {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.card__status.alive { background: #4FC08D; }
.card__status.dead { background: #e74c3c; }
.card__status.unknown { background: #999; }
.card__fav {
margin-top: 0.75rem;
background: none;
border: 1px solid #61DAFB;
color: #61DAFB;
padding: 0.4rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.card__fav:hover {
background: #61DAFB;
color: #1a1a2e;
}
Paso 6 — Vista de personajes (CharactersView)
import { useState, useEffect } from 'react'
import useCharacters from '../hooks/useCharacters'
import CharacterCard from '../components/CharacterCard'
import './CharactersView.css'
function CharactersView() {
const { characters, loading, error, info, fetchCharacters } = useCharacters()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
useEffect(() => {
fetchCharacters()
}, [])
useEffect(() => {
const timeout = setTimeout(() => {
setPage(1)
fetchCharacters(1, search)
}, 400)
return () => clearTimeout(timeout)
}, [search])
function prevPage() {
if (page > 1) {
const newPage = page - 1
setPage(newPage)
fetchCharacters(newPage, search)
}
}
function nextPage() {
if (info?.next) {
const newPage = page + 1
setPage(newPage)
fetchCharacters(newPage, search)
}
}
return (
<div>
<h1>Personajes</h1>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
type="text"
placeholder="Buscar personaje..."
className="search-input"
/>
{loading && <div className="status">Cargando personajes...</div>}
{!loading && error && <div className="status status--error">{error}</div>}
{!loading && !error && characters.length === 0 && (
<div className="status">No se encontraron personajes.</div>
)}
{!loading && !error && characters.length > 0 && (
<>
<div className="grid">
{characters.map(character => (
<CharacterCard key={character.id} character={character} />
))}
</div>
<div className="pagination">
<button onClick={prevPage} disabled={page <= 1}>
← Anterior
</button>
<span>Página {page} de {info?.pages || '?'}</span>
<button onClick={nextPage} disabled={!info?.next}>
Siguiente →
</button>
</div>
</>
)}
</div>
)
}
export default CharactersView
.search-input {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border: 2px solid #333;
border-radius: 8px;
background: #16213e;
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: #61DAFB;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
padding: 0.5rem 1rem;
background: #61DAFB;
color: #1a1a2e;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status {
text-align: center;
color: #aaa;
padding: 3rem;
font-size: 1.1rem;
}
.status--error { color: #e74c3c; }
Paso 7 — Vista de detalle (CharacterDetailView)
import { useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import useCharacters from '../hooks/useCharacters'
import useFavorites from '../hooks/useFavorites'
import './CharacterDetailView.css'
function CharacterDetailView() {
const { id } = useParams()
const navigate = useNavigate()
const { character, loading, error, fetchCharacter } = useCharacters()
const { isFavorite, toggleFavorite } = useFavorites()
useEffect(() => {
fetchCharacter(id)
}, [id])
if (loading) return <div className="status">Cargando...</div>
if (error) return <div className="status status--error">{error}</div>
if (!character) return null
return (
<div>
<button className="back-btn" onClick={() => navigate(-1)}>
← Volver
</button>
<div className="detail">
<img
src={character.image}
alt={character.name}
className="detail__image"
/>
<div className="detail__info">
<h1>{character.name}</h1>
<p>
<span className={`status-dot ${character.status.toLowerCase()}`} />
{character.status} — {character.species}
</p>
<ul className="detail__list">
<li><strong>Género:</strong> {character.gender}</li>
<li><strong>Origen:</strong> {character.origin.name}</li>
<li><strong>Ubicación:</strong> {character.location.name}</li>
<li><strong>Episodios:</strong> {character.episode.length}</li>
</ul>
<button
className="fav-btn"
onClick={() => toggleFavorite(character)}
>
{isFavorite(character.id)
? '❤ Quitar de favoritos'
: '♡ Añadir a favoritos'}
</button>
</div>
</div>
</div>
)
}
export default CharacterDetailView
.back-btn {
background: none;
border: 1px solid #666;
color: #ccc;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-bottom: 1.5rem;
}
.back-btn:hover { border-color: #61DAFB; color: #61DAFB; }
.detail {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.detail__image {
width: 300px;
border-radius: 12px;
}
.detail__info h1 {
margin-top: 0;
color: #fff;
}
.detail__list {
list-style: none;
padding: 0;
}
.detail__list li {
padding: 0.4rem 0;
color: #ccc;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
}
.status-dot.alive { background: #4FC08D; }
.status-dot.dead { background: #e74c3c; }
.status-dot.unknown { background: #999; }
.fav-btn {
margin-top: 1rem;
padding: 0.6rem 1.5rem;
background: none;
border: 2px solid #61DAFB;
color: #61DAFB;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.fav-btn:hover {
background: #61DAFB;
color: #1a1a2e;
}
.status { text-align: center; color: #aaa; padding: 3rem; }
.status--error { color: #e74c3c; }
@media (max-width: 640px) {
.detail { flex-direction: column; }
.detail__image { width: 100%; }
}
Paso 8 — Favoritos e inicio
import { Link } from 'react-router-dom'
import useFavorites from '../hooks/useFavorites'
import CharacterCard from '../components/CharacterCard'
function FavoritesView() {
const { favorites } = useFavorites()
return (
<div>
<h1>Tus favoritos</h1>
{favorites.length === 0 ? (
<div className="empty">
<p>No tienes favoritos todavía. Explora los personajes y añade los que más te gusten.</p>
<Link to="/characters" className="cta">Ver personajes</Link>
</div>
) : (
<div className="grid">
{favorites.map(character => (
<CharacterCard key={character.id} character={character} />
))}
</div>
)}
</div>
)
}
export default FavoritesView
import { Link } from 'react-router-dom'
import './HomeView.css'
function HomeView() {
return (
<div className="home">
<h1>Rick & Morty Explorer</h1>
<p>
Explora todos los personajes del multiverso de Rick and Morty.
Busca, descubre detalles y guarda tus favoritos.
</p>
<div className="home__actions">
<Link to="/characters" className="btn btn--primary">
Explorar personajes
</Link>
<Link to="/favorites" className="btn btn--secondary">
Ver favoritos
</Link>
</div>
</div>
)
}
export default HomeView
.home {
text-align: center;
padding: 4rem 1rem;
}
.home h1 {
font-size: 2.5rem;
color: #61DAFB;
}
.home p {
color: #aaa;
font-size: 1.2rem;
max-width: 500px;
margin: 1rem auto 2rem;
}
.home__actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 2rem;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
font-size: 1rem;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn--primary {
background: #61DAFB;
color: #1a1a2e;
}
.btn--secondary {
background: transparent;
border: 2px solid #61DAFB;
color: #61DAFB;
}
.empty {
text-align: center;
color: #aaa;
padding: 3rem;
}
.cta {
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 1.5rem;
background: #61DAFB;
color: #1a1a2e;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
}
Lo que has construido
Mira todo lo que acabas de hacer. En una sola aplicación has usado:
- Componentes reutilizables (
CharacterCard) con props. - Custom hooks (
useCharacters,useFavorites) para encapsular lógica. - Estado con
useStatey estado compartido entre componentes. - Efectos con
useEffectpara llamadas a la API y debounce. - Consumo de API con
fetchyasync/await. - Renderizado condicional para estados de carga, error y vacío.
- Listas dinámicas con
mapykey. - Routing con React Router: rutas, parámetros dinámicos, navegación programática.
- Persistencia en localStorage para los favoritos.
- Eventos y binding dinámico de clases.
Has construido una SPA completa y funcional. Esto ya no es un ejercicio de curso — es una aplicación real que podrías enseñar en una entrevista de trabajo.
Extiende el explorador del multiverso
Tu explorador funciona, pero siempre se puede mejorar. Elige al menos dos de estas extensiones y añádelas al proyecto:
- Filtro por estado: añade botones o un select para filtrar por Alive, Dead o Unknown (usa el parámetro
?status=de la API). - Filtro por especie: añade un filtro por especie (Human, Alien, etc.) usando
?species=. - Lista de episodios: en la vista de detalle, haz fetch de los episodios del personaje y muestra sus nombres.
- Personaje aleatorio: añade un botón en la home que navegue a un personaje al azar (la API tiene 826 personajes).
- Modo oscuro: añade un toggle para cambiar entre tema claro y oscuro, guardando la preferencia en localStorage.
lightbulb Pistas
Para el filtro por estado, añade un estado para el status seleccionado y pásalo a fetchCharacters construyendo la URL con &status=. Para el personaje aleatorio, genera un número entre 1 y 826 con Math.ceil(Math.random() * 826) y usa navigate. Para el modo oscuro, usa un useState booleano y aplica una clase CSS al elemento raíz con useEffect.