Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .coderabbit.yaml
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
49 changes: 49 additions & 0 deletions 05-effects/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 05. API 연동하기: side-effect(feat. effects)

## 🎯 요구 사항
- API로 레스토랑 목록을 불러와 `<RestaurantList />`에 내려줍니다.
- 로딩 상태, 에러 상태 등은 고려하지 않습니다.
- 레스토랑 추가 모달에서 추가하기 버튼을 클릭하면 POST 요청을 보냅니다. 모달이 닫히고, 레스토랑 목록을 다시 불러옵니다.

## ✅ 키워드
- effect (feat. side effect)
- useEffect

## 🧙‍♀️ 진행 가이드

- 진행 시간: 2시간 내에 완료하는 것을 목표로 합니다.

### `json-server`로 가짜 서버 띄워 활용하기
연습용 앱이기 때문에 [`json-server`](https://github.com/typicode/json-server)를 활용해 간단한 가짜 REST API를 구축해 사용합니다.
- `npm run server`를 실행합니다. (혹은 `npx json-server db.json` 를 직접 실행해도 상관없습니다)
- `GET http://localhost:3000/restaurants`으로 `db.json`에 있는 레스토랑 목록을 불러올 수 있습니다.
```javascript
// GET 예시
const response = await fetch("http://localhost:3000/restaurants");

// POST 예시
const response = await fetch("http://localhost:3000/restaurants", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(restaurant),
});
```

## 🔗 참고 문서
- [Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects)
> Effects let you specify side effects that are caused by rendering itself, rather than by a particular event.
- [API Reference: useEffect](https://react.dev/reference/react/useEffect)
> useEffect is a React Hook that lets you synchronize a component with an external system.
- [API Reference: useSate > updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state)
> `set` function에 함수를 넘겨주면 `updater function`으로 동작합니다. 함수가 아닌 값을 넘겨줄 때와 어떻게 다른지 알아보세요.

구현을 다 해본 뒤에 Introduction에서 살펴보았던 설계 원칙과 관련해 조금 더 학습해보고 싶다면 아래 문서들도 추가로 확인해 보세요.
- [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
> You do need Effects to synchronize with external systems.
> In React, data flows from the parent components to their children.
- [Components and Hooks must be pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure)
> Purity in Components and Hooks is a key rule of React that makes your app predictable, easy to debug, and allows React to automatically optimize your code.
> Side effects should not run in render, as React can render components multiple times to create the best possible user experience.
> One important principle in React is local reasoning: the ability to understand what a component or hook does by looking at its code in isolation. Hooks should be treated like “black boxes” when they are called.
410 changes: 308 additions & 102 deletions README.md

Large diffs are not rendered by default.

37 changes: 18 additions & 19 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@ import CategoryFilter from "./components/CategoryFilter/CategoryFilter.jsx";
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 { useRestaurants } from "./hooks/useRestaurants.js";
import { ALL_CATEGORY } from "./constants/categories.js";

function App() {
const [filterCategory, setFilterCategory] = useState("전체");
const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY);
const [clickedRestaurant, setClickedRestaurant] = useState(null);
const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] =
useState(false);
const [restaurants, setRestaurants] = useState(RESTAURANTS);
const { restaurants, addRestaurant, isLoading, error } = useRestaurants();

const isRestaurantDetailModalOpen = !!clickedRestaurant;
const filteredRestaurants = filterRestaurants(restaurants, filterCategory);
const filteredRestaurants = filterRestaurants(restaurants, selectedCategory);

function handleFilterCategoryChange(e) {
setFilterCategory(e.target.value);
function handleCategoryChange(e) {
setSelectedCategory(e.target.value);
}

function handleRestaurantClick(restaurant) {
Expand All @@ -38,26 +39,24 @@ function App() {
setIsAddRestaurantModalOpen(false);
}

function handleRestaurantSubmit({ category, name, description }) {
setRestaurants([
...restaurants,
{
id: Date.now(),
category,
name,
description,
},
]);
setIsAddRestaurantModalOpen(false);
async function handleRestaurantSubmit(restaurant) {
try {
await addRestaurant(restaurant);
setIsAddRestaurantModalOpen(false);
} catch {
alert("음식점 추가에 실패했습니다. 다시 시도해주세요.");
}
}
Comment thread
meteorqz6 marked this conversation as resolved.

return (
<>
<Header onAddButtonClick={handleAddButtonClick} />
<main>
{isLoading && <p>불러오는 중...</p>}
{error && <p>{error}</p>}
<CategoryFilter
category={filterCategory}
onCategoryChange={handleFilterCategoryChange}
category={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<RestaurantList
restaurants={filteredRestaurants}
Expand Down
16 changes: 16 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const BASE_URL = "http://localhost:3000";
Comment thread
meteorqz6 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
http://localhost:3000이 두 곳에 반복되는 걸 상수로 추출하신 부분 실무에서도 자주 쓰이는 패턴인 것 같아요. 저도 적용해볼게요!


export async function getRestaurants() {
const response = await fetch(`${BASE_URL}/restaurants`);
if (!response.ok) throw new Error(`서버 오류: ${response.status}`);
return response.json();
}

export async function createRestaurant(restaurant) {
const response = await fetch(`${BASE_URL}/restaurants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(restaurant),
});
if (!response.ok) throw new Error(`서버 오류: ${response.status}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
리드미에서 에러 처리를 고민하신 걸 봤는데, 최종적으로 api는 어차피 호출부에 throw를 해줘야 하니까 catch를 제거해서 불필요한 코드를 삭제하신 부분 좋은 것 같아요! '에러를 처리할 수 있는 곳에서만 잡는다'는 원칙도 좋은 기준인 것 같습니다!

}
14 changes: 7 additions & 7 deletions src/components/AddRestaurantModal/AddRestaurantModal.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import styles from "./AddRestaurantModal.module.css";
import Modal from "../Modal/Modal";
import Modal from "../Modal/Modal.jsx";
import { CATEGORIES } from "../../constants/categories.js";

export default function AddRestaurantModal({ onSubmit, onClose }) {
const [category, setCategory] = useState("");
Expand All @@ -27,12 +28,11 @@ export default function AddRestaurantModal({ onSubmit, onClose }) {
required
>
<option value="">선택해 주세요</option>
<option value="한식">한식</option>
<option value="중식">중식</option>
<option value="일식">일식</option>
<option value="양식">양식</option>
<option value="아시안">아시안</option>
<option value="기타">기타</option>
{CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>

Expand Down
14 changes: 7 additions & 7 deletions src/components/CategoryFilter/CategoryFilter.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ALL_CATEGORY, CATEGORIES } from "../../constants/categories.js";
import styles from "./CategoryFilter.module.css";

export default function CategoryFilter({ category, onCategoryChange }) {
Expand All @@ -11,13 +12,12 @@ export default function CategoryFilter({ category, onCategoryChange }) {
value={category}
onChange={onCategoryChange}
>
<option value="전체">전체</option>
<option value="한식">한식</option>
<option value="중식">중식</option>
<option value="일식">일식</option>
<option value="양식">양식</option>
<option value="아시안">아시안</option>
<option value="기타">기타</option>
<option value={ALL_CATEGORY}>{ALL_CATEGORY}</option>
{CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</section>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Header/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function Header({ onAddButtonClick }) {
aria-label="음식점 추가"
onClick={onAddButtonClick}
>
<img src={addButton} alt="음식점 추가" />
<img src={addButton} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
그러고 보니 버튼에 aria-label이 있는데 이미지에도 alt가 있으면 스크린 리더가 중복해서 읽게 되겠네요. 템플릿을 옮겨오는 과정에서 별 생각 없이 적용한 코드였는데, 접근성까지 챙겨서 리팩토링하신 부분이 인상깊어요. 저도 제거해볼게요!

</button>
</header>
);
Expand Down
9 changes: 9 additions & 0 deletions src/components/Modal/Modal.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { useEffect } from "react";
import styles from "./Modal.module.css";

export default function Modal({ children, title, onClose }) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

Comment on lines +5 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
키보드 사용자도 모달을 닫을 수 있도록 접근성까지 고려한 게 진짜 꼼꼼하신 것 같아요! cleanup 함수로 이벤트 리스너를 제거하지 않으면 모달을 열고 닫을 때마다 리스너가 누적된다는 건 미처 생각 못했는데, 컴포넌트가 사라져도 자동으로 정리되지 않는 것들은 반드시 cleanup이 필요하다는 것 덕분에 알게 됐어요.

return (
<>
<div className={styles.modal__backdrop} onClick={onClose}></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import styles from "./RestaurantDetailModal.module.css";
import Modal from "../Modal/Modal";
import Modal from "../Modal/Modal.jsx";

export default function RestaurantDetailModal({ restaurant, onClose }) {
return (
Expand Down
38 changes: 18 additions & 20 deletions src/components/RestaurantList/RestaurantList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,24 @@ export default function RestaurantList({ restaurants, onRestaurantClick }) {
<ul>
{restaurants.map((restaurant) => {
return (
<li
key={restaurant.id}
className={styles.restaurant}
onClick={() => onRestaurantClick(restaurant)}
>
<div className={styles.restaurant__category}>
<img
src={CATEGORY_IMAGES[restaurant.category]}
alt={restaurant.category}
className={styles.restaurant__categoryIcon}
/>
</div>
<div className={styles.restaurant__info}>
<h3 className={`${styles.restaurant__name} text-subtitle`}>
{restaurant.name}
</h3>
<p className={`${styles.restaurant__description} text-body`}>
{restaurant.description}
</p>
</div>
<li key={restaurant.id} className={styles.restaurant}>
<button className={styles.restaurant__button} onClick={() => onRestaurantClick(restaurant)}>
<div className={styles.restaurant__category}>
<img
src={CATEGORY_IMAGES[restaurant.category]}
alt={restaurant.category}
className={styles.restaurant__categoryIcon}
/>
</div>
<div className={styles.restaurant__info}>
<h3 className={`${styles.restaurant__name} text-subtitle`}>
{restaurant.name}
</h3>
<p className={`${styles.restaurant__description} text-body`}>
{restaurant.description}
</p>
</div>
</button>
</li>
);
})}
Expand Down
10 changes: 9 additions & 1 deletion src/components/RestaurantList/RestaurantList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@
}

.restaurant {
border-bottom: 1px solid #e9eaed;
}

.restaurant__button {
display: flex;
align-items: flex-start;

width: 100%;
padding: 16px 8px;

border-bottom: 1px solid #e9eaed;
background: none;
border: none;
cursor: pointer;
text-align: left;
}

.restaurant__category {
Expand Down
2 changes: 2 additions & 0 deletions src/constants/categories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ALL_CATEGORY = "전체";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
저번 미션에서 매직 스트링을 리팩토링했으면서도 "전체" 문자열이 매직 스트링이라고 인지를 못하고 있었어요. 카테고리 상수에서 관리하는 것 좋은 것 같아요! 저도 적용해보겠습니다!

export const CATEGORIES = ["한식", "중식", "일식", "양식", "아시안", "기타"];
41 changes: 0 additions & 41 deletions src/constants/restaurants.js

This file was deleted.

31 changes: 31 additions & 0 deletions src/hooks/useRestaurants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState, useEffect, useCallback } from "react";
import { getRestaurants, createRestaurant } from "../api.js";

export function useRestaurants() {
const [restaurants, setRestaurants] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

const fetchRestaurants = useCallback(async () => {
setIsLoading(true);
try {
const data = await getRestaurants();
setRestaurants(data);
} catch (error) {
setError("음식점 목록을 불러오지 못했습니다.");
} finally {
setIsLoading(false);
}
Comment on lines +16 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
요구사항에 없는 로딩 상태까지 구현하신 게 인상적이에요! setIsLoading(false)try/catch 양쪽에 중복으로 쓰는 대신 finally로 한 번만 쓴 것도 깔끔하고 좋은 것 같아요. 성공/실패와 무관하게 반드시 실행돼야 하는 코드는 finally에 두는 패턴 좋은 기준인 것 같아요.

}, []);

useEffect(() => {
void fetchRestaurants();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
fetchRestaurants()void로 호출하신 부분 좋은 것 같아요. async 함수를 useEffect 안에서 호출하면 Promise가 반환되는데, React는 cleanup 함수(또는 undefined)만 기대하기 때문에 void로 반환값을 명시적으로 버리는 패턴이군요! 저도 적용해보겠습니다.

}, [fetchRestaurants]);
Comment thread
meteorqz6 marked this conversation as resolved.

async function addRestaurant(restaurant) {
await createRestaurant(restaurant);
await fetchRestaurants();
}
Comment thread
meteorqz6 marked this conversation as resolved.

return { restaurants, addRestaurant, isLoading, error };
}
8 changes: 4 additions & 4 deletions src/utils/filterRestaurants.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ALL_CATEGORY } from "../constants/categories.js";

export function filterRestaurants(restaurants, category) {
if (category === "전체") return restaurants;
else {
return restaurants.filter((restaurant) => restaurant.category === category);
}
if (category === ALL_CATEGORY) return restaurants;
return restaurants.filter((restaurant) => restaurant.category === category);
}