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
29 changes: 0 additions & 29 deletions 03-modal/README.md

This file was deleted.

42 changes: 42 additions & 0 deletions 04-form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 04. 폼 UI 구현하기: controlled vs uncontrolled

## 🎯 요구 사항

- `Header`의 레스토랑 추가 버튼을 클릭하면 레스토랑 추가 폼이 모달로 뜨도록 구현해 주세요
- 이전 단계에서 만들어두었던 `AddRestaurantModal`을 그대로 사용합니다.
- 카테고리를 선택하고, `<input/>`, `<textarea/>`에 값을 입력한 뒤 '추가하기' 버튼을 클릭하면 레스토랑 목록에 추가되도록 구현해 주세요.
- 유효성 검사는 하지 않습니다. 아주 간단하게 입력값을 처리해보는 구현만 해도 충분합니다.
- id는 `Date.now()`값을 임의로 할당합니다.
- (optional) 재사용할 수 있는 Modal 컴포넌트를 만들어서 `AddRestaurantModal`, `RestaurantDetailModal`을 Modal 컴포넌트를 활용해 구현하는 것으로 개선해 보세요

## ✅ 키워드

- controlled vs uncontrolled
- children props

> 재사용할 수 있는 모달을 만들 때 `children`을 활용해 보세요. 아래와 같은 식으로 UI를 구성할 수 있습니다.

```javascript
// 설명을 위한 예시용 마크업입니다. 실제로 사용하는 마크업은 template/ 하위의 html을 참고하거나 직접 원하는대로 구현하여 사용해 주세요.
// 반드시 아래와 같은 형식으로 쓸 필요는 없습니다. 원하는 방식대로 재사용 가능한 <Modal/> 컴포넌트를 만들어 보세요.

// AddRestaurantModal.jsx
<Modal title="새로운 음식점" onClose={onClose}>
<form></form>
</Modal>

// RestaurantDetailModal.jsx
<Modal title={restaurant.name} onClose={onClose}>
<div className="restaurant-info"></div>
<div className="button-container"></div>
</Modal>
```

## 🧙‍♀️ 진행 가이드

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

## 🔗 참고 문서

