-
Notifications
You must be signed in to change notification settings - Fork 0
[Step3] hippo - 조건부 렌더링 활용 #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: hippo-step2
Are you sure you want to change the base?
Changes from all commits
2c3a052
7424273
8725623
b81345e
d3d11f0
74d9beb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| language: ko-KR | ||
| early_access: false | ||
| reviews: | ||
| profile: "chill" | ||
| request_changes_workflow: false | ||
| high_level_summary: true | ||
| poem: false | ||
| review_status: true | ||
| collapse_walkthrough: false | ||
| auto_review: | ||
| enabled: true | ||
| drafts: false | ||
| chat: | ||
| auto_reply: true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # 03. 모달 UI 구현하기: side-effect(feat. event handler) | ||
|
|
||
| ## 🎯 요구 사항 | ||
| - `RestaurantList` 의 아이템을 클릭하면, 클릭한 아이템의 정보를 보여주는 모달이 뜨도록 변경해 주세요. '확인' 버튼을 클릭하거나 모달 뒤의 backdrop을 클릭하면 모달이 닫혀야 합니다. | ||
| - (작은 단계로 구현해보기 1) 아이템을 클릭하면 정해진 텍스트를 그대로 보여주는 모달을 열고 닫습니다. | ||
| - (작은 단계로 구현해보기 2) 클릭한 아이템의 정보를 모달에 내려줄 수 있도록 개선합니다. | ||
|
|
||
| ### 구현 결과 예시 | ||
| ```javascript | ||
| // App.jsx | ||
| {isModalOpen && <RestaurantDetailModal {/** 적절한 props */}/>} | ||
| ``` | ||
|
|
||
| ## ✅ 키워드 | ||
| - event handler (feat. side effect) | ||
| - conditional rendering | ||
| - lifting state up | ||
|
|
||
| ## 🧙♀️ 진행 가이드 | ||
| - 진행 시간: 1시간 내에 완료하는 것을 목표로 합니다. | ||
|
|
||
| ## 🔗 참고 문서 | ||
| - [Thinking in React](https://react.dev/learn/thinking-in-react)의 Step5 | ||
| - [Responding to Events](https://react.dev/learn/responding-to-events) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,93 +1,130 @@ | ||
| # Props와 State | ||
| # 조건부 렌더링 활용 | ||
|
|
||
| ## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드 | ||
|
|
||
| 이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다. | ||
|
|
||
| 1. Props를 통해 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 방식을 이해한다. | ||
| 2. State를 사용해 컴포넌트 내부의 동적 상태를 관리한다. | ||
| 3. Props와 State를 조합하여 부모-자식 간 양방향 데이터 흐름을 구현한다. | ||
| 1. 이벤트 핸들러를 통해 사용자 인터랙션에 반응하는 방법을 이해한다. | ||
| 2. 조건부 렌더링(`&&`)을 활용해 상황에 따라 컴포넌트를 보여주고 숨긴다. | ||
| 3. 어떤 값을 state로 선언해야 하는지 기준을 세운다. | ||
|
|
||
| --- | ||
|
|
||
| ## 📝 기능 구현 목록 | ||
|
|
||
| - [x] RestaurantList에 restaurants 배열을 props로 전달 | ||
| - [x] 배열 데이터를 map()으로 동적 렌더링 | ||
| - [x] 각 리스트 항목에 key prop 추가 | ||
| - [x] CategoryFilter에서 선택된 카테고리 상태 관리 | ||
| - [x] App 컴포넌트에서 카테고리별 필터링 로직 구현 | ||
| - [x] 필터된 데이터를 RestaurantList에 props로 전달 | ||
| - [x] 카테고리별 동적 이미지 매핑 | ||
| - [x] 음식점 아이템 클릭 시 모달 열기 | ||
| - [x] 닫기 버튼 또는 backdrop 클릭 시 모달 닫기 | ||
| - [x] 클릭한 음식점 정보를 모달에 전달하여 표시 | ||
|
|
||
| --- | ||
|
|
||
| ## 📚 학습 내용 | ||
|
|
||
| ### Props (속성) | ||
| - 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 메커니즘이다. | ||
| - 자식 컴포넌트 함수의 매개변수로 받는다: `function Component({ prop1, prop2 }) { }` | ||
| - Props는 읽기 전용이므로 자식에서 직접 수정할 수 없다. | ||
|
|
||
| ### State (상태) | ||
| - 컴포넌트 내부에서 변경 가능한 데이터를 관리한다. | ||
| - `useState` 훅으로 선언한다: `const [state, setState] = useState(초기값)` | ||
| - State가 변경되면 컴포넌트가 리렌더링된다. | ||
| - State는 각 컴포넌트 인스턴스마다 독립적으로 존재한다. | ||
|
|
||
| ### Props vs State | ||
| - Props: 부모 → 자식, 읽기 전용 | ||
| - State: 컴포넌트 내부, 변경 가능 | ||
| - State를 변경하려면 setter 함수(`setState`)를 사용한다. | ||
|
|
||
| ### 배열 렌더링과 Key | ||
| - 배열을 렌더링할 때 `map()` 메서드를 사용한다. | ||
| - 각 항목에 고유한 `key` prop을 부여해야 한다. | ||
| - key는 React가 어떤 항목이 변경/추가/삭제되었는지 식별하는 데 사용된다. | ||
| - 안정적인 고유값(예: id)을 key로 사용하고, index는 피한다. | ||
|
|
||
| ### 동적 데이터 매핑 | ||
| - 객체를 사용해 카테고리와 이미지 등을 매핑할 수 있다. | ||
| - `const CATEGORY_IMAGES = { "한식": koreanImg, ... }` | ||
| - 필요한 값을 동적으로 조회: `CATEGORY_IMAGES[restaurant.category]` | ||
| ### 이벤트 핸들러와 데이터 전달 | ||
|
|
||
| --- | ||
| 이벤트 핸들러에 함수를 직접 연결하면 React가 이벤트 객체(`e`)를 자동으로 넘겨준다. 클릭된 아이템의 데이터를 함께 전달하려면 화살표 함수로 한 번 감싸야 한다. | ||
|
|
||
| ## 🤔 고민했던 문제와 해결 과정에서 배운 점 | ||
| ```jsx | ||
| // 이벤트 객체(e)만 전달됨 | ||
| onClick={onRestaurantClick} | ||
|
|
||
| // 클릭된 restaurant 데이터를 직접 전달 | ||
| onClick={() => onRestaurantClick(restaurant)} | ||
| ``` | ||
|
|
||
| `() => onRestaurantClick(restaurant)`는 `map` 순회 중인 `restaurant`를 부모까지 전달하는 역할을 한다. | ||
|
|
||
| ### 조건부 렌더링 | ||
|
|
||
| `{condition && <Component />}` 패턴으로 조건이 참일 때만 컴포넌트를 렌더링한다. | ||
|
|
||
| ```jsx | ||
| {clickedRestaurant && ( | ||
| <RestaurantDetailModal restaurant={clickedRestaurant} onClose={handleModalClose} /> | ||
| )} | ||
| ``` | ||
|
|
||
| ### State로 선언할 것과 아닌 것 | ||
|
|
||
| **State가 필요한 경우**: 시간이 지나면서 변하고, 렌더링에 영향을 주며, 다른 state나 props로부터 계산할 수 없는 값 | ||
|
|
||
| **State가 불필요한 경우**: 기존 state나 props로부터 계산 가능한 파생값(derived value) | ||
|
|
||
| ```jsx | ||
| // category state에서 계산 가능 → state 불필요 | ||
| const filteredRestaurants = filterRestaurants(RESTAURANTS, category); | ||
|
|
||
| // clickedRestaurant state에서 계산 가능 → state 불필요 | ||
| const isRestaurantDetailModalOpen = !!clickedRestaurant; | ||
| ``` | ||
|
|
||
| 파생 변수는 state와 달리 setter가 없어 동기화 버그가 생기지 않고, 렌더링마다 자동으로 최신값을 계산한다. | ||
|
|
||
| ### `&&` 조건부 렌더링 주의사항 | ||
|
|
||
| ### Controlled Component의 필요성 | ||
| 처음엔 CategoryFilter가 자체 state를 관리했고 RestaurantList는 고정된 데이터를 표시했다. 부모인 App에서 카테고리 선택값을 알 수 없었다. | ||
| `&&` 앞에 숫자나 빈 문자열 같은 falsy 값이 오면, `false`로 평가되지 않고 값 자체가 화면에 출력된다. | ||
|
|
||
| **해결:** State를 부모 App으로 올렸다. CategoryFilter는 props로 `category`와 `onChangeCategory`를 받아 controlled component가 되었고, 필터링 로직은 App에서 처리하게 됐다. 이를 통해 부모-자식 간 데이터 흐름이 단방향으로 명확해졌다. | ||
| ```jsx | ||
| // ⚠️ count가 0이면 "0"이 화면에 렌더링됨 | ||
| {count && <Modal />} | ||
|
|
||
| ### 동적 이미지 매핑의 필요성 | ||
| 처음엔 모든 음식점이 한식 이미지(`koreanImg`)만 표시됐다. 음식점 객체에 `category` 필드가 있는데도 활용하지 않고 있었다. | ||
| // ✅ 명시적으로 불리언으로 변환 | ||
| {!!count && <Modal />} | ||
| {count > 0 && <Modal />} | ||
| ``` | ||
|
|
||
| **해결:** `CATEGORY_IMAGES` 객체를 만들어 카테고리별 이미지를 매핑했다. `src={CATEGORY_IMAGES[restaurant.category]}`로 각 음식점에 맞는 이미지가 동적으로 표시되도록 수정했다. | ||
| 이 미션에서 `clickedRestaurant`는 객체 또는 `null`만 들어오므로 문제없지만, 숫자나 문자열을 조건으로 쓸 때는 주의해야 한다. `!!`를 사용하는 이유 중 하나이기도 하다. | ||
|
|
||
| ### Lifting State Up | ||
|
|
||
| 클릭된 음식점 정보를 `RestaurantList`와 `RestaurantDetailModal` 두 컴포넌트가 공유해야 하므로, 공통 부모인 `App`에서 state를 관리한다. | ||
|
|
||
| ### `!!` 이중 부정 연산자 | ||
|
|
||
| 자바스크립트에서 값의 truthy/falsy 평가 결과를 명시적으로 불리언 값으로 변환하기 위해 사용된다. 주로 조건식의 결과를 일관된 불리언 타입으로 정규화할 때 활용된다. | ||
|
|
||
| ```jsx | ||
| !!null // → false (모달 닫힌 상태) | ||
| !!{ id: 1 } // → true (모달 열린 상태) | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 🛠 리팩토링 | ||
| ## 🤔 고민했던 문제와 해결 과정에서 배운 점 | ||
|
|
||
| 1. Props와 State 책임 분리 | ||
| ### 무엇을 state로 선언할지 | ||
|
|
||
| - 이유: 초기 구현에서 각 컴포넌트가 자신의 state를 독립적으로 관리하고 있어, 부모 컴포넌트가 상태 변화를 알 수 없었다. 이로 인해 필터링이 제대로 작동하지 않았다. | ||
| 가장 고민이 된 부분은 `restaurant`를 state로 만들어야 하는가였다. `filteredRestaurants`로 계산할 수 있을 것 같아 state가 불필요하다고 생각했는데, 실제로는 두 값의 역할이 다르다. | ||
|
|
||
| - 개선: State를 부모 App 컴포넌트로 올렸다(state lifting). CategoryFilter는 선택된 카테고리를 props로 받아 표시만 하고, 변경 시 콜백 함수를 통해 부모에 알린다. 이제 App이 중앙에서 상태를 관리하고, RestaurantList에 필터된 데이터를 props로 전달한다. | ||
| - `filteredRestaurants` — 화면에 보여줄 **목록** → `category`에서 파생, state 불필요 | ||
| - `clickedRestaurant` — 모달에 보여줄 **선택된 하나** → 클릭 전까지 알 수 없으므로 state 필요 | ||
|
Comment on lines
+97
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [배움] |
||
|
|
||
| 2. 동적 이미지 매핑으로 하드코딩 제거 | ||
| **기준**: 다른 값으로부터 계산할 수 없다면 state, 계산할 수 있다면 파생 변수 | ||
|
|
||
| - 이유: 모든 음식점에 한식 이미지만 매핑되어 있었다. 각 음식점의 `category` 필드를 활용하지 않고 있었고, 새로운 카테고리 추가 시 컴포넌트 코드를 수정해야 했다. | ||
| ### isModalOpen과 clickedRestaurant를 따로 두면 생기는 문제 | ||
|
|
||
| - 개선: `CATEGORY_IMAGES` 객체를 만들어 카테고리와 이미지를 매핑했다. 이제 음식점 데이터의 `category`에 따라 자동으로 올바른 이미지가 표시된다. 새로운 카테고리를 추가할 때도 객체에만 항목을 추가하면 된다. | ||
| 처음엔 `isModalOpen` boolean과 `clickedRestaurant` 두 state를 동시에 관리하려 했다. 이 경우 둘을 항상 함께 업데이트해야 하는데, 하나라도 빠지면 모달은 열려 있지만 `clickedRestaurant`가 `null`인 상황이 발생해 런타임 에러가 난다. | ||
|
|
||
| 3. 상수와 유틸 함수를 별도 파일로 분리 | ||
| ```jsx | ||
| function handleRestaurantClick(restaurant) { | ||
| setClickedRestaurant(restaurant); | ||
| setIsModalOpen(true); // 둘 중 하나라도 빠지면 버그 | ||
| } | ||
| ``` | ||
|
|
||
| **해결:** `clickedRestaurant` 하나로 통합. `null`이면 닫힌 상태, 값이 있으면 열린 상태다. `isRestaurantDetailModalOpen`은 이를 기반으로 계산한 파생 변수로 가독성을 확보했다. | ||
|
|
||
| ```jsx | ||
| const [clickedRestaurant, setClickedRestaurant] = useState(null); | ||
| const isRestaurantDetailModalOpen = !!clickedRestaurant; // 파생 변수 | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 🛠 리팩토링 | ||
|
|
||
| - 이유: App.jsx에 RESTAURANTS 데이터와 filter 함수가 모두 포함되어 있어서 컴포넌트 로직과 비즈니스 로직이 섞여 있었다. 파일이 길어지고 가독성이 떨어졌다. | ||
| State 설계를 단계적으로 개선하며 사고 과정을 커밋으로 기록했다. | ||
|
|
||
| - 개선: | ||
| - `src/constants/restaurants.js` — RESTAURANTS 배열 분리 | ||
| - `src/constants/categoryImages.js` — CATEGORY_IMAGES 매핑 객체 분리 | ||
| - `src/utils/filterRestaurants.js` — filterRestaurants 함수 분리 (함수명, 함수 형태 수정) | ||
|
|
||
| App.jsx는 이제 필요한 상수와 함수를 import해서 사용하므로 역할이 명확해졌다. 또한 각 모듈이 독립적이므로 재사용성이 높아졌고, 테스트하거나 수정할 때 해당 파일만 건드리면 된다. | ||
| 1. **Stage 1** — `isModalOpen` boolean state로 모달 열고 닫기만 구현 (restaurant 데이터 없음) | ||
| 2. **Stage 2** — `isModalOpen` + `clickedRestaurant` 두 state로 실제 데이터 전달 (동기화 문제 내포) | ||
| 3. **Stage 3** — `clickedRestaurant` 단일 state로 통합, `isRestaurantDetailModalOpen`을 파생 변수로 개선 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,28 +5,44 @@ import RestaurantList from "./components/RestaurantList/RestaurantList.jsx"; | |
| import { useState } from "react"; | ||
| import { filterRestaurants } from "./utils/filterRestaurants.js"; | ||
| import { RESTAURANTS } from "./constants/restaurants.js"; | ||
| // import RestaurantDetailModal from "./components/RestaurantDetailModal/RestaurantDetailModal.jsx"; | ||
| // import AddRestaurantModal from "./components/AddRestaurantModal/AddRestaurantModal.jsx"; | ||
| import RestaurantDetailModal from "./components/RestaurantDetailModal/RestaurantDetailModal.jsx"; | ||
|
|
||
| function App() { | ||
| const [category, setCategory] = useState("전체"); | ||
| const [clickedRestaurant, setClickedRestaurant] = useState(null); | ||
| const isRestaurantDetailModalOpen = !!clickedRestaurant; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [배움] |
||
| const filteredRestaurants = filterRestaurants(RESTAURANTS, category); | ||
|
|
||
| function handleChange(e) { | ||
| setCategory(e.target.value); | ||
| } | ||
|
|
||
| function handleRestaurantClick(restaurant) { | ||
| setClickedRestaurant(restaurant); | ||
| } | ||
|
|
||
| function handleModalClose() { | ||
| setClickedRestaurant(null); | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <Header /> | ||
| <main> | ||
| <CategoryFilter category={category} onChangeCategory={handleChange} /> | ||
| <RestaurantList restaurants={filteredRestaurants} /> | ||
| <RestaurantList | ||
| restaurants={filteredRestaurants} | ||
| onRestaurantClick={handleRestaurantClick} | ||
| /> | ||
| </main> | ||
| {/* <aside> | ||
| <RestaurantDetailModal /> | ||
| <AddRestaurantModal /> | ||
| </aside> */} | ||
| <aside> | ||
| {isRestaurantDetailModalOpen && ( | ||
| <RestaurantDetailModal | ||
| restaurant={clickedRestaurant} | ||
| onClose={handleModalClose} | ||
| /> | ||
| )} | ||
| </aside> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,20 @@ | ||
| import styles from "./RestaurantDetailModal.module.css"; | ||
|
|
||
| export default function RestaurantDetailModal() { | ||
| export default function RestaurantDetailModal({ restaurant, onClose }) { | ||
| return ( | ||
| <div className={`${styles.modal} ${styles["modal--open"]}`}> | ||
| <div className={styles.modal__backdrop}></div> | ||
| <div className={styles.modal__backdrop} onClick={onClose}></div> | ||
| <div className={styles.modal__container}> | ||
| <h2 className={`${styles.modal__title} text-title`}>음식점 이름</h2> | ||
| <h2 className={`${styles.modal__title} text-title`}> | ||
| {restaurant.name} | ||
| </h2> | ||
|
Comment on lines
5
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 모달에 dialog 시맨틱을 추가해주세요. 현재는 스크린리더에 모달로 인식되지 않아 컨텍스트 전달이 약합니다. 수정 예시- <div className={`${styles.modal} ${styles["modal--open"]}`}>
+ <div
+ className={`${styles.modal} ${styles["modal--open"]}`}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="restaurant-detail-title"
+ >
@@
- <h2 className={`${styles.modal__title} text-title`}>
+ <h2
+ id="restaurant-detail-title"
+ className={`${styles.modal__title} text-title`}
+ >
{restaurant.name}
</h2>🤖 Prompt for AI Agents |
||
| <div className={styles.modal__restaurantInfo}> | ||
| <p className="text-body">음식점 소개 문구</p> | ||
| <p className="text-body">{restaurant.description}</p> | ||
| </div> | ||
| <div className={styles.modal__buttonContainer}> | ||
| <button | ||
| className={`${styles.button} ${styles["button--primary"]} text-caption`} | ||
| onClick={onClose} | ||
| > | ||
| 닫기 | ||
| </button> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,13 +1,17 @@ | ||||||||||||||||||||||||||||||||||||||
| import styles from "./RestaurantList.module.css"; | ||||||||||||||||||||||||||||||||||||||
| import { CATEGORY_IMAGES } from "../../constants/categoryImages.js"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export default function RestaurantList({ restaurants }) { | ||||||||||||||||||||||||||||||||||||||
| export default function RestaurantList({ restaurants, onRestaurantClick }) { | ||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||
| <section className={styles.restaurantList}> | ||||||||||||||||||||||||||||||||||||||
| <ul> | ||||||||||||||||||||||||||||||||||||||
| {restaurants.map((restaurant) => { | ||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||
| <li key={restaurant.id} className={styles.restaurant}> | ||||||||||||||||||||||||||||||||||||||
| <li | ||||||||||||||||||||||||||||||||||||||
| key={restaurant.id} | ||||||||||||||||||||||||||||||||||||||
| className={styles.restaurant} | ||||||||||||||||||||||||||||||||||||||
| onClick={() => onRestaurantClick(restaurant)} | ||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+10
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 키보드 접근 가능한 클릭 인터랙션으로 바꿔주세요. 현재는 마우스 클릭만 처리되어 키보드 사용자(Enter/Space)가 식당 상세 모달을 열 수 없습니다. 수정 예시 <li
key={restaurant.id}
className={styles.restaurant}
+ role="button"
+ tabIndex={0}
onClick={() => onRestaurantClick(restaurant)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onRestaurantClick(restaurant);
+ }
+ }}
>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+10
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안]
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Step4에서 |
||||||||||||||||||||||||||||||||||||||
| <div className={styles.restaurant__category}> | ||||||||||||||||||||||||||||||||||||||
| <img | ||||||||||||||||||||||||||||||||||||||
| src={CATEGORY_IMAGES[restaurant.category]} | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[배움]
저는
count && <Modal />이 0을 그대로 렌더링하는 케이스를 생각해보지 못했는데,!!로 명시적 boolean 변환을 하면 이런 오류를 방지할 수 있겠네요!