[개인과제] 포켓몬 도감 만들기

안현희·2024년 11월 7일
1

React를 배워보자!

목록 보기
14/20
post-thumbnail

리액트 숙련주차 과제가 주어졌다.

⭐스압주의⭐

  • 리액트 숙련주차인만큼 잘해내봅시다.
  • 첫 번째는 props-drilling방식 입니다.

1. 라우터 설정 및 스타일드 컴포넌트

1-1. 라우터 설정

우선 패키지를 먼저 설치해준다.

npm install react-router-dom

설치가 완료되면 폴더를 하나 만들고!

  • Router.jsx까지 만들어준다.
    이제 라우터 설정을 해보자.

//Router.jsx
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Home from "../pages/Home";
import Dex from "../pages/Dex";
import PokemonDetail from "../pages/PokemonDetail";

function AppRouter() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dex" element={<Dex />} />
        <Route path="/pokemon-detail" element={<PokemonDetail />} />
      </Routes>
    </Router>
  );
}

export default AppRouter;
  • 초기설정은 이렇게 진행했다.
    이후 설정한 라우터 파일을 App,jsx에 임포트한다.
//App.jsx
import AppRouter from "./shared/Router";

function App() {
  return <AppRouter />;
}

export default App;
  • 간단하게 완료했다.

1-2. 스타일드 컴포넌트

이건 처음해보는거라서 처음에는 생소했다.
그런데 하다보니까 너무 편안하고 좋았다.
이것 또한 마찬가지로 패키지를 먼저 설치해준다.

npm install styled-components

패지키 설치가 완료되면 폴더를 하나 만들자!

  • 해당 파일에 맞게 이름을 지정해주고,

//HomeStyles.js
import styled from "styled-components";

const MainPage = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100vh;
`;

const PokemonLogo = styled.img`
  width: 600px;
  margin-bottom: 20px;
`;

const MainButton = styled.button`
  padding: 10px 20px;
  font-size: 18px;
  cursor: pointer;
  border-radius: 5px;
  background-color: rgb(255, 0, 0);
  color: white;
  border: none;
  transition: background-color 0.3s;
  &:hover {
    background-color: rgb(204, 0, 0);
  }
