Skip to content

Proyecto #01: ¿Quién es ese Pokémon?

src\main.tsx

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

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

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

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

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

ts
export interface Pokemon {
  id: number;
  name: string;
  image: string; // sprites.other["official-artwork"].front_default
}

src\services\pokemon.service.ts

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

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:

typescript
useEffect(() => {
  loadNewPokemon();
}, [loadNewPokemon]);
  • loadNewPokemon es una función creada con useCallback.
  • 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 con useCallback, 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:

typescript
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:

typescript
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:

typescript
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

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

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

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

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

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

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

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

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

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

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:

ts
export enum GameState {
  Playing = "playing",
  Correct = "correct",
  Wrong = "wrong",
}

Se transpila a:

js
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:

ts
export const GameState = {
  Playing: "playing",
  Correct: "correct",
  Wrong: "wrong",
} as const;

export type GameState = (typeof GameState)[keyof typeof GameState];

Y lo usas así:

ts
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

sh
npm install react-confetti
npm install react-use

App.tsx

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;