Proyecto #01: ¿Quién es ese Pokémon?
Links
- https://pokeapi.co/
- vite
- Bootstrap
- https://icons.getbootstrap.com/
- https://transform.tools/html-to-jsx (para convertir HTML a JSX)
- número aleatorio
- https://www.npmjs.com/package/react-confetti
src\main.tsx
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap/dist/css/bootstrap.min.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
src\App.tsx
import PokemonDisplay from "./components/pokemon-display";
import PokemonForm from "./components/pokemon-form";
import PokemonResult from "./components/pokemon-result";
const App = () => {
return (
<div className="container mx-auto my-5">
<div className="row justify-content-center">
<div className="col-md-8">
<PokemonDisplay />
<PokemonForm />
<PokemonResult />
</div>
</div>
</div>
);
};
export default App;
src\components\pokemon-display.tsx
const PokemonDisplay = () => {
const showAnswer = false;
const image =
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/4.png";
return (
<div className="card">
<div className="card-header">
<h1 className="text-center py-3">
{!showAnswer ? "¿Quién es ese Pokémon?" : "¡Charmander!"}
</h1>
</div>
<div className="card-body">
<img
src={image}
alt="Pokemon"
className="img-fluid mx-auto d-block"
style={{
maxHeight: "300px",
filter: showAnswer ? "none" : "brightness(0)",
transition: "filter 0.3s ease-in-out",
}}
/>
</div>
</div>
);
};
export default PokemonDisplay;
src\components\pokemon-form.tsx
import { useState, type FormEvent } from "react";
const PokemonForm = () => {
const [userText, setUserText] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
};
return (
<div className="my-3">
<form
className="input-group mb-3"
onSubmit={handleSubmit}
>
<input
type="text"
className="form-control"
placeholder="¿Quién es ese Pokémon?"
aria-label="¿Quién es ese Pokémon?"
value={userText}
onChange={(e) => setUserText(e.target.value)}
/>
<button
className="btn btn-outline-dark"
type="submit"
disabled={!userText.trim()}
>
Adivinar
</button>
</form>
</div>
);
};
export default PokemonForm;
src\components\pokemon-result.tsx
const PokemonResult = () => {
const result = "correct";
return (
<div
className={`alert alert-${
result === "correct" ? "success" : "danger"
} text-center`}
>
{result === "correct" ? (
<span>
¡Correcto! <i className="bi bi-bluesky"></i>
</span>
) : (
<span>
¡Incorrecto! <i className="bi bi-slash-circle"></i>
</span>
)}
<button className="btn btn-outline-dark d-block mx-auto mt-3">
Volver a jugar
</button>
</div>
);
};
export default PokemonResult;
Conceptos
- useCallback
Práctica
src\types\pokemon.interface.ts
export interface Pokemon {
id: number;
name: string;
image: string; // sprites.other["official-artwork"].front_default
}
src\services\pokemon.service.ts
import type { Pokemon } from "../types/pokemon.interface";
const POKEMON_API_URL = "https://pokeapi.co/api/v2/pokemon";
const MAX_POKEMON_COUNT = 151;
function getRandomIntInclusive(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1) + min);
}
export const getRandomPokemon = async () => {
try {
const randomId = getRandomIntInclusive(1, MAX_POKEMON_COUNT);
const response = await fetch(`${POKEMON_API_URL}/${randomId}`);
if (!response.ok) {
throw new Error(`Error fetching Pokémon with ID ${randomId}`);
}
const pokemonData = await response.json();
return {
id: pokemonData.id,
name: pokemonData.name,
image:
pokemonData.sprites.other["official-artwork"].front_default ||
pokemonData.sprites.front_default,
} as Pokemon;
} catch (error) {
console.log(error);
throw new Error("Failed to fetch Pokémon data");
}
};
src\hooks\use-game-manager.ts
import { useCallback, useEffect, useState } from "react";
import { getRandomPokemon } from "../services/pokemon.service";
import type { Pokemon } from "../types/pokemon.interface";
export const useGameManager = () => {
const [pokemon, setPokemon] = useState<Pokemon | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const loadNewPokemon = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const newPokemon = await getRandomPokemon();
setPokemon(newPokemon);
} catch (err) {
setError("Failed to load Pokémon");
console.error(err);
} finally {
setIsLoading(false);
}
}, []);
// Cargar un Pokémon al iniciar el juego
useEffect(() => {
loadNewPokemon();
}, [loadNewPokemon]);
return {
pokemon,
isLoading,
error,
loadNewPokemon,
};
};
useCallback
useCallback
es un hook de React que memoriza una función para evitar que se vuelva a crear en cada renderizado, a menos que cambien sus dependencias. Esto es útil para optimizar el rendimiento de componentes que dependen de funciones que no necesitan ser recreadas en cada render.
Caso | ¿Usar useCallback ? |
---|---|
Solo usas la función dentro del mismo hook o componente | ❌ No |
Pasas la función a un componente hijo | ✅ Sí |
Es parte de una dependencia de useEffect , useMemo , etc. | ✅ Generalmente sí |
El hook tiene muchas funciones internas y quieres evitar renders innecesarios | ✅ Opcional, si el rendimiento es un problema |
¿Por qué usar useCallback
?
useCallback
es útil para optimizar el rendimiento de tu aplicación React, especialmente cuando pasas funciones como props a componentes hijos. Al memorizar la función, evitas que se vuelva a crear en cada renderizado, lo que puede prevenir renders innecesarios de los componentes hijos que dependen de esa función.
¿Por qué pasamos [loadNewPokemon]
como dependencia en el useEffect
?
En React, cuando usas useEffect
, el segundo argumento (el array de dependencias) le dice a React:
"Ejecuta este efecto cada vez que alguna de estas dependencias cambie."
En este caso:
useEffect(() => {
loadNewPokemon();
}, [loadNewPokemon]);
loadNewPokemon
es una función creada conuseCallback
.- Si su definición cambia (es decir, si alguna de sus dependencias cambia),
useCallback
creará una nueva función. - Por eso, React recomienda poner las funciones creadas con
useCallback
como dependencia de los efectos que las usan, para evitar bugs sutiles.
¿Eso puede causar un loop?
No, normalmente no causa un loop porque:
loadNewPokemon
se define conuseCallback
, y su array de dependencias es[]
(vacío), así que solo se crea una vez y no cambia nunca.- Por lo tanto, el efecto se ejecuta solo una vez (cuando el componente se monta) y no cada vez que se renderiza.
Ejemplo seguro:
const loadNewPokemon = useCallback(async () => {
// ...
}, []); // <- dependencias vacías
useEffect(() => {
loadNewPokemon();
}, [loadNewPokemon]);
¿Cuándo podría causar un loop?
Si declararas la función así sin useCallback
:
const loadNewPokemon = async () => { ... };
Cada render crea una nueva función, así que useEffect
lo vería como "cambio" en cada render, ejecutando de nuevo el efecto... y ahí sí sería un loop infinito.
Por eso es importante memorizar la función con useCallback
si la usas como dependencia.
¿Qué pasa si NO pasas la función como dependencia?
Supón este código:
const loadNewPokemon = useCallback(async () => {
// ...
}, []);
useEffect(() => {
loadNewPokemon();
}, []); // <--- dependencia vacía
En este caso, el efecto solo se ejecuta una vez al montar el componente, porque el array de dependencias está vacío.
Aunque cambie loadNewPokemon
en el futuro, el efecto no volverá a ejecutarse.
¿Entonces, para qué ponerla como dependencia?
React recomienda poner TODAS las variables y funciones que usas dentro de tu efecto en el array de dependencias, para evitar bugs difíciles de detectar si alguna de ellas cambia en el futuro. Esto es especialmente importante si:
- La función depende de props, estados o cualquier variable que pueda cambiar.
- El código del hook puede evolucionar y la función podría dejar de ser estable.
Si usas useCallback
con dependencias vacías ([]
), la función es "estable" y no cambiará nunca, así que en la práctica no pasa nada si no la pones como dependencia (no se rompe el efecto ni se crea un loop).
PERO:
Si por accidente cambias la función (por ejemplo, agregando un estado o prop como dependencia de useCallback
), y no actualizas el array de dependencias del useEffect
, puedes tener bugs muy difíciles de encontrar.
Forma segura y recomendada
Siempre pon tus funciones como dependencia del efecto (aunque vengan de useCallback
).
Esto te protege si, en el futuro, la función sí cambia.
src\hooks\use-game-manager.ts
src\hooks\use-game-manager.ts
import { useCallback, useEffect, useState } from "react";
import { getRandomPokemon } from "../services/pokemon.service";
import type { Pokemon } from "../types/pokemon.interface";
export const useGameManager = () => {
const [pokemon, setPokemon] = useState<Pokemon | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const loadNewPokemon = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const newPokemon = await getRandomPokemon();
setPokemon(newPokemon);
} catch (err) {
setError("Failed to load Pokémon");
console.error(err);
} finally {
setIsLoading(false);
}
}, []);
// Cargar un Pokémon al iniciar el juego
useEffect(() => {
loadNewPokemon();
}, [loadNewPokemon]);
return {
pokemon,
isLoading,
error,
loadNewPokemon,
};
};
src\App.tsx
import PokemonDisplay from "./components/pokemon-display";
import PokemonForm from "./components/pokemon-form";
import PokemonResult from "./components/pokemon-result";
import { useGameManager } from "./hooks/use-game-manager";
const App = () => {
// use GameManager
const { isLoading, pokemon, error, loadNewPokemon } = useGameManager();
if (isLoading) {
return <div className="text-center">Loading...</div>;
}
if (error) {
return <div className="text-center text-danger">{error}</div>;
}
return (
<div className="container mx-auto my-5">
<div className="row justify-content-center">
<div className="col-md-8">
<PokemonDisplay pokemon={pokemon} />
<PokemonForm />
<PokemonResult loadNewPokemon={loadNewPokemon} />
</div>
</div>
</div>
);
};
export default App;
src\components\pokemon-display.tsx
import type { Pokemon } from "../types/pokemon.interface";
interface Props {
pokemon: Pokemon | null;
}
const PokemonDisplay = ({ pokemon }: Props) => {
const showAnswer = false;
const image = pokemon?.image;
console.log(pokemon?.name);
return (
...
);
};
export default PokemonDisplay;
src\components\pokemon-result.tsx
interface Props {
loadNewPokemon: () => void;
}
const PokemonResult = ({ loadNewPokemon }: Props) => {
const result = "correct";
return (
<div
className={`alert alert-${
result === "correct" ? "success" : "danger"
} text-center`}
>
...
<button
className="btn btn-outline-dark d-block mx-auto mt-3"
onClick={loadNewPokemon}
>
Volver a jugar
</button>
</div>
);
};
export default PokemonResult;
Procesar respuesta del usuario
src\services\pokemon.service.ts
const normalizePokemonName = (name: string) => {
return name
.toLowerCase()
.trim()
.normalize("NFD") // Normaliza a caracteres acentuados
.replace(/[\u0300-\u036f]/g, "") // Remove los acentos
.replace(/[^a-z0-9]/g, ""); // Elimina caracteres especiales
};
export const isPokemonNameCorrect = (
pokemonName: string,
userInput: string
): boolean => {
const normalizedPokemonName = normalizePokemonName(pokemonName);
const normalizedUserInput = normalizePokemonName(userInput);
return normalizedPokemonName === normalizedUserInput;
};
src\hooks\use-game-manager.ts
import { useCallback, useEffect, useState } from "react";
import {
getRandomPokemon,
isPokemonNameCorrect,
} from "../services/pokemon.service";
import type { Pokemon } from "../types/pokemon.interface";
// export enum GameState {
// Playing = "playing",
// Correct = "correct",
// Wrong = "wrong",
// }
// const union
export const GameState = {
Playing: "playing",
Correct: "correct",
Wrong: "wrong",
} as const;
export type GameState = (typeof GameState)[keyof typeof GameState];
export const useGameManager = () => {
const [pokemon, setPokemon] = useState<Pokemon | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [gameState, setGameState] = useState<GameState>(GameState.Playing);
const handlePokemonNameSubmit = useCallback(
(userInput: string) => {
if (!pokemon) return;
const isCorrect = isPokemonNameCorrect(pokemon.name, userInput);
setGameState(isCorrect ? GameState.Correct : GameState.Wrong);
},
[pokemon] // Quiero que esta función se memorice (no se redefina) a menos que pokemon cambie.
);
const loadNewPokemon = useCallback(async () => {
setIsLoading(true);
setError(null);
setGameState(GameState.Playing);
try {
const newPokemon = await getRandomPokemon();
setPokemon(newPokemon);
} catch (err) {
setError("Failed to load Pokémon");
console.error(err);
} finally {
setIsLoading(false);
}
}, []);
// Cargar un Pokémon al iniciar el juego
useEffect(() => {
loadNewPokemon();
}, [loadNewPokemon]);
return {
pokemon,
isLoading,
error,
loadNewPokemon,
handlePokemonNameSubmit,
gameState,
};
};
src\App.tsx
import PokemonDisplay from "./components/pokemon-display";
import PokemonForm from "./components/pokemon-form";
import PokemonResult from "./components/pokemon-result";
import { useGameManager } from "./hooks/use-game-manager";
const App = () => {
// use GameManager
const {
isLoading,
pokemon,
error,
loadNewPokemon,
gameState,
handlePokemonNameSubmit,
} = useGameManager();
if (error) {
return <div className="text-center text-danger">{error}</div>;
}
return (
<div className="container mx-auto my-5">
<div className="row justify-content-center">
<div className="col-md-8">
<PokemonDisplay
pokemon={pokemon}
isLoading={isLoading}
gameState={gameState}
/>
<PokemonForm
handlePokemonNameSubmit={handlePokemonNameSubmit}
gameState={gameState}
/>
<PokemonResult
loadNewPokemon={loadNewPokemon}
gameState={gameState}
/>
</div>
</div>
</div>
);
};
export default App;
src\components\pokemon-form.tsx
import { useState, type FormEvent } from "react";
import type { GameState } from "../hooks/use-game-manager";
interface Props {
handlePokemonNameSubmit: (userInput: string) => void;
gameState: GameState;
}
const PokemonForm = ({ handlePokemonNameSubmit, gameState }: Props) => {
const [userText, setUserText] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handlePokemonNameSubmit(userText);
setUserText(""); // Limpiar el campo de entrada después de enviar
};
return (
<div className="my-3">
<form
className="input-group mb-3"
onSubmit={handleSubmit}
>
<input
type="text"
className="form-control"
placeholder="¿Quién es ese Pokémon?"
aria-label="¿Quién es ese Pokémon?"
value={userText}
onChange={(e) => setUserText(e.target.value)}
disabled={gameState !== "playing"} // Deshabilitar si no está en juego
autoFocus
/>
<button
className="btn btn-outline-dark"
type="submit"
disabled={gameState !== "playing" || userText.trim() === ""}
>
Adivinar
</button>
</form>
</div>
);
};
export default PokemonForm;
src\components\pokemon-result.tsx
import type { GameState } from "../hooks/use-game-manager";
interface Props {
loadNewPokemon: () => void;
gameState: GameState;
}
const PokemonResult = ({ loadNewPokemon, gameState }: Props) => {
if (gameState === "playing") {
return null; // No mostrar nada si el juego está en curso
}
return (
<div
className={`alert alert-${
gameState === "correct" ? "success" : "danger"
} text-center`}
>
{gameState === "correct" ? (
<span>
¡Correcto! <i className="bi bi-bluesky"></i>
</span>
) : (
<span>
¡Incorrecto! <i className="bi bi-slash-circle"></i>
</span>
)}
<button
className="btn btn-outline-dark d-block mx-auto mt-3"
onClick={loadNewPokemon}
>
Volver a jugar
</button>
</div>
);
};
export default PokemonResult;
src\components\pokemon-display.tsx
import type { GameState } from "../hooks/use-game-manager";
import type { Pokemon } from "../types/pokemon.interface";
interface Props {
pokemon: Pokemon | null;
isLoading: boolean;
gameState: GameState;
}
const PokemonDisplay = ({ pokemon, isLoading, gameState }: Props) => {
const showAnswer = gameState === "correct" || gameState === "wrong";
const image = pokemon?.image;
console.log(pokemon?.name);
return (
<div className="card">
<div className="card-header">
<h1 className="text-center py-3">
{!showAnswer ? "¿Quién es ese Pokémon?" : `¡${pokemon?.name}!`}
</h1>
</div>
<div className="card-body">
{isLoading ? (
<div className="text-center">
<div
className="spinner-border text-primary"
role="status"
>
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : (
<img
src={image}
alt="Pokemon"
className="img-fluid mx-auto d-block"
style={{
maxHeight: "300px",
filter: showAnswer ? "none" : "brightness(0)",
transition: "filter 0.3s ease-in-out",
}}
/>
)}
</div>
</div>
);
};
export default PokemonDisplay;
Error erasableSyntaxOnly
y enum
El error relacionado con erasableSyntaxOnly
y el uso de enum
se debe a cómo TypeScript trata ciertas características de tipo cuando se usa con herramientas como Babel o compiladores alternativos (como SWC o esbuild) en entornos como Vite, Next.js, etc.
🧠 ¿Qué es erasableSyntaxOnly
?
erasableSyntaxOnly
es una opción interna de TypeScript (no documentada oficialmente) que aparece cuando estás usando un transformador personalizado de TypeScript, como Babel.
Cuando se establece en true
, el compilador solo acepta sintaxis de tipos que pueda "borrar" fácilmente (es decir, que se pueda eliminar en tiempo de compilación sin dejar rastros en el JavaScript final).
❌ ¿Por qué enum
da problemas cuando erasableSyntaxOnly: true
?
Porque los enum
no son borrables. A diferencia de tipos (type
, interface
) que desaparecen en tiempo de compilación, los enum
son transformados a código JavaScript real:
Por ejemplo:
export enum GameState {
Playing = "playing",
Correct = "correct",
Wrong = "wrong",
}
Se transpila a:
export var GameState;
(function (GameState) {
GameState["Playing"] = "playing";
GameState["Correct"] = "correct";
GameState["Wrong"] = "wrong";
})(GameState || (GameState = {}));
Es decir: genera código en tiempo de ejecución, y eso no está permitido si tu configuración está diseñada solo para borrar tipos.
✅ ¿Cómo solucionarlo?
Tienes dos opciones:
✅ Opción 1: Desactiva erasableSyntaxOnly
Si estás usando Babel, es posible que esté activado por configuración. Verifica si tu proyecto (por ejemplo con vite-plugin-swc
o @babel/preset-typescript
) está forzando eso. Lo puedes desactivar o cambiar a una configuración que permita enum
.
✅ Opción 2: Evita enum
y usa un const
union
Puedes reemplazar el enum
por un objeto as const
y una unión de tipos, que sí son borrables y 100% compatibles con Babel y esbuild:
export const GameState = {
Playing: "playing",
Correct: "correct",
Wrong: "wrong",
} as const;
export type GameState = (typeof GameState)[keyof typeof GameState];
Y lo usas así:
const [gameState, setGameState] = useState<GameState>(GameState.Playing);
💡 Esta forma es más flexible, moderna y totalmente compatible con proyectos como Vite o Next.js usando SWC.
Confetti
npm install react-confetti
npm install react-use
App.tsx
import Confetti from "react-confetti";
import { useWindowSize } from "react-use";
import PokemonDisplay from "./components/pokemon-display";
import PokemonForm from "./components/pokemon-form";
import PokemonResult from "./components/pokemon-result";
import { GameState, useGameManager } from "./hooks/use-game-manager";
const App = () => {
// use GameManager
const {
isLoading,
pokemon,
error,
loadNewPokemon,
gameState,
handlePokemonNameSubmit,
} = useGameManager();
const { width, height } = useWindowSize(); // para tamaño de la ventana
if (error) {
return <div className="text-center text-danger">{error}</div>;
}
return (
<div className="container mx-auto my-5">
{gameState === GameState.Correct && (
<Confetti
width={width}
height={height}
numberOfPieces={300}
recycle={false}
/>
)}
<div className="row justify-content-center">
<div className="col-md-8">
<PokemonDisplay
pokemon={pokemon}
isLoading={isLoading}
gameState={gameState}
/>
<PokemonForm
handlePokemonNameSubmit={handlePokemonNameSubmit}
gameState={gameState}
/>
<PokemonResult
loadNewPokemon={loadNewPokemon}
gameState={gameState}
/>
</div>
</div>
</div>
);
};
export default App;