내일배움캠프 React_7기 TIL - 26. 숙련주차 개인과제 - Pokemon Dex

·2024년 11월 12일
1


배포 링크 : https://pokemon-dex-theta.vercel.app/

Pokemon Dex 과제를 끝 냈 습 니 다.

디자인은 pokerogue의 색감을 참고해서 조금 바꾸고, 폰트도 1세대의 느낌을 비슷하게 주기 위해 픽셀 폰트로 변경했다.

File Tree

📦pokemon-dex
 ┣ 📂node_modules
 ┣ 📂public
 ┃ ┗ 📂assets
 ┃ ┃ ┣ 📂fonts
 ┣ 📂src
 ┃ ┣ 📂components
 ┃ ┃ ┣ 📜Button.jsx
 ┃ ┃ ┣ 📜Dashboard.jsx
 ┃ ┃ ┣ 📜PokemonCard.jsx
 ┃ ┃ ┣ 📜PokemonList.jsx
 ┃ ┃ ┗ 📜PokemonSelectList.jsx
 ┃ ┣ 📂data
 ┃ ┃ ┗ 📜PokemonMockData.js
 ┃ ┣ 📂features
 ┃ ┃ ┗ 📜pokemonSlice.js
 ┃ ┣ 📂pages
 ┃ ┃ ┣ 📜Detail.jsx
 ┃ ┃ ┣ 📜Dex.jsx
 ┃ ┃ ┗ 📜Home.jsx
 ┃ ┣ 📂shared
 ┃ ┃ ┗ 📜Router.jsx
 ┃ ┣ 📂store
 ┃ ┃ ┗ 📜store.js
 ┃ ┣ 📜App.css
 ┃ ┣ 📜App.jsx
 ┃ ┣ 📜Globalstyles.js
 ┃ ┣ 📜index.css
 ┃ ┗ 📜main.jsx
 ┣ ...

redux로 상태관리까지 한 상태의 폴더 구조이다.

 	┣ 📦context
 	┗ 📜PokemonContext.jsx

context API로 상태관리 했을 땐 context 폴더를 따로 만들어 관리했다.

react-route-dom

import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "../pages/Home";
import Dex from "../pages/Dex";
import Detail from "../pages/Detail";

export default function Router() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dex" element={<Dex />} />
        <Route path="/detail/:id" element={<Detail />} />
      </Routes>
    </BrowserRouter>
  );
}

react-route-dom을 통해 페이지 라우팅을 구현했다.
<Router> 컴포넌트를 사용하여 각 페이지에 대한 Routes와 Route를 설정하고, App.jsx에서 <Router> 컴포넌트를 렌더링한다.

function App() {
  return (
    <PokemonProvider>
      {/* 글로벌 폰트 적용 */}
      <GlobalStyles />
      <Router />
    </PokemonProvider>
  );
}
function PokemonCard({ pokemon, buttonType }) {
...

  return (
    <Link to={`/detail/${pokemon.id}`} state={{ pokemon }}>
     ...

        {buttonType !== "delete" ? (
          <Button
            text={"추가"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              handleSelectPokemon(pokemon);
            }}
          />
        ) : (
          <Button
            text={"삭제"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              handleRemovePokemon(pokemon);
            }}
          />
        )}
      </StyledCard>
    </Link>
  );
}



function Detail() {
  const location = useLocation();
  const navigate = useNavigate();
  const pokemon = location.state?.pokemon;
  const handleBackClick = () => {
    navigate(-1); // 이전 페이지로 이동
  };

  if (!pokemon) return <p>포켓몬 데이터를 찾을 수 없습니다.</p>;

  return (
    <StyledDetailContainer>
      <img src={pokemon.img_url} alt={pokemon.korean_name} />
      <h2>{pokemon.korean_name}</h2>
      <p>타입 : {pokemon.types.join(" , ")}</p>
      <p>{pokemon.description}</p>
      <Button text={"뒤로 가기"} onClick={handleBackClick}></Button>
    </StyledDetailContainer>
  );
}


PokemonCard 컴포넌트에서, Link를 통해 포켓몬 id값에 해당하는 detail 페이지로 라우팅되도록 했다. Link로 state를 전달해 detail 페이지로 해당 포켓몬 데이터를 넘겨줬다. detail 컴포넌트에서는 useLocation을 사용해 받은 포켓몬 데이터를 가져올 수 있다.

