배포 링크 : https://pokemon-dex-theta.vercel.app/
디자인은 pokerogue의 색감을 참고해서 조금 바꾸고, 폰트도 1세대의 느낌을 비슷하게 주기 위해 픽셀 폰트로 변경했다.
📦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 폴더를 따로 만들어 관리했다.
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 프로퍼티에서 전달된 데이터를 얻을 수 있다.
//`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가 이를 감지하여 불변성을 보장한다.
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가지 버전의 상태 관리를 각각 구현해보고 차이를 학습하였다.
전역 상태 관리 라이브러리나 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 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 탈출하고 신세계를 체험✨
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), 즉 호출해준다.
와 개쩐다 역시 SUN님 ^-^)b