# React Expert

> React 컴포넌트를 설계·구현·리팩터링하거나 상태 관리, useEffect 남용, 리렌더 성능, 접근성 문제를 다룰 때 사용한다. React 19 기준.

- Skill: `leeyudok/react-expert` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add leeyudok/react-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/leeyudok/react-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: LeeYudok (https://skillmd.com/u/leeyudok)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/leeyudok/react-expert

---


# react-expert

컴포넌트 코드가 대상이다. 번들러·패키지 매니저·의존성은 `frontend-build`,
디자인 토큰·컴포넌트 선택은 `doksam-ui` 가 맡는다.

이 문서는 **일반론을 적지 않는다.** 판단이 갈리는 지점, 자주 틀리는 곳, React 19 에서
바뀐 것만 담는다.

## 1. 상태는 필요한 만큼만, 있어야 할 곳에

판단 순서:

1. **props 나 기존 상태에서 계산할 수 있는가** → 렌더 중에 계산한다. `useState` + `useEffect`
   조합으로 파생값을 동기화하지 않는다. 이 패턴이 버그의 큰 축이다.
2. **여러 컴포넌트가 공유하는가** → 가장 가까운 공통 부모로 올린다. 전역 스토어는
   "여러 화면이 같은 서버 상태를 본다"가 성립할 때만.
3. **URL 에 있어야 하는가** — 새로고침·공유·뒤로가기가 의미 있으면 라우터 상태다.
   상세 화면·필터·탭이 여기 해당한다.

```tsx
// 나쁨 — 파생값을 상태로 두고 동기화
const [filtered, setFiltered] = useState<Room[]>([])
useEffect(() => { setFiltered(rooms.filter(r => r.name.includes(q))) }, [rooms, q])

// 좋음 — 렌더 중 계산
const filtered = useMemo(() => rooms.filter(r => r.name.includes(q)), [rooms, q])
```

`useMemo` 는 **측정 가능한 비용이 있을 때만**. 배열 몇 개 도는 것에 붙이면 코드만 늘어난다.

## 2. useEffect 는 "외부 시스템과 동기화"에만

effect 를 쓰기 전에 답한다: **이 코드가 맞물리려는 외부 시스템이 무엇인가?**
(네트워크, DOM 이벤트, 타이머, 구독) 답이 없으면 effect 가 아니다.

- **사용자 행동의 결과는 이벤트 핸들러에서 처리한다.** 상태를 바꾸고 그 변화를 effect 로
  감지해 후속 작업을 하는 구조는 흐름을 끊고 중복 실행을 부른다.
- **StrictMode 에서 effect 는 두 번 실행된다.** 이건 버그가 아니라 정리(cleanup) 누락을
  드러내는 장치다. 두 번 돌아 깨지면 effect 쪽을 고친다.

### 비동기 요청 취소는 필수

```tsx
useEffect(() => {
  let alive = true
  api.messages(dbRef, roomId).then(m => { if (alive) setMessages(m) })
  return () => { alive = false }
}, [dbRef, roomId])
```

빠뜨리면 대상을 연달아 바꿀 때 **먼저 보낸 응답이 나중에 도착해 화면을 덮는다**(경합).
`AbortController` 를 쓸 수 있으면 그쪽이 더 낫다 — 요청 자체를 끊는다.

### 의존성 배열을 거짓말로 채우지 않는다

린트가 요구하는 값을 빼서 "한 번만 실행"을 흉내내지 않는다. 대신 원인을 없앤다 —
함수는 `useCallback` 으로 안정화하거나 effect 안으로 옮기고, 정말 마운트 1회면
그 사실이 드러나게 쓴다.

## 3. 리스트와 key

- `key` 는 **데이터의 안정적 식별자**. 배열 인덱스는 순서가 바뀌거나 중간 삽입이 있으면
  상태가 엉뚱한 행에 붙는다.
- **컴포넌트를 초기화하고 싶을 때 `key` 를 바꾸는 것은 정식 기법이다.**
  상세 뷰에서 대상이 바뀔 때 내부 상태를 리셋하는 가장 단순한 방법이다.

```tsx
<MessageView key={`${room.id}:${jumpTo ?? 0}`} ... />
```

## 4. React 19 에서 달라진 것

- **`forwardRef` 가 필요 없다** — 함수 컴포넌트가 `ref` 를 일반 prop 으로 받는다.
  기존 코드를 일괄 변환할 필요는 없지만 새 코드에서 쓰지 않는다.
- **`use()`** 로 promise·context 를 조건부로 읽을 수 있다. Suspense 경계와 함께 쓴다.
- `useFormStatus`·`useActionState` 는 폼 제출 상태를 다룬다. 서버 액션이 없는
  SPA 에서도 쓸 수 있다.
- ref 콜백이 정리 함수를 반환할 수 있다.

**서드파티 컴포넌트에 ref 를 넘겨 특정 자식으로 스크롤하는 식의 조작은 취약하다.**
내부가 어떤 엘리먼트를 렌더하는지에 의존하기 때문이다. 이럴 땐 `data-*` 속성을 붙이고
컨테이너에서 `querySelector` 로 찾는 편이 타입·구조 양쪽에서 안전하다.

## 5. 접근성 — 구조로 강제한다

리뷰에서 지적하는 대신 **틀리기 어렵게** 만든다.

- **아이콘 전용 버튼은 `aria-label` 이 없으면 만들지 않는다.** 레이블을 필수 prop 으로 받는
  래퍼 컴포넌트를 두면 구조적으로 막힌다.
- 클릭 가능한 것은 `<button>`/`<a>`. `<div onClick>` 은 키보드·스크린리더에서 사라진다.
- 폼 입력은 `<label htmlFor>` 로 연결한다. placeholder 는 레이블이 아니다.
- 에러 메시지는 `role="alert"`.
- 포커스 링을 지우지 않는다. 디자인상 바꿔야 하면 `focus-visible` 로 대체 스타일을 준다.
- 상태를 색으로만 알리지 않는다 — 텍스트·아이콘·모양을 함께 쓴다.
- 브라우저 `confirm()`/`alert()` 를 쓰지 않는다. 데스크톱 셸(웹뷰)에서 이벤트 루프를 막아
  앱이 굳는다. 2단계 인라인 확인이나 다이얼로그 컴포넌트로 대체한다.

## 6. 위험한 렌더

- **`dangerouslySetInnerHTML` 는 기본 금지.** 꼭 필요하면 서버에서 새니타이즈하고, 그 사실을
  주석에 남긴다. 사용자 입력·외부 데이터를 그대로 넣지 않는다.
- 외부에서 온 URL 을 `href`/`src` 에 넣을 때 스킴을 검사한다(`javascript:` 차단).
- 사용자 텍스트는 JSX 텍스트 노드로 넣으면 자동 이스케이프된다 — 굳이 직렬화하지 않는다.

## 7. 에러 표면

- 서버가 주는 실패 사유를 **삼키지 않는다.** 공통 인터셉터(401 → 로그인 유도 등)를 둘 때는
  그 처리가 **적용되면 안 되는 요청**을 먼저 정한다. 로그인 요청의 401 은 "세션 만료"가
  아니라 "자격 불일치"이므로 서버 메시지가 그대로 보여야 한다.
- 사용자에게 보여줄 메시지와 로그용 상세를 구분한다.

## 8. 성능은 측정 후에 손댄다

기본은 "단순하게 쓰고, 느려지면 고친다". React 컴파일러가 도입된 프로젝트라면
수동 메모이제이션을 먼저 걷어낸다.

실제로 문제가 되는 것들:

- **리스트가 길다** → 가상 스크롤. 수백 건부터 체감된다.
- **큰 트리가 매 입력마다 리렌더** → 상태를 아래로 내리거나 입력을 지역화한다.
- **검색·필터가 매 타이핑마다 요청** → 디바운스(200~300ms). 이전 요청 취소도 함께.
- **부모가 매 렌더마다 새 객체·함수를 props 로 준다** → 자식이 memo 여도 소용없다.

## 9. 화면 및 폼 인터랙션 검증 (Playwright / 접근성 트리)

- **스크린샷 대신 접근성 스냅샷 우선**: AI 에이전트나 자동화 도구로 UI를 검증할 때, 고비용 이미지 비교보다 `aria-controls`, `aria-expanded`, `role="alert"` 등 접근성 트리(Accessibility Tree)를 기반으로 DOM 상태와 노출 여부를 판정한다.
- **접힘(Accordion/Collapsible) 영역 내 폼 상태**: `hidden` 속성으로 숨겨진 폼 필드는 DOM에 남아 있어 자동완성을 보존하지만 렌더 트리에서는 비가시 상태다. 테스트 시 버튼 트리거로 가시 상태(`isVisible`) 전환 후 제출과 에러 안내 렌더링을 확인한다.

## 10. 완료 조건

- 타입체크 통과, `any` 0건
- 파생값을 상태로 두고 effect 로 동기화하는 코드가 없음
- 모든 비동기 effect 에 취소·정리가 있음
- 아이콘 전용 버튼에 접근 가능한 이름이 있음
- 리스트 `key` 가 안정적 식별자임
- `dangerouslySetInnerHTML` 을 썼다면 근거가 주석에 있음
- 실제로 띄워서 확인함 — 렌더 결과와 콘솔 에러 없음까지

# Learned warnings

- (2026-08-23) Playwright E2E/MCP 검증 시 이미지보다 접근성 트리(`role=alert`, `aria-expanded` 등)를 확인하는 것이 토큰 효율과 판정 정확도가 높다. 접힌(hidden) 폼 영역은 가시성 토글 이벤트 후 인터랙션을 검증한다.