- [Sharing State Between Components](https://react.dev/learn/sharing-state-between-components)
- [API Reference: <input>](https://react.dev/reference/react-dom/components/input)
223 changes: 136 additions & 87 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,162 +1,211 @@
# Self-Paced React Step 3
# Self-Paced React Step 4

## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드

이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다.

**1. 이벤트 핸들러를 통한 데이터 전달 이해**
**1. Controlled vs Uncontrolled Input 이해**

자식 컴포넌트에서 발생한 이벤트가 부모로 어떻게 전달되는지 이해하고,
클릭 이벤트와 함께 데이터를 넘기는 패턴을 직접 구현한다.
폼 입력 상태를 React state로 직접 관리하는 방식(controlled)과
DOM에서 직접 읽는 방식(uncontrolled)의 차이를 이해하고,
각각 어느 상황에 적합한지 판단할 수 있게 된다.

**2. 조건부 렌더링 이해**
**2. children prop 패턴 이해**

`&&` 연산자를 활용하여 특정 조건일 때만 컴포넌트를 렌더링하는 방식을 익힌다.
`children`을 활용하여 재사용 가능한 Modal 컴포넌트를 설계하고,
두 종류의 모달(`AddRestaurantModal`, `RestaurantDetailModal`)에 공통 구조를 적용한다.

**3. 파생 상태(Derived State) 이해**
**3. 폼 데이터 처리 및 state 업데이트 흐름 이해**

모달 열림 여부처럼 기존 상태에서 계산 가능한 값은 별도 state로 선언하지 않고
기존 state를 활용하는 방식을 이해한다.
폼 제출 시 입력값을 수집하여 상위 컴포넌트의 state를 업데이트하고,
목록에 새 항목이 추가되는 전체 흐름을 직접 구현한다.

---

## 📝 기능 구현 목록

**1. 음식점 아이템 클릭 시 상세 모달 열기**
**1. 헤더 추가 버튼 클릭 시 음식점 추가 모달 열기**

- `clickedRestaurant` state를 App.jsx에 추가한다. (초기값: null)
- `handleRestaurantClick(restaurant)` 핸들러를 정의하여 `RestaurantList`에 전달한다.
- `RestaurantList`는 아이템 클릭 시 해당 restaurant 객체를 핸들러에 넘긴다.
- `clickedRestaurant`가 null이 아닐 때만 `RestaurantDetailModal`을 렌더링한다.
- 음식점 추가 모달 열림 여부를 boolean state로 관리한다.
- 버튼 클릭 시 모달을 조건부 렌더링한다.

**2. 모달 닫기**
**2. 음식점 추가 폼 제출 시 목록에 추가**

- `handleModalClose` 핸들러를 정의하여 `setClickedRestaurant(null)`로 모달을 닫는다.
- 닫기 버튼과 backdrop 클릭 시 `onClose` props를 통해 핸들러를 호출한다.
- 폼 입력값을 controlled input으로 관리한다.
- 폼 제출 시 입력값을 상위 컴포넌트로 전달해 목록에 추가하고 모달을 닫는다.

**3. 클릭한 음식점 정보 모달에 표시**
**3. 모달 닫기**

- `clickedRestaurant` 객체를 `RestaurantDetailModal`에 props로 전달한다.
- 모달에서 `clickedRestaurant.name`, `clickedRestaurant.description`을 렌더링한다.
- backdrop 클릭 또는 폼 제출 완료 시 모달을 닫는다.

**[optional] 재사용 가능한 Modal 컴포넌트 구현**

- 두 모달의 공통 구조를 `Modal` 컴포넌트로 추출한다.
- 각 모달의 고유 내용은 `children`으로 전달한다.

---

## 📚 학습 내용

### 1. 조건부 렌더링
### 1. Controlled vs Uncontrolled Input

특정 조건일 때만 컴포넌트를 렌더링할 때 `&&` 연산자를 사용한다.
폼 입력값을 다루는 두 가지 방식이다.

```jsx
{clickedRestaurant && <RestaurantDetailModal restaurant={clickedRestaurant} />}
```
**Controlled** — 입력값을 React state로 관리한다. `value`로 state를 바인딩하고, `onChange`로 변화를 감지해 state를 업데이트한다. React가 값의 단일 출처(source of truth)가 된다.

좌측 값이 falsy(null, undefined, false)이면 렌더링하지 않고, truthy이면 우측 컴포넌트를 렌더링한다.
```jsx
const [name, setName] = useState("");

### 2. State 불변성과 리렌더링
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
```

React에서 state는 직접 수정하면 리렌더링이 트리거되지 않는다. 반드시 setter 함수를 통해야 React가 변화를 감지하고 화면을 다시 그린다.
**Uncontrolled** — state 없이, 제출 시점에 DOM에서 값을 직접 읽는다. `e.target.elements` 또는 `FormData`를 활용한다.

```jsx
// ❌ 직접 수정 — 리렌더링 안 됨
clickedRestaurant = restaurant;

// ✅ setter 사용 — 리렌더링 트리거
setClickedRestaurant(restaurant);
const handleSubmit = (e) => {
e.preventDefault();
const name = e.target.elements.name.value;
};
```

### 3. 파생 상태 (Derived State)
| | Controlled | Uncontrolled |
|---|---|---|
| 값 관리 | React state | DOM |
| 값 접근 시점 | 언제든지 | 제출 시 |
| 실시간 유효성 검사 | 가능 | 어려움 |
| 코드량 | 많음 | 적음 |

이번 미션에서는 Controlled 방식을 사용했다.

모달 열림 여부를 관리하기 위해 별도 boolean state(`isModalOpen`)를 선언하지 않아도 된다. `clickedRestaurant`가 null이면 닫힘, 객체면 열림 — 기존 state에서 의미를 도출할 수 있기 때문이다.
### 2. 배열 state 업데이트

### 4. 웹 접근성 (a11y) — 클릭 가능한 비버튼 요소
배열 state에 항목을 추가할 때 `push`는 직접 변경이라 리렌더링이 트리거되지 않는다. 스프레드 연산자로 새 배열을 만들어야 한다.

`<li>` 같은 비인터랙티브 요소에 `onClick`을 달면 마우스 사용자는 동작하지만, 키보드 사용자는 접근할 수 없다. 다음 세 가지를 함께 추가해야 한다.
```jsx
setNewRestaurants([...newRestaurants, newRestaurant]);
```

### 3. children prop

- `role="button"`: 스크린 리더에게 이 요소가 버튼처럼 동작함을 알린다.
- `tabIndex={0}`: Tab 키로 포커스를 받을 수 있게 한다.
- `onKeyDown`: Enter 또는 Space 키 입력 시 onClick과 동일한 동작을 수행한다.
컴포넌트 태그 사이에 넣은 JSX가 `children`이라는 특수 prop으로 전달된다.

```jsx
<li
role="button"
tabIndex={0}
onClick={() => onRestaurantClick(restaurant)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onRestaurantClick(restaurant);
}
}}
>
<Modal title="새로운 음식점" onClose={onClose}>
<form>...</form> {/* 이게 children */}
</Modal>

function Modal({ title, onClose, children }) {
return (
<div>
<div onClick={onClose}></div>
<div>
<h2>{title}</h2>
{children} {/* <form>...</form>이 여기에 렌더링됨 */}
</div>
</div>
);
}
```

공통 구조(껍데기)는 `Modal`이 담당하고, 각 모달은 고유한 내용만 children으로 전달하면 된다.

---

## 🤔 고민했던 문제와 해결 과정에서 배운 점

### 1. 이벤트 핸들러 인자 전달
### 1. e.preventDefault() 위치

**고민했던 점**
폼 submit 이벤트의 기본 동작(페이지 새로고침)을 막으려면 `e.preventDefault()`를 이벤트가 발생하는 곳에서 호출해야 한다. App의 `handleFormSubmit`이 받는 건 이벤트가 아니라 음식점 객체이기 때문에, `AddRestaurantModal` 내부의 `handleFormSubmit`에서 처리해야 한다.

`handleRestaurantClick` 핸들러에서 인자를 선언하지 않아 `setClickedRestaurant`에 항상 null이 저장되는 문제가 발생했다.
### 2. `<select>`의 controlled input 연결

**해결 과정 및 배운 점**
`value`와 `onChange`는 `<option>`이 아닌 `<select>`에 달아야 한다. `<option>`의 `value`는 해당 옵션이 선택됐을 때 `e.target.value`로 읽히는 값이고, `<select>`의 `value`가 현재 선택된 상태를 React state와 동기화한다.

RestaurantList에서 `onClick={() => onRestaurantClick(restaurant)}`로 restaurant 객체를 인자로 넘기고, App의 핸들러에서 `(restaurant) => setClickedRestaurant(restaurant)` 형태로 받아야 한다는 것을 이해했다. 이벤트가 발생하는 곳에서 인자를 넘기고, 핸들러에서 매개변수로 받는 흐름을 체득했다.
### 3. 어떤 state를 어느 컴포넌트가 소유해야 하는가

```jsx
// RestaurantList — 클릭 시 restaurant 객체를 넘김
onClick={() => onRestaurantClick(restaurant)}
"이 state를 누가 필요로 하는가"를 기준으로 가장 가까운 공통 부모가 소유해야 한다. 스터디를 통해 이 기준을 정립하게 됐다.

// App.jsx — 넘겨받은 객체를 state에 저장
const handleRestaurantClick = (restaurant) => {
setClickedRestaurant(restaurant);
};
```
- `newRestaurants`, `isAddModalOpen` — App 전체가 필요로 하므로 App 소유
- 폼 입력값(`category`, `name`, `description`) — `AddRestaurantModal`만 사용하므로 해당 컴포넌트 소유
- `id` — 데이터의 고유 식별자로 App의 관심사이므로 App에서 생성

## 🛠 리팩토링

**1. 클릭 가능한 `<li>`에 웹 접근성 속성 추가**
**1. 재사용 가능한 Modal 컴포넌트 추출**

`<li>`에 `onClick`만 달면 키보드 사용자는 접근할 수 없다. 과거 코드 리뷰에서 받은 피드백을 반영하여 `role="button"`, `tabIndex={0}`, `onKeyDown`을 추가했다.
`AddRestaurantModal`과 `RestaurantDetailModal`이 backdrop, container, title 구조를 중복으로 갖고 있었다. 공통 구조를 `Modal` 컴포넌트로 분리하고, 각 모달은 `children`으로 고유 내용만 넘기도록 리팩토링했다.

---
**2. 배열 state 업데이트를 함수형 업데이트로 변경**

`[...newRestaurants, newRestaurant]`에서 `(prev) => [...prev, newRestaurant]`로 변경했다. 이전 state에 의존하는 업데이트는 함수형 업데이트가 더 안전하다.

**3. id 생성을 `crypto.randomUUID()`로 변경**

`Date.now()` 대신 `crypto.randomUUID()`를 사용하도록 변경했다. 밀리초 단위 충돌 가능성을 제거하고 고유성을 보장한다.

**4. 이벤트 핸들러 네이밍 규칙 통일 + id 생성 위치 이동**

핸들러는 `handle + [대상] + [동작]`, props는 `on + [대상] + [동작]` 패턴으로 통일했다. on-/handle- 대칭이 맞지 않던 세 곳을 수정했다.

| 이전 | 이후 |
|------|------|
| `onSubmit` | `onFormSubmit` |
| `onChangeCategory` | `onCategoryChange` |
| `onClick` (Header) | `onAddModalOpen` |

id 생성도 `AddRestaurantModal`에서 App으로 이동했다. 폼은 입력값만 올려보내고, 데이터 식별자 생성은 state를 소유한 App의 관심사이기 때문이다.

**5. Modal 래퍼 `<div>` → Fragment, CSS 토글 패턴 제거**

템플릿(순수 HTML/CSS)에서 `.modal--open` 클래스를 붙이고 떼는 방식으로 모달을 보이고 숨겼는데, React 조건부 렌더링으로 전환하면서 CSS 토글이 불필요해졌다. 래퍼 `<div>`도 Fragment로 교체했다. backdrop과 container가 모두 `position: fixed`라 부모 요소의 레이아웃에 영향을 받지 않기 때문에 래퍼가 없어도 동작이 동일하다.

## 과거 코드와 비교

### 달라진 점

**1. State 통합 — boolean 분리 → 단일 객체 state**
**1. 폼 입력값 읽기 방식 — Uncontrolled → Controlled**

| 구분 | 과거 코드 | 현재 코드 |
|------|---------|---------|
| 모달 상태 관리 | `selected` + `isModalOpen` 2개 | `clickedRestaurant` 1개 |

과거엔 `selected`(선택된 음식점)와 `isModalOpen`(열림 여부)를 따로 관리했다. 두 상태는 항상 함께 변하기 때문에 불필요한 중복이 생긴다. 현재는 `clickedRestaurant`가 null이면 닫힘, 객체면 열림으로 하나의 state에서 두 의미를 모두 표현했다.
| 방식 | Uncontrolled (`FormData`) | Controlled (`useState`) |
| 값 접근 | 제출 시 DOM에서 읽음 | state로 실시간 관리 |
| 코드량 | 적음 | 많음 |

### 과거 코드에서 배운 점
과거 코드는 state 없이 폼 제출 시 `FormData`로 DOM에서 한 번에 읽었다. 각 input에 `name` 속성이 있으면 키-값 쌍으로 꺼낼 수 있어 코드가 간결하다.

**1. 파생 상태는 state로 선언하지 않는다**
```jsx
// 과거 — Uncontrolled
const fd = new FormData(e.currentTarget);
onAdd({ category: fd.get("category"), name: fd.get("name") });

리뷰어 피드백: *"두 상태가 서로 의존적인 관계인데 분리해서 관리할 경우 일관성이 깨질 수 있습니다. 불필요한 상태는 제거해 하나의 상태로 관리해보는 건 어떨까요?"*
// 현재 — Controlled
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />
```

다른 state에서 계산 가능한 값을 별도 state로 선언하면 두 상태가 불일치할 위험이 생긴다. 기존 state만으로 표현 가능하다면 파생 상태로 처리하는 것이 더 안전하다.
### 과거 코드에서 배운 점

**2. 웹 접근성 — 클릭 가능한 `<li>`**
**1. 함수형 업데이트**

리뷰어 피드백: *"`<li>`에 onClick 이벤트를 사용할 경우 role, tabIndex, onKeyDown을 추가해 키보드 사용자도 접근할 수 있도록 해야 합니다."*
리뷰어 코드에서 배열 state 업데이트 시 함수형 업데이트를 사용했다.

```jsx
<li
role="button"
tabIndex={0}
onClick={() => onRestaurantClick(restaurant)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onRestaurantClick(restaurant);
}
}}
>
// 기존 코드 — newRestaurants를 직접 참조
setNewRestaurants([...newRestaurants, newRestaurant]);

// 함수형 업데이트 — React가 최신 state를 prev로 전달
setNewRestaurants((prev) => [...prev, newRestaurant]);

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.

[논의]
두 방식을 스터디 세션 때 같이 정리하면 좋을 것 같습니다!

```

마우스 없이 Tab + Enter로도 동작하도록 보장하는 것이 웹 접근성의 기본이다.
React의 state 업데이트는 즉시 반영되지 않아 렌더링 사이클 사이에 `newRestaurants`가 오래된 값일 수 있다. `prev`는 React가 보장하는 최신 state이므로, 이전 state를 기반으로 새 state를 만들 때는 함수형 업데이트가 더 안전하다.

`prev`는 관례적인 이름일 뿐이며, 각 setter에 묶인 해당 state의 최신값이 전달된다. state가 여러 개여도 setter가 다르므로 섞이지 않는다.

**2. `crypto.randomUUID()`**

리뷰어 피드백: *"아주 짧은 시간 안에 여러 항목이 추가될 경우 `Date.now()`는 중복된 id가 생성될 가능성이 있습니다. `crypto.randomUUID()`를 고려해보세요."*

`Date.now()`는 밀리초 단위 숫자라 짧은 시간 안에 두 번 호출되면 같은 값이 나올 수 있다. `crypto.randomUUID()`는 브라우저 내장 함수로 `"550e8400-e29b-41d4-a716-446655440000"` 형태의 충돌 없는 고유 ID를 생성한다.
Loading