Saltar al contenido
schedule 25 min React

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

src/main.jsx
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:

src/hooks/useCharacters.js
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:

src/hooks/useFavorites.js
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)

src/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
src/App.css
.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

src/components/CharacterCard.jsx
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
src/components/CharacterCard.css
.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)

src/views/CharactersView.jsx
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
src/views/CharactersView.css
.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)

src/views/CharacterDetailView.jsx
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
src/views/CharacterDetailView.css
.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

src/views/FavoritesView.jsx
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
src/views/HomeView.jsx
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
src/views/HomeView.css
.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 useState y estado compartido entre componentes.
  • Efectos con useEffect para llamadas a la API y debounce.
  • Consumo de API con fetch y async/await.
  • Renderizado condicional para estados de carga, error y vacío.
  • Listas dinámicas con map y key.
  • 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.

code

Extiende el explorador del multiverso

Avanzado schedule 45 min

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.

Newsletter

Recibe nuevos cursos, actualizaciones, artículos del blog y promociones en tu correo.