Link로 전달된 state와 URL
Link로 state를 전달하면, 전달한 값이 브라우저의 내부 상태(뒤로 가기 등을 처리하는데 사용되는 히스토리 상태)로 저장된다.
useLocation을 사용해 Link에서 전달된 state에 접근할 수 있다. useLocation은 URL의 상태 정보를 담고 있는 객체를 반환하는데, 그 중 state 프로퍼티에서 전달된 데이터를 얻을 수 있다.

포켓몬 추가/삭제 및 알림 기능

addPokemon(추가), removePokemon

//`PokemonSilce.js`
import { createSlice } from "@reduxjs/toolkit";
import Swal from "sweetalert2";

// 초기 상태
const initialState = {
  selectedPokemons: [],
};

const pokemonSlice = createSlice({
  name: "pokemon",
  initialState,
  reducers: {
    // 포켓몬 추가 액션
    addPokemon: (state, action) => {
      const pokemon = action.payload;

      // 6마리 이상 선택된 경우
      if (state.selectedPokemons.length >= 6) {
        ...
      }

      // 이미 선택된 포켓몬인 경우
      if (
        state.selectedPokemons.some((selected) => selected.id === pokemon.id)
      ) {
        ...
      }

      state.selectedPokemons.push(pokemon);
    },

    // 포켓몬 삭제 액션
    removePokemon: (state, action) => {
      const pokemon = action.payload;
      state.selectedPokemons = state.selectedPokemons.filter(
        (poke) => poke.id !== pokemon.id
      );
    },
  },
});

export const { addPokemon, removePokemon } = pokemonSlice.actions;

export default pokemonSlice.reducer;

로직의 경우 간단하다. pokemon을 받고 조건에 부합하면 상태에 추가하거나 삭제한다.
redux Toolkit을 사용하지 않았다면, 상태의 불변성을 유지해야만 해서 코드가 약간 다르다.

//props-drilling 과 같이 redux toolkit을 사용하지 않았을 때

const handleSelectPokemon = (pokemon) => {
    setSelectedPokemon(
      (prevList) => {
        if (prevList.length >= 6) {
         ...
          return prevList;
        }
        if (
          prevList.some((selected) => selected.id === pokemon.id)
        ) {
          ...
          return prevList;
        }
        return [...prevList, pokemon];
      }
    );
  };

  const handleRemovePokemon = (pokemon) => {
    setSelectedPokemon((prevList) => {
      return prevList.filter((selected) => selected.id !== pokemon.id);
    });
  };

redux Toolkit을 사용하지 않을 땐,return [...prevList, pokemon]; 와 같이 상태값을 직접 변경하지 않는다. 상태의 불변성이 유지되지 않으면 React가 변경을 감지하기 어렵기 때문이다.
하지만 redux Toolkit은 내부적으로 Immer을 사용하여 상태 관리 중 불변성을 자동으로 처리해주기 때문에 직접 불변성을 관리할 필요가 없다. 상태를 직접 수정하는 방식(push, filter 등)을 사용해도 Immer가 이를 감지하여 불변성을 보장한다.

SweetAlert으로 알림기능 구현

SweetAlert2를 사용하여, 동일한 포켓몬이 선택되었거나, 6마리 이상이 선택되었을 때 알럿창이 뜨도록 구현했다. (매우 간단)

