이 글은 노마드코더 - AI 기초 탄탄 클럽 시리즈의 12번째 기록입니다. (총 13개)

노마드코더에서 수강 가능한 풀스택 웹 기초를 6주만에 훑는 챌린지. 해당 강의: ReactJS로 영화 웹 서비스 만들기 #19는 React(JS)로 포켓몬 갤러리를 만드는 것.


챌린지 목표

  • 여러 마리의 포켓몬을 카드 형태로 보여주는 갤러리를 React로 만드세요.
  • 블루프린트에 제공된 포켓몬 배열을 활용하세요. (! 이걸 지금 봤는데 API에서 불러온거라 실패처리되려나…)

챌린지 조건

  1. 지금 블루프린트에는 Bulbasaur 한 마리만 화면에 보입니다. pokemons 배열을 .map()으로 순회하여, 8마리 모두가 카드로 표시되도록 만드세요.
  2. 각 카드에 고유한 key 값을 지정하세요.
  3. 블루프린트의 “잡기” 버튼이 동작하도록 만드세요. 버튼을 처음 누르면 잡힌 상태(🔴)가 되고, 다시 누르면 놓아준 상태(⚪)로 돌아가야 합니다.
  4. 각 카드의 “잡기” 상태는 서로 독립적으로 관리되어야 합니다.

리스트 렌더링 — .map() + key

pokemons.map((pokemon) => (
  <PokemonCard key={pokemon.id} ... />
))
  • React는 리스트를 렌더링할 때 각 요소를 추적할 고유 식별자가 필요함
  • key는 props가 아니라 React 내부용 힌트 — PokemonCard 안에서 props.key로 접근 불가
  • 배열 index를 key로 쓰면 순서가 바뀔 때 버그 생길 수 있어서, 고유 ID 사용이 정석

상태 끌어올리기 (Lifting State Up)

  • 잡기 상태를 PokemonCard 안에서 각자 관리하지 않고 굳이 App으로 끌어올림(사실 챌린지 달성만 목표로 했다면 끌어올리지 않아도 됨)
// App.jsx
const [caughtIds, setCaughtIds] = React.useState([]);

// PokemonCard에 내려줌
isCaught={caughtIds.includes(pokemon.id)}
onToggle={() => toggleCatch(pokemon.id)}
  • 전체 포획 수 집계 (currentCaught, 달성률 계산)가 필요했기 때문. 미션은 아니었지만 왠지 당연히 있어야 할 것 같았다.
  • 상태를 공유하거나 집계해야 할 때 → 공통 부모로 올리는 게 React 패턴

useEffect + 비동기 fetch

React.useEffect(() => {
  const fetchPokemons = async () => { ... };
  fetchPokemons();
}, []);
  • useEffect 콜백 자체는 async 불가 → 내부에 async 함수 선언 후 즉시 호출하는 패턴
  • [] 빈 의존성 배열 → 마운트 시 딱 한 번만 실행

Promise.all로 병렬 fetch

const detailedPokemons = await Promise.all(
  data.results.map(async (pokemon, index) => {
    const res = await fetch(`.../${index + 1}`);
    return res.json();
  })
);
  • 151번까지 순차 요청하면 엄청 느림(미션은 8개였으나 151번까지가 1세대 포켓몬이라 maxIndex를 151로 잡음)
  • Promise.all은 모든 요청을 동시에 쏘고, 전부 완료되면 결과 배열 반환
  • 단, 하나라도 실패하면 전체 실패 → 실무에선 Promise.allSettled 고려. 단 이번에는 하나의 API에서 데이터를 받아오는 방식이라 Promise.all 사용

토글 상태 관리 — 배열 기반

const toggleCatch = (id) => {
  if (caughtIds.includes(id)) {
    setCaughtIds(caughtIds.filter((caughtId) => caughtId !== id));
  } else {
    setCaughtIds([...caughtIds, id]);
  }
};
  • boolean 하나짜리 상태가 아니라, 어떤 ID들이 잡혔는지 배열로 관리
  • Set으로 관리하는 방식도 있음 — 포켓몬이 많아질수록 includes O(n)보다 Set.has O(1)이 유리

다국어 처리 — 객체 룩업

const notices = {
  ko: '현재 잡은 포켓몬',
  en: 'Current Pokémon',
  'ja-hrkt': '現在所持しているポケモン',
};

// 사용
notices[lang]
  • if/elseswitch 대신 객체를 딕셔너리처럼 쓰는 패턴
  • PokeAPI의 pokemon.names.find(item => item.language.name === lang) 도 같은 맥락 — 언어 코드로 필터링

CSS Grid auto-fill + minmax

grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  • 컬럼 수를 직접 지정하지 않아도, 컨테이너 너비에 따라 자동으로 줄 수 조정
  • 반응형 갤러리 레이아웃의 정석 패턴

toFixed(n)

  • 소수점 n자리까지만 보여줌. n+1자리에서 반올림
const percentageOfGoal = (currentCaught / maxIndex) * 100;

...

<span>
  {notices[lang]}: {currentCaught} / {maxIndex} ({percentageOfGoal.toFixed(2)}%)
</span>

textTransform: ‘capitalize’

<p style={{ textTransform: 'capitalize' }}>
  {pokemon.egg_groups.map((group) => group.name).join(', ')}
</p>
  • 소문자로 데이터를 넘겨도 브라우저가 알아서 첫 알파벳을 대문자로 렌더링해줌.

replace, Set

const cleanNames = pokemon.egg_groups.map((group=>
group.name.replace(/[0-9]/g'')
); // 숫자 없애기

const uniqueNames = [...new Set(cleanNames)]; // 중복 제거

return (
	<PokemonCard
		key={pokemon.id}
		name={pokemonName.name}
		id={pokemon.id}
		type={uniqueNames.join(', ')} // 여러개면 연달아 표시
		isCaught={caughtIds.includes(pokemon.id)}
		onToggle={() => toggleCatch(pokemon.id)}
	/>
);


Series: 노마드코더 - AI 기초 탄탄 클럽