`;

export { MainPage, PokemonLogo, MainButton };
  • 이와 같이 스타일링을 해준 후에,

//Home.jsx
import { useNavigate } from "react-router-dom";
import { MainButton, MainPage, PokemonLogo } from "../styles/HomeStyles";

const Home = () => {
  const nav = useNavigate();
  return (
    <MainPage>
      <PokemonLogo src="../public/pokemon-logo-RN0wntMB.png" />
      <MainButton
        onClick={() => {
          nav("/dex");
        }}
      >
        포켓몬 도감 시작하기
      </MainButton>
    </MainPage>
  );
};
>
export default Home;
  • 이런식으로 로직을 작성해주면 끝이다.

  • 스타일 컴포넌트로 인해 상당한 시간을 벌었다.
    className을 정해주지 않아도 됐고,
    매 번 해당 요소를 찾아서 스타일링하지 않아도 됐기 때문이다.

  • 다음으로 넘어가자.


2. 기본 컴포넌트 구성 및 렌더링

2-1. 기본 컴포넌트 구성

컴포넌트 구조는 다음과 같다.

  • 이미 로드맵에서 컴포넌트 이름도 정해줬기 때문에 구조만 나누면 됐다.

  • 이제 MOCK_DATA를 활용하여 리스트를 출력해보자.


2-2. 리스트 출력

//PokemonList.jsx
import PokemonCard from "./PokemonCard";
import MOCK_DATA from "../data/MOCK_DATA";
import { PokemonListMain } from "../styles/PokemonListStyles";

const PokemonList = () => {
  return (
    <PokemonListMain>
      {MOCK_DATA.map((pokemon) => (
        <PokemonCard
          key={pokemon.id}
          id={pokemon.id}
          korean_name={pokemon.korean_name}
          img_url={pokemon.img_url}
        />
      ))}
    </PokemonListMain>
  );
};

export default PokemonList;
  • MOCK_DATAmap을 활용하여PokemonCard형태로 뿌려준다.

  • 아주 잘 나온다.

3. 추가, 삭제 및 알림기능

이제 기능을 구현해보자.

  1. 현재 프로젝트는 props-drilling형태이기 때문에
    목적에 맞게 Dex.jsx에서 상태를 관리해야한다.
  2. Dashborad에는 초기값으로 몬스터볼 6개가 출력되어야 한다.
  3. 대시보드에 카드를 추가 했을때, 카드 형태로 추가되어야한다.
  4. 대시보드에 추가된 카드는 버튼이 삭제로 바뀌어야 한다.
  5. 같은 카드는 추가되지 않게 한다.
  6. 최대 6개의 카드까지만 추가되게 한다.
  7. 삭제버튼을 눌렀을시 해당 카드는 삭제되며, 다시 몬스터볼이 출력되어야 한다.

위 조건들에 맞게 구현해보자.


3-1. 대시보드 컴포넌트

일단 대시보드 컴포넌트를 살펴보자.

//Dashboard.jsx
import {
  DashboardImg,
  DashboardItem,
  DashboardList,
  DashboardMain,
  DashboardTitle,
} from "../styles/DashboardStyles";
import PokemonCard from "./PokemonCard";

const Dashboard = ({ selectedPokemon, handleRemovePokemon }) => {
  return (
    <DashboardMain>
      <DashboardTitle>나만의 포켓몬</DashboardTitle>
      <DashboardList>
        {selectedPokemon.map((pokemon) => (
          <PokemonCard
            key={pokemon.id}
            id={pokemon.id}
            korean_name={pokemon.korean_name}
            img_url={pokemon.img_url}
            selectedPokemon={selectedPokemon}
            handleRemovePokemon={handleRemovePokemon}
            onDashboard={true}
          />
        ))}
        {Array.from({ length: 6 - selectedPokemon.length }).map((_, index) => (
          <DashboardItem key={`empty-${index}`}>
            <DashboardImg src={"../public/pokeball-13iwdk7Y.png"} />
          </DashboardItem>
        ))}
      </DashboardList>
    </DashboardMain>
  );
};

export default Dashboard;
  • Dashboard의 로직은 다음과 같다.
    여기서 한 가지 로직을 자세히 살펴보겠다.

{Array.from({ length: 6 - selectedPokemon.length }).map((_, index) => (
          <DashboardItem key={`empty-${index}`}>
            <DashboardImg src={"../public/pokeball-13iwdk7Y.png"} />
          </DashboardItem>
        ))}
  • 위 로직은 몬스터볼 개수를 셋팅 해주는 로직이다.
    코드를 하나씩 뜯어보자.

  • Array.from({ length: 6 - selectedPokemon.length }):는 정해진 길이만큼의 배열을 생성하는 방법이다.
    고정된 6개의 슬롯을 유지하기 위해 남은 빈 슬롯의 수만큼 반복 렌더링할 배열을 생성해야하기 때문에 사용했다.

  • 6 - selectedPokemon.length로 현재 포켓몬 수를 뺀 빈 슬롯의 수를 계산한다.

  • .map((_, index) => ( ... )): map함수에서 (_, index) =>_의미 없는 변수로, 해당 값이 필요하지 않다는 표시다.


이제 대시보드를 살펴보자.

  • 출력이 잘 된다.

3-2. 추가, 삭제 및 알림

props-drilling형태이기 때문에 Dex.jsx에 상태관리 로직을 구현해줬다.

//Dex.jsx
import { useState } from "react";
import Dashboard from "../components/Dashboard";
import PokemonList from "../components/PokemonList";
import { DexMain } from "../styles/DexStyles";
import { toast } from "react-toastify";

const Dex = () => {
  const [selectedPokemon, setSelectedPokemon] = useState([]);

  const handelAddPokemon = (pokemon) => {
    if (selectedPokemon.some((p) => p.id === pokemon.id)) {
      toast.info("이미 추가된 포켓몬입니다.");
      return;
    }

    if (selectedPokemon.length > 5) {
      toast.info("최대 6개까지만 추가할 수 있습니다.");
      return;
    }
    setSelectedPokemon([...selectedPokemon, pokemon]);
  };

  const handleRemovePokemon = (id) => {
    setSelectedPokemon(selectedPokemon.filter((p) => p.id !== id));
  };

  return (
    <DexMain>
      <Dashboard
        selectedPokemon={selectedPokemon}
        handleRemovePokemon={handleRemovePokemon}
      />
      <PokemonList
        selectedPokemon={selectedPokemon}
        handelAddPokemon={handelAddPokemon}
        handleRemovePokemon={handleRemovePokemon}
      />
    </DexMain>
  );
};

export default Dex;
  • 이후 각각 필요한 요소들을 각 컴포넌트에 props로 전달해준다.

Tip. toast는 UI 라이브러리입니다.

npm install react-toastify

  • 이렇게 설치하신 후,
// App.js
import React from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

function App() {
  return (
    <div>
      {/* 다른 컴포넌트들 */}
      <ToastContainer />
    </div>
  );
}

export default App;
  • 최상위 컴포넌트에서 셋팅해준다음,

import { toast } from 'react-toastify';

function SomeComponent() {
  const notify = () => {
    toast("이것은 알림입니다!");
  };

  return (
    <button onClick={notify}>알림 띄우기</button>
  );
}
  • 다른컴포넌트에서 이와 같이 사용하시면 됩니다.

이어서,

//PokemonList.jsx
import PokemonCard from "./PokemonCard";
import MOCK_DATA from "../data/MOCK_DATA";
import { PokemonListMain } from "../styles/PokemonListStyles";

const PokemonList = ({ handelAddPokemon, handleRemovePokemon }) => {
  return (
    <PokemonListMain>
      {MOCK_DATA.map((pokemon) => (
        <PokemonCard
          key={pokemon.id}
          id={pokemon.id}
          korean_name={pokemon.korean_name}
          img_url={pokemon.img_url}
          handelAddPokemon={handelAddPokemon}
          handleRemovePokemon={handleRemovePokemon}
          onDashboard={false}
        />
      ))}
    </PokemonListMain>
  );
};

export default PokemonList;
  • PokemonList에서 이벤트 작동함수는 필요가 없지만 PokemonCard에서 해당 함수가 필요하기 때문에 다시금 props로 두 가지 함수를 넘겨준다.

//PokemonCard.jsx
import { useNavigate } from "react-router-dom";
import {
  PokemonCardButton,
  PokemonCardImg,
  PokemonCardInfoWrap,
  PokemonCardItem,
  PokemonCardName,
  PokemonCardNumber,
} from "../styles/PokemonCardStyles";

const PokemonCard = ({
  id,
  korean_name,
  img_url,
  handelAddPokemon,
  handleRemovePokemon,
  onDashboard,
}) => {
  const nav = useNavigate();
  const formattedId = id.toString().padStart(3, "0");

  const handleButtonAdd = (e) => {
    e.stopPropagation();
    if (onDashboard) {
      handleRemovePokemon(id);
    } else {
      handelAddPokemon({ id, korean_name, img_url });
    }
  };

  return (
    <PokemonCardItem
      onClick={() => {
        nav(`/pokemon-detail/${id}`);
      }}
    >
      <PokemonCardImg src={img_url} />
      <PokemonCardInfoWrap>
        <PokemonCardName>{korean_name}</PokemonCardName>
        <PokemonCardNumber>No. {formattedId}</PokemonCardNumber>
      </PokemonCardInfoWrap>
      <PokemonCardButton onClick={handleButtonAdd}>
        {onDashboard ? "삭제" : "추가"}
      </PokemonCardButton>
    </PokemonCardItem>
  );
};

export default PokemonCard;
  • props로 넘겨받은 함수를 바탕으로 로직을 작성해준다.
    코드를 살펴보자.

  • e.stopPropagation() : PokemonCardItemonClick속성이 줬기 때문에 e.stopPropagation()을 추가하여 이벤트 전파를 막아줬다.

  • onDashboard : 대시보드에 올라가 있는지 여부를 판단한다.
    초기값은 false로 이 조건에 따라 버튼이 추가 또는 삭제로 나타난다.
    참고 : Dashboard에서 카드가 렌더링될때 true (대시보드 로직참고)

  • const formattedId = id.toString().padStart(3, "0") : 숫자를 001, 091, 151 이런식으로 표현해준다.


이제 결과물을 보도록 하자.

  • 잇츠, 구우우우우우우웃!

4. 디테일 페이지

디테일 페이지를 만들어보자.

우선 Router.jsx의 로직을 살펴보겠다.

//Router.jsx
<Route path="/pokemon-detail/:id" element={<PokemonDetail />} />
  • 느낌이 오는가?

다음은 PokemonCard.jsx의 로직중 하나다.

//PokemonCard.jsx
<PokemonCardItem
      onClick={() => {
        nav(`/pokemon-detail/${id}`);
      }}
  >
  • 느낌이 오는가??????????
  • 이 정도면 느낌이 와야한다.

여기서 하나만 짚고 넘어가자.

  • Router.jsx에서 <Route path="/pokemon-detail/:id" element={<PokemonDetail />} />로 라우터 설정을 해줬기 때문에,
    PokemonCard.jsx에서 useNavigate를 활용한 이동이 가능한것이다.

  • useNavigate는 단순한 경로 이동만을 담당한다.
    라우터 설정이 없으면 해당 컴포넌트를 찾질 못한다.
    꼭 기억해둘것.


//PokemonDetail.jsx
import { useParams, useNavigate } from "react-router-dom";
import MOCK_DATA from "../data/MOCK_DATA";
import {
  DetailButton,
  DetailImg,
  DetailInfo,
  DetailMain,
  DetailTitle,
} from "../styles/PokemonDetailStyles";

const PokemonDetail = () => {
  const { id } = useParams();
  const nav = useNavigate();

  const selectedPokemon = MOCK_DATA.find(
    (pokemon) => pokemon.id === Number(id)
  );

  return (
    <DetailMain>
      <DetailImg src={selectedPokemon.img_url} />
      <DetailTitle>{selectedPokemon.korean_name}</DetailTitle>
      <DetailInfo>타입 : {selectedPokemon.types.join(", ")}</DetailInfo>
      <DetailInfo>{selectedPokemon.description}</DetailInfo>
      <DetailButton
        onClick={() => {
          nav(-1);
        }}
      >
        뒤로 가기
      </DetailButton>
    </DetailMain>
  );
};

export default PokemonDetail;
  • 위 로직 중 가장 중요하다고 생각되는 로직인const { id } = useParams()에 대해 잠시 살펴보자.

useParams와 로직에 대한 이해

  • useParamsReact Router에서 제공하는 으로, 현재 URL에 포함된 매개변수 값을 객체 형태로 반환한다.

  • useParams는 주로 Route컴포넌트에 동적 경로 (예: /pokemon/:id)가 포함된 경우, 그 경로에서 특정 값을 추출하는 데 사용된다.

  • :id : :를 사용하면 :뒤는 동적으로 변하는 값이라는것을 나타낸다.
    :로 설정하지 않으면 추출불가

따라서, const { id } = useParams() 로직은 다음과 같다.

  • 현재 URL주소(pokemon-detail/:id)에 동적으로 오게되는 id값을 추출한다.

추가적인 Tip.

1. 여러 파라미터가 있을 때

만약 라우트 경로가 /pokemon-detail/:id/:name와 같이 여러 개의 동적 파라미터가 포함된 경우라면,

const params = useParams();
// 예를 들어 URL이 http://localhost:5173/pokemon-detail/2/pikachu일 경우
params = { id: "2", name: "pikachu" };

2. 구조 분해 할당을 하지 않는경우

const params = useParams();
  • 이렇게 하면 params에는 useParams가 반환하는 객체 전체가 들어가게 된다.

// 구조분해할당 하지 않은경우
const params = useParams();
const id = params.id;
const id = useParams().id;

params = { id: "2" };
console.log(params.id); // "2"
console.log(params.name); // "pikachu"

// 구조분해할당
const { id, name } = useParams();
console.log(id);   // "2"
console.log(name); // "pikachu"
  • 취향껏 쓰면 된다고 생각하지만, 구조분해할당👍

이제 결과물을 보도록 하자.

  • 아주 잘 나온다.

5. 한 가지 아쉬운점이 있다면?

  • 기껏 추가했는데, 클릭을 한 번 잘못하면 대쉬보드가 초기화 된다.

  • 로컬스토리지로 해결해볼까 했지만 관뒀다.
    이것은 차후에 해결해보도록 하자.


회고

  • 솔직히 숙련주차 강의를 보면서 엄청 쫄아있었다.
    내가 이거를 활용해서 과제를 할 수 있을까??
    이런 걱정이 더 많이 들었었기 때문이다.

  • 그런데 막상 과제에 부딪혀보니 몰랐던 개념들에 대해 더 많이 이해 할 수 있었고, 특히나 리액트 훅의 적절한 사용법을 아는데 도움이 많이 됐다.

  • 과제 내주신 튜터님 정말 감사합니다.

  • 이제 ContextAPI로 리팩토링하는것과 선택구현사항이 남았다.
    끝까지 잘해내봅시다! 화이팅!

그럼이만

0개의 댓글