//`PokemonSilce.js`
import Swal from "sweetalert2";
....(생략)
addPokemon: (state, action) => {
      const pokemon = action.payload;

      // 6마리 이상 선택된 경우
      if (state.selectedPokemons.length >= 6) {
        Swal.fire({
          icon: "error",
          text: "포켓몬은 6마리까지 선택 가능합니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return;
      }

      // 이미 선택된 포켓몬인 경우
      if (
        state.selectedPokemons.some((selected) => selected.id === pokemon.id)
      ) {
        Swal.fire({
          icon: "error",
          text: "이미 선택된 포켓몬입니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return;
      }

      state.selectedPokemons.push(pokemon);
    },

Swal을 import하고, Swal.fire()로 알럿창을 부르면 된다. icon 또는 css 커스텀이 어느정도 가능하여 유용하게 사용할 수 있다.

상태관리

이번 프로젝트에서는 3가지 버전의 상태 관리를 각각 구현해보고 차이를 학습하였다.

props-drilling

전역 상태 관리 라이브러리나 API없이 App.jsx에서 포켓몬 상태, 추가 및 삭제 로직을 관리하고 자식 컴포넌트로 전달 또 전달하도록 했다.
Router 컴포넌트나 Dex 컴포넌트에서 관리하지 않은 이유는 페이지를 이동하거나 새로고침해도 선택된 포켓몬이 남아있게 하기 위함이였다.

그리고 생각해보니 MOCK_DATA는 PokemonList에만 전달했으면 됐을 텐데... App.js에 넣어서 계속 props-drilling을 했네...🤔
다른 브랜치에선 수정해놨지만 여기는 깜빡했군요...

function App() {
  const [selectedPokemon, setSelectedPokemon] = useState([]);

  const handleSelectPokemon = (pokemon) => {
    setSelectedPokemon(
      //함수형 업데이트 방식. 자동으로 그 함수의 첫번째 인자로 현재 상태 값 넘김
      (prevList) => {
        if (prevList.length >= 6) {
          //6마리까지만 포켓몬을 받음
          Swal.fire({
            icon: "error",
            text: "포켓몬은 6마리까지 선택 가능합니다.",
            confirmButtonColor: "rgb(185, 23, 23)",
            confirmButtonText: "확인",
          });
          return prevList;
        }
        if (
          // 중복되면
          prevList.some((selected) => selected.id === pokemon.id)
        ) {
          Swal.fire({
            icon: "error",
            text: "이미 선택된 포켓몬입니다.",
            confirmButtonColor: "rgb(185, 23, 23)",
            confirmButtonText: "확인",
          });
          return prevList;
        }
        return [...prevList, pokemon];
      }
    );
  };

  const handleRemovePokemon = (pokemon) => {
    setSelectedPokemon((prevList) => {
      return prevList.filter((selected) => selected.id !== pokemon.id);
    });
  };
  return (
    <>
      {/* 글로벌 폰트 적용 */}
      <GlobalStyles />
      <Router
        mockData={MOCK_DATA}
        selectedPokemon={selectedPokemon}
        onSelectPokemon={handleSelectPokemon}
        onRemovePokemon={handleRemovePokemon}
      ></Router>
    </>
  );
}

export default App;

...

//PokemonList.jsx

function PokemonList({ mockData, onSelectPokemon }) {
  return (
    <StyledPokemonList>
      {mockData.map((pokemon, i) => (
        <PokemonCard
          key={i}
          pokemon={pokemon}
          onSelectPokemon={onSelectPokemon}
        />
      ))}
    </StyledPokemonList>
  );
}

//PokemonCard.jsx
function PokemonCard({ pokemon, onSelectPokemon, onRemovePokemon }) {
  return (
    <Link to={`/detail/${pokemon.id}`} state={{ pokemon }}>
      <StyledCard>
        <img src={pokemon.img_url} alt={pokemon.korean_name} />
        <h3>{pokemon.korean_name}</h3>
        <p>{"NO." + pokemon.id.toString().padStart(3, "0")}</p>
        {onSelectPokemon ? (
          <Button
            text={"추가"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation(); // Link 이벤트 막기
              onSelectPokemon(pokemon);
            }}
          />
        ) : (
          <Button
            text={"삭제"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation(); // Link 이벤트 막기
              onRemovePokemon(pokemon);
            }}
          />
        )}
      </StyledCard>
    </Link>
  );
}

이렇게 계속 계속 무한으로 props를 내린다... 이것이 props-drilling...

context

Context API는 React에서 상위 컴포넌트에서 하위 컴포넌트로 데이터를 props를 통해 전달하는 대신, 글로벌한 상태를 관리하고 공유할 수 있도록 도와주는 기능이다.

import { createContext, useState } from "react";
import Swal from "sweetalert2";

const PokemonContext = createContext();

export const PokemonProvider = ({ children }) => {
  const [selectedPokemon, setSelectedPokemon] = useState([]);

  const handleSelectPokemon = (pokemon) => {
    setSelectedPokemon((prevList) => {
      if (prevList.length >= 6) {
        Swal.fire({
          icon: "error",
          text: "포켓몬은 6마리까지 선택 가능합니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return prevList;
      }
      if (prevList.some((selected) => selected.id === pokemon.id)) {
        Swal.fire({
          icon: "error",
          text: "이미 선택된 포켓몬입니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return prevList;
      }
      return [...prevList, pokemon];
    });
  };

  const handleRemovePokemon = (pokemon) => {
    setSelectedPokemon((prevList) =>
      prevList.filter((selected) => selected.id !== pokemon.id)
    );
  };

createContext()로 새로운 Context 객체를 생성한다. 이 객체는 상태를 제공하고 소비하는 기능을 가지고 있다.

Context상태와 로직을 관리하는 PokemonProvider 컴포넌트를 만들었다. 그리고 컴포넌트 내에서 포켓몬 상태와 선택 및 삭제 로직을 관리한다.

return (
  <PokemonContext.Provider
    value={{ selectedPokemon, handleSelectPokemon, handleRemovePokemon }}
  >
    {children}
  </PokemonContext.Provider>
);
export default PokemonContext;

PokemonContext(context객체).Provider 로 하위 컴포넌트들에 상태와 함수를 전달하도록 한다. value 프로퍼티로 상태와 로직들을 하위컴포넌트에 전달한다. {children}안에서 Context 값을 소비할 수 있다.

import "./App.css";
import GlobalStyles from "./Globalstyles.js";
import Router from "./shared/Router";

import { PokemonProvider } from "./context/PokemonContext.jsx";

function App() {
  return (
    <PokemonProvider>
      {/* 글로벌 폰트 적용 */}
      <GlobalStyles />
      <Router />
    </PokemonProvider>
  );
}

export default App;

PokemonProvider를 최상위 컴포넌트로 설정하여 하위 컴포넌트들에 상태와 로직을 전달한다.

function PokemonCard({ pokemon, buttonType }) {
  const { handleSelectPokemon, handleRemovePokemon } =
    useContext(PokemonContext);

  return (
    <Link to={`/detail/${pokemon.id}`} state={{ pokemon }}>
      <StyledCard>
        <img src={pokemon.img_url} alt={pokemon.korean_name} />
        <h3>{pokemon.korean_name}</h3>
        <p>{"NO." + pokemon.id.toString().padStart(3, "0")}</p>

        {buttonType !== "delete" ? (
          <Button
            text={"추가"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              handleSelectPokemon(pokemon);
            }}
          />
        ) : (
          <Button
            text={"삭제"}
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              handleRemovePokemon(pokemon);
            }}
          />
        )}
      </StyledCard>
    </Link>
  );
}

useContext를 사용하여 하위 컴포넌트에서 Context를 꺼내 쓰면 된다.
props-drilling 탈출하고 신세계를 체험✨

Redux

slice는 상태와 그 상태를 업데이트할 액션(즉, 리듀서)을 정의하는 단위이다. 이 slice를 정의한 후, 이를 store에 등록하면 애플리케이션 전체에서 해당 상태와 액션을 전역적으로 사용할 수 있게 된다.

import { createSlice } from "@reduxjs/toolkit";
import Swal from "sweetalert2";

// 초기 상태
const initialState = {
  selectedPokemons: [],
};

const pokemonSlice = createSlice({
  name: "pokemon",
  initialState,
  reducers: {
    // 포켓몬 추가 액션
    addPokemon: (state, action) => {
      const pokemon = action.payload;

      // 6마리 이상 선택된 경우
      if (state.selectedPokemons.length >= 6) {
        Swal.fire({
          icon: "error",
          text: "포켓몬은 6마리까지 선택 가능합니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return;
      }

      // 이미 선택된 포켓몬인 경우
      if (
        state.selectedPokemons.some((selected) => selected.id === pokemon.id)
      ) {
        Swal.fire({
          icon: "error",
          text: "이미 선택된 포켓몬입니다.",
          confirmButtonColor: "rgb(185, 23, 23)",
          confirmButtonText: "확인",
        });
        return;
      }

      state.selectedPokemons.push(pokemon);
    },

    // 포켓몬 삭제 액션
    removePokemon: (state, action) => {
      const pokemon = action.payload;
      state.selectedPokemons = state.selectedPokemons.filter(
        (poke) => poke.id !== pokemon.id
      );
    },
  },
});

export const { addPokemon, removePokemon } = pokemonSlice.actions;

export default pokemonSlice.reducer;

Redux Toolkit을 사용해 포켓몬 선택과 관련된 상태를 관리하는 slice를 정의했다.
createSlice로 name, 초기상태, reducer(상태 업데이트 함수)를 정의해준다.

createSlice : Redux Toolkit에서 제공하는 함수로, 리덕스에서 상태와 그 상태를 변경하는 로직(reducer)을 간편하게 설정할 수 있게 해준다.
(즉, 액션 타입(action type), 액션 생성자(action creator), 리듀서(reducer)를 한 번에 정의!)

위에서 설명했듯, immer가 자동으로 불변성을 관리해준다.
또한 액션 생성자와 타입을 자동으로 생성하니까 코드를 깔끔하게 유지할 수 있다.

import { configureStore } from "@reduxjs/toolkit";
import pokemonSlice from "../features/pokemonSlice";

const store = configureStore({
  reducer: {
    pokemon: pokemonSlice,
  },
});

export default store;

configureStore를 사용해 Redux 스토어를 설정하고 생성했다. 포켓몬과 관련된 상태와 관련된 리듀서 함수들을 포함한 slice를 pokemon이라는 이름의 state로 스토어에 추가한다.

(slice의 reducerd과 store의 reducer의 차이점)

slice에서 reducers를 만들었는데 왜 store에도 reducer을 만들까?

createSlice에서 reducers는 상태 변화 로직을 정의하는 객체이다. 이 객체는 슬라이스 내에서 처리할 수 있는 액션을 정의하고, 각 액션에 대해 상태를 어떻게 변형할지 설정한다.

store에서의 reducer는 애플리케이션 전체의 상태를 관리하는 중앙 집합체이다. 여러 슬라이스에서 정의된 리듀서를 결합하여 하나의 큰 리듀서를 만든 후, 이를 store에 전달한다.
전체 애플리케이션의 상태를 관리하고, 각 슬라이스의 상태를 하나로 결합하여 store에 제공한다는 것이다. 여러 슬라이스 리듀서를 결합한 하나의 리듀서 객체이다.

💡 결론 !!
createSlice의 reducer은 개별 슬라이스에 대한 상태 변경 로직을 정의
store의 reducer은 여러 슬라이스의 리듀서를 결합한 후, 이를 하나의 루트 리듀서로 만들어 store에 전달하여 애플리케이션의 상태를 관리한다.

import { Provider } from "react-redux";
import store from "./store/store.js";

function App() {
  return (
    <Provider store={store}>
      {/* 글로벌 폰트 적용 */}
      <GlobalStyles />
      <Router />
    </Provider>
  );

Provider 컴포넌트는 Redux에서 제공하는 컴포넌트로, 애플리케이션 전역에 Redux store을 공유한다.

store이 만들어졌으니 필요한 컴포넌트에서 전역 상태를 사용하면 된다!

import { Link } from "react-router-dom";
import Button from "./Button";
import styled from "styled-components";
import { useDispatch } from "react-redux";
import { addPokemon, removePokemon } from "../features/pokemonSlice";

...

function PokemonCard({ pokemon, buttonType }) {
  const dispatch = useDispatch();

  const handleAddPokemon = (pokemon) => {
    dispatch(addPokemon(pokemon));
  };

  const handleRemovePokemon = (pokemon) => {
    dispatch(removePokemon(pokemon));
  };

  const handleButtonClick = (e, pokemon) => {
    e.preventDefault();
    e.stopPropagation();
    if (buttonType !== "delete") {
      handleAddPokemon(pokemon);
    } else {
      handleRemovePokemon(pokemon);
    }
  };

  return (
    <StyledCard>
      <Link to={`/detail/${pokemon.id}`} state={{ pokemon }}>
        <StyledImage src={pokemon.img_url} alt={pokemon.korean_name} />
        <h3>{pokemon.korean_name}</h3>
        <p>{"NO." + pokemon.id.toString().padStart(3, "0")}</p>
      </Link>

      <Button
        text={buttonType !== "delete" ? "추가" : "삭제"}
        onClick={(e) => handleButtonClick(e, pokemon)}
      />
    </StyledCard>
  );
}

export default PokemonCard;

useDispatch를 사용해 slice에서 정의한 액션들을 디스패치(dispatch), 즉 호출해준다.

profile
내배캠 React_7기 이수중

1개의 댓글

comment-user-thumbnail
2024년 11월 12일

와 개쩐다 역시 SUN님 ^-^)b

답글 달기

관련 채용 정보