diff --git a/keyword/chapter09/global-state-management.md b/keyword/chapter09/global-state-management.md new file mode 100644 index 00000000..1fd30621 --- /dev/null +++ b/keyword/chapter09/global-state-management.md @@ -0,0 +1,212 @@ +# React 전역 상태 관리 가이드 + +> 참고 +> +> - [개발자 매튜 | React 전역 상태 관리 완벽 가이드: Context API vs Zustand vs Jotai](https://www.yolog.co.kr/post/global-state/) +> - [useSyncExternalStore – React](https://react.dev/reference/react/useSyncExternalStore) +> - [TanStack Query – Overview](https://tanstack.com/query/latest/docs/framework/react/overview) + +--- + +## 상태의 세 가지 종류 + +| 종류 | 설명 | 예시 | +| --- | --- | --- | +| **로컬 state** | 한 컴포넌트 안에서만 | 폼 input, 모달 open | +| **전역 state (클라이언트)** | 여러 컴포넌트가 공유, 클라이언트가 통제 | 테마, 장바구니 UI, 인증 토큰 | +| **서버 state** | API에서 오는 데이터, 신선도·캐시 필요 | LP 목록, 댓글 | + +이 문서는 **클라이언트 전역 state** 비교에 집중함. 서버 state는 TanStack Query(chapter06)가 담당하는 영역 + +--- + +## Context API value 전체 구독 vs Zustand selector 구독 + +### Context — value 전체 구독 + +```tsx +function UserProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + + const value = { user, setUser, theme, setTheme }; + + return {children}; +} +``` + +`useContext`는 Provider의 **`value` 객체 참조**를 구독함 + +- `user`만 바뀌어도 `value`가 새 객체 → **theme만 쓰는 컴포넌트도 재평가** +- `theme`만 바꿔도 **user만 쓰는 컴포넌트도 재평가** +- Context를 나누거나 `useMemo`로 value를 안정화해도, **한 Context 안의 특정 필드만** 구독하는 API는 없음 + +React 공식 문서도 Context value 변경 시 해당 Context를 읽는 컴포넌트가 리렌더된다고 설명함 + +### Zustand — selector 기반 구독 + +```tsx +function Parent() { + const user = useUserStore((state) => state.user); + return
{user?.name}
; +} + +function Child() { + const theme = useUserStore((state) => state.theme); + return ; +} +``` + +- store는 React 트리 **밖** module-level에 존재 +- `useSyncExternalStore`로 외부 store 구독 → Concurrent Rendering에서 tearing 방지 +- 각 컴포넌트의 **selector 반환값**만 `Object.is`로 비교 +- `set({ theme: 'dark' })` → `Parent`(user 구독)는 리렌더 **안 됨** + +### 성능 차이 요약 + +| | Context API | Zustand | +| --- | --- | --- | +| 구독 단위 | `value` 전체 | selector가 고른 slice | +| A 필드 변경 시 B만 쓰는 컴포넌트 | 재평가됨 (memo 없으면 리렌더) | selector 결과 동일 → 스킵 | +| Provider | 필수 | 기본 불필요 | +| 적합 | 자주 안 바뀌는 값 (locale, auth snapshot) | 필드 많고 갱신 잦은 전역 state | + +매튜 블로그 벤치마크(커뮤니티 측정 인용): 50개 필드 폼에서 한 필드 변경 시 Context ~280ms vs Zustand ~45ms 수준 — **환경마다 다르므로 Profiler로 직접 측정**하는 것이 원칙 + +--- + +## Jotai atom 조합과 파생 상태 (Zustand 대비) + +### Zustand — Top-down, 단일 store + selector + +```tsx +const useStore = create(() => ({ + user: { name: '매튜', age: 30 }, + theme: 'light', +})); + +// 파생 값은 컴포넌트나 selector에서 직접 계산 +const userName = useStore((s) => s.user.name); +const doubleAge = useStore((s) => s.user.age * 2); +``` + +파생 state가 많아지면 selector가 흩어지거나, store action/reducer에 로직이 몰릴 수 있음 + +### Jotai — Bottom-up, atom + 의존성 그래프 + +```tsx +import { atom } from 'jotai'; + +const userAtom = atom({ name: '매튜', age: 30 }); +const themeAtom = atom<'light' | 'dark'>('light'); + +// 파생 atom — read 시 get()으로 의존 atom 자동 추적 +const userNameAtom = atom((get) => get(userAtom).name); +const doubleAgeAtom = atom((get) => get(userAtom).age * 2); +const isLoggedInAtom = atom((get) => get(userAtom) !== null); +``` + +### 의존성 추적 관점의 장점 + +Jotai는 파생 atom의 `read(get)` 실행 중 **`get(다른Atom)` 호출을 기록**해 의존성 그래프를 만듦 + +``` +userAtom + ├─> userNameAtom + └─> doubleAgeAtom +``` + +- `userAtom` 변경 → **의존하는 atom만** 재계산 → 그 atom을 구독하는 컴포넌트만 리렌더 +- `themeAtom` 변경 → `userNameAtom`과 무관 → user 관련 UI는 영향 없음 +- 파생 state를 **선언적으로 atom으로 조합** — Zustand는 store 한 덩어리 + selector, Jotai는 atom 단위 그래프 + +Zustand: “큰 store를 selector로 쪼갠다” +Jotai: “작은 atom을 조합하고, 의존성은 프레임워크가 추적한다” + +| | Zustand | Jotai | +| --- | --- | --- | +| 구조 | 단일(또는 소수) store | atom 단위 bottom-up | +| 파생 state | selector / getState 수동 | `atom(get => ...)` 자동 의존 | +| Suspense 연동 | 가능 | atom 단위 async에 강함 | +| 학습 곡선 | 낮음 | atom/Provider 개념 필요 | + +--- + +## useEffect로 서버 상태를 관리할 때의 문제 + +### 나쁜 패턴 + +```tsx +function ProductList() { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch('/api/products') + .then((res) => res.json()) + .then((data) => { + setProducts(data); + setLoading(false); + }); + }, []); + + if (loading) return
로딩 중...
; + return ; +} +``` + +### 1) 캐싱 없음 + +- 컴ponent **unmount** 시 `useState` 데이터 소실 +- 같은 페이지로 다시 오면 **매번 fetch** — 네트워크·로딩 UX 낭비 +- TanStack Query는 `queryKey` 기준 **메모리 캐시** + `staleTime`으로 재사용 + +### 2) 중복 요청 + +- `ProductList`와 `ProductHeader`가 각각 같은 API를 `useEffect`로 호출하면 **동일 요청이 N번** 발생 +- Query는 같은 `queryKey` 요청을 **dedupe**하고 in-flight 공유 + +### 3) 클라이언트 간 불일치 + +- 컴포넌트 A는 `useState`로 products, B도 별도 `useState` → 한쪽만 갱신되면 **화면마다 다른 데이터** +- 전역 cache(source of truth)가 없어 **동기화 책임이 개발자**에게 있음 + +### 4) 그 외 한계 + +- 로딩/에러/재시도/백그라운드 refetch/낙관적 업데이트를 **매 fetch마다 수동 구현** +- race condition: 느린 요청이 나중에 도착해 **최신 데이터를 덮어씀** (cleanup·abort 없을 때) +- 서버 데이터는 **다른 사용자·탭·시간**에 따라 변함 — 클라이언트 전역 state와 성격이 다름 + +### 개선 — TanStack Query + +```tsx +function ProductList() { + const { data: products, isLoading } = useQuery({ + queryKey: ['products'], + queryFn: () => fetch('/api/products').then((res) => res.json()), + }); + + if (isLoading) return
로딩 중...
; + return ; +} +``` + +- 캐시·중복 제거·stale/fresh·invalidate가 **라이브러리 책임** +- Zustand/Redux는 **클라이언트 UI state**, TanStack Query는 **서버 state** — 역할 분리가 실무 패턴 + +--- + +## 선택 가이드 (요약) + +``` +1. 한 컴포넌트만 쓰나? → useState +2. 여러 컴포넌트 공유? + └─ 자주 안 바뀜 → Context API + └─ 자주 바뀜 / 필드 많음 → Zustand + └─ 파생 state·atom 그래프 많음 → Jotai +3. API에서 가져온 데이터? → TanStack Query (useEffect + useState 지양) +4. 복잡한 클라이언트 전역 + DevTools → Redux Toolkit +``` + +chapter08처럼 **역할별로 도구를 조합**하는 것이 한 라이브러리만 고집하는 것보다 유지보수에 유리함 diff --git a/keyword/chapter09/props-drilling.md b/keyword/chapter09/props-drilling.md new file mode 100644 index 00000000..33cd48cb --- /dev/null +++ b/keyword/chapter09/props-drilling.md @@ -0,0 +1,214 @@ +# Props Drilling + +> 참고 +> +> - [Passing Data Deeply with Context | React Docs](https://react.dev/learn/passing-data-deeply-with-context) +> - [chapter02/use-context.md](../chapter02/use-context.md) — Context API 실습 + +--- + +## Props Drilling이란? + +상위 컴포넌트에서 하위 컴포넌트로 데이터를 전달할 때, **실제로 그 값을 쓰지 않는 중간 컴포넌트**도 props를 받아 다시 아래로 넘겨야 하는 상황을 말함 + +React는 **단방향 데이터 흐름**(부모 → 자식)을 따르기 때문에, 트리가 깊어질수록 전달 경로가 길어짐 + +``` +App (name 보유) + └─ Parent ← name 사용 안 함, 전달만 + └─ Child ← name 사용 안 함, 전달만 + └─ GrandChild ← name 실제 사용 +``` + +### 예시 + +```tsx +function App() { + return ; +} + +function Parent({ name }: { name: string }) { + return ; +} + +function Child({ name }: { name: string }) { + return ; +} + +function GrandChild({ name }: { name: string }) { + return
Hello {name}
; +} +``` + +`name`이 필요한 컴포넌트는 `GrandChild` 하나뿐이지만, `Parent`와 `Child`도 props를 받아야 함 + +--- + +## 왜 문제인가 + +| 문제 | 설명 | +| --- | --- | +| 유지보수 | props 이름·타입 변경 시 경로상 모든 컴포넌트 수정 필요 | +| 가독성 | 중간 컴포넌트가 실제로 쓰는 값인지, 전달용인지 구분하기 어려움 | +| 재사용성 | `Child`를 다른 곳에 쓰려면 불필요한 props까지 강제로 전달해야 함 | +| 리팩토링 부담 | 컴포넌트 구조를 바꿀 때마다 props 전달 경로도 함께 수정해야 함 | + +--- + +## 자주 발생하는 상황 + +여러 곳에서 공통으로 필요한 값을 내려보낼 때 Drilling이 길어지기 쉬움 + +- 로그인 사용자 정보 (`user`, `profile`) +- 테마 (다크/라이트 모드) +- 언어 설정 (i18n, locale) +- 레이아웃 전역 설정 +- 모달 열림 여부, 알림 상태 등 UI 상태 + +--- + +## 줄이는 방법 + +### 1. 컴포넌트 설계부터 점검 + +Context나 전역 상태 라이브러리로 가기 전에 먼저 확인할 것 + +1. 이 데이터가 정말 이 깊이까지 내려가야 하는가? +2. 컴포넌트 역할을 다시 나누면 props 경로를 줄일 수 있는가? + +특정 섹션에서만 쓰는 데이터라면, 그 섹션을 묶어 **그 안에서만** 상태를 관리하는 편이 나을 수 있음 (State Colocation) + +### 2. children / 컴포넌트 추출 + +React 공식 문서는 “데이터를 사용하지 않는 중간 컴포넌트가 많다면, **children으로 JSX를 전달**하는 구조 재설계”를 권장함 + +```tsx +function Page({ user }: { user: User }) { + return ( + + {/* Layout은 user를 몰라도 됨 */} + + ); +} +``` + +### 3. Context API + +여러 컴포넌트가 공통으로 쓰는 값은 Provider로 감싸고, 필요한 곳에서 `useContext`로 직접 읽음 + +```tsx +interface UserContextType { + name: string; +} + +const UserContext = createContext(null); + +function App() { + return ( + + + + ); +} + +function GrandChild() { + const user = useContext(UserContext); + if (!user) return null; + return
Hello {user.name}
; +} +``` + +중간 컴포넌트(`Parent`, `Child`)는 `name` props를 받을 필요가 없어짐 + +> Context 상세: [chapter02/use-context.md](../chapter02/use-context.md) + +### 4. 상태 관리 라이브러리 + +규모가 커지면 Redux Toolkit, Zustand 등으로 **스토어에서 직접 구독**하는 방식이 Drilling을 줄이는 데 유리함 + +### 5. Custom Hook + +같은 로직을 여러 컴포넌트에서 쓴다면 Hook으로 분리. 전역 공유가 필요하면 Hook 내부에서 Context나 스토어와 결합 + +```tsx +function useDarkMode() { + const [isDarkMode, setIsDarkMode] = useState(false); + const toggle = () => setIsDarkMode((prev) => !prev); + return { isDarkMode, toggle }; +} +``` + +--- + +## Context로 리팩토링 예시 + +### Before — Drilling + +```tsx +function App() { + return ; +} + +function Page({ isDarkMode }: { isDarkMode: boolean }) { + return ; +} + +function Layout({ isDarkMode }: { isDarkMode: boolean }) { + return
; +} + +function Header({ isDarkMode }: { isDarkMode: boolean }) { + return

{isDarkMode ? 'Dark' : 'Light'} Mode

; +} +``` + +### After — Context + +```tsx +interface ThemeContextType { + isDarkMode: boolean; +} + +const ThemeContext = createContext(null); + +function App() { + return ( + + + + ); +} + +function Header() { + const theme = useContext(ThemeContext); + if (!theme) return null; + return

{theme.isDarkMode ? 'Dark' : 'Light'} Mode

; +} +``` + +--- + +## TypeScript 관점 + +props와 Context 타입을 명시하면 컴파일 단계에서 전달 오류를 잡을 수 있음 + +```tsx +interface AuthContextType { + userName: string; + isLoggedIn: boolean; +} + +const AuthContext = createContext(null); +``` + +Provider 밖 사용을 막으려면 커스텀 Hook에서 `undefined`/`null` 체크 후 throw하는 패턴이 일반적임 + +--- + +## 체크리스트 + +- 이 props는 이 컴포넌트가 **직접 필요해서** 받는가? (전달만이면 Drilling 가능성) +- 이 상태는 **특정 영역**에서만 쓰이는가, **앱 전역**에서 필요한가? +- 컴포넌트 구조를 나누면 props 깊이를 줄일 수 있는가? +- Context를 쓸 때 **전역 변수 지옥**이 되지 않는가? (정말 공통 값에만 사용) +- 공통 로직은 Custom Hook으로 분리할 수 있는가? diff --git a/keyword/chapter09/redux-vs-redux-toolkit.md b/keyword/chapter09/redux-vs-redux-toolkit.md new file mode 100644 index 00000000..d6749f20 --- /dev/null +++ b/keyword/chapter09/redux-vs-redux-toolkit.md @@ -0,0 +1,211 @@ +# Redux vs Redux Toolkit + +> 참고 공식 문서 +> +> - [Redux – Getting Started](https://redux.js.org/introduction/getting-started) +> - [Redux Toolkit – Getting Started](https://redux-toolkit.js.org/introduction/getting-started) +> - [Redux FAQ: When should I use Redux?](https://redux.js.org/faq/general#when-should-i-use-redux) + +--- + +## Redux란? + +**예측 가능하고 유지보수하기 쉬운 전역 상태**를 관리하기 위한 JS 라이브러리임. React뿐 아니라 다른 UI와도 함께 쓸 수 있음 + +### 핵심 원칙 (Redux Docs) + +1. 앱의 전역 state는 **하나의 store** 객체 트리에 저장 +2. state를 바꾸는 유일한 방법은 **action**을 dispatch하는 것 +3. state 변경 방식은 **순수 reducer 함수**로만 정의 + +``` +UI → dispatch(action) → reducer(state, action) → new state → UI +``` + +### Legacy Redux 예시 (보일러플레이트 많음) + +```tsx +// actions.ts +export const INCREMENT = 'INCREMENT'; +export const increment = () => ({ type: INCREMENT }); + +// reducer.ts +const initialState = { count: 0 }; + +export function counterReducer(state = initialState, action: { type: string }) { + switch (action.type) { + case INCREMENT: + return { ...state, count: state.count + 1 }; + default: + return state; + } +} +``` + +action type 상수, action creator, reducer, store 설정을 **파일·코드마다 직접** 작성해야 함. 불변 업데이트도 spread로 직접 처리 + +--- + +## Redux Toolkit(RTK)이란? + +Redux 팀이 **Redux를 쓰는 표준 방식**으로 공식 권장하는 도구 모음임 + +RTK가 해결하려는 세 가지 불만 (공식 문서): + +- store 설정이 너무 복잡하다 +- 유용한 기능을 쓰려면 패키지를 많이 깔아야 한다 +- 보일러플레이트가 너무 많다 + +### RTK Counter 예시 + +```tsx +import { configureStore, createSlice } from '@reduxjs/toolkit'; + +const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + incremented(state) { + state.value += 1; // Immer 덕분에 “변경처럼” 작성 가능 + }, + decremented(state) { + state.value -= 1; + }, + }, +}); + +export const { incremented, decremented } = counterSlice.actions; + +export const store = configureStore({ + reducer: counterSlice.reducer, +}); +``` + +`createSlice` 하나로 **reducer + action creator + action type**이 생성됨 + +--- + +## 한눈에 비교 + +| 항목 | Redux (Legacy) | Redux Toolkit | +| --- | --- | --- | +| 코드량 | 많음 | 적음 | +| 불변성 | 직접 spread/copy | Immer 내장 (mutable 문법 → immutable 결과) | +| reducer | switch + type 상수 | `createSlice`의 `reducers` 객체 | +| action | creator 수동 작성 | slice에서 자동 생성 | +| store 설정 | `createStore` + middleware 수동 | `configureStore` (thunk, DevTools 기본) | +| 비동기 | redux-thunk/saga 별도 설정 | `createAsyncThunk` 내장 | +| 공식 권장 | 과거 방식 | **현재 표준** | + +--- + +## RTK 주요 API + +### configureStore + +store를 만들고 slice reducer를 합치며, 기본 middleware(thunk)와 DevTools를 설정함 + +```tsx +import { configureStore } from '@reduxjs/toolkit'; +import counterReducer from './counterSlice'; + +export const store = configureStore({ + reducer: { + counter: counterReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; +``` + +### createSlice + +name, initialState, reducers(동기), extraReducers(비동기 등)를 받아 slice reducer와 action을 생성 + +### createAsyncThunk + +`pending / fulfilled / rejected` action을 자동 dispatch하는 비동기 thunk 생성 + +```tsx +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +export const fetchUser = createAsyncThunk('user/fetch', async (userId: string) => { + const res = await fetch(`/api/users/${userId}`); + return res.json(); +}); +``` + +### React 연동 — Provider, useSelector, useDispatch + +```tsx +// main.tsx +import { Provider } from 'react-redux'; +import { store } from './store'; + +createRoot(document.getElementById('root')!).render( + + + , +); +``` + +```tsx +// Counter.tsx +import { useDispatch, useSelector } from 'react-redux'; +import { incremented } from './counterSlice'; +import type { RootState } from './store'; + +function Counter() { + const count = useSelector((state: RootState) => state.counter.value); + const dispatch = useDispatch(); + + return ; +} +``` + +TypeScript에서는 `RootState`, `AppDispatch` 타입을 export해 selector/dispatch에 재사용하는 패턴이 일반적임 + +### RTK Query (선택) + +`@reduxjs/toolkit/query` — API fetch·캐시·invalidation을 slice 없이 선언적으로 처리 (서버 상태용) + +--- + +## RTK가 공식 권장인 이유 + +Redux 팀: *"RTK is the standard way to write Redux logic"* — 보일러플레이트를 줄이면서 Redux의 **예측 가능한 단방향 흐름**은 유지함 + +- DevTools로 action 타임라인 추적·리플레이 가능 +- Immer로 실수하기 쉬운 불변 업데이트를 완화 +- 팀 onboarding 시 “Redux Toolkit부터”가 현재 업계 표준 + +--- + +## 언제 쓸까 / 언제 과한가 + +### RTK를 고려할 때 + +- 여러 컴포넌트가 **복잡한 클라이언트 전역 state**를 공유 +- 비동기 API + 전역 UI state를 **한 store**에서 체계적으로 관리하고 싶을 때 +- action/reducer 패턴으로 **변경 이력·디버깅**이 중요할 때 + +### 과할 수 있을 때 + +- `useState` / `useReducer` + Context로 충분한 작은 앱 +- 서버에서 가져온 데이터만 필요 → **TanStack Query** 등 서버 상태 도구가 더 적합 (chapter06 참고) + +Redux FAQ: *"Don't use Redux just because someone said you should"* — props/Context로 한계가 올 때 도입을 검토 + +--- + +## Redux 패턴과 useReducer의 관계 + +| | useReducer + Context | Redux Toolkit | +| --- | --- | --- | +| 범위 | 앱 일부 또는 중소 규모 | 앱 전역, 팀 단위 | +| DevTools | 없음 (직접 구현) | Redux DevTools | +| 미들웨어 | 없음 | thunk, listener 등 | +| 보일러플레이트 | 적음 | RTK로 Redux 대비 적음 | + +미션 규모에서는 useReducer로 패턴을 익힌 뒤, RTK는 **slice·store·hooks** 흐름을 익히는 단계로 보면 됨 diff --git a/keyword/chapter09/use-reducer.md b/keyword/chapter09/use-reducer.md new file mode 100644 index 00000000..6d6e3224 --- /dev/null +++ b/keyword/chapter09/use-reducer.md @@ -0,0 +1,200 @@ +# useReducer + +> 참고 공식 문서 +> +> - [useReducer – React](https://react.dev/reference/react/useReducer) +> - [Extracting State Logic into a Reducer – React](https://react.dev/learn/extracting-state-logic-into-a-reducer) + +--- + +## useReducer란? + +복잡한 state 업데이트 로직을 **컴포넌트 밖 reducer 함수 한 곳**으로 모아 관리하는 React Hook임 + +`useState`와 비슷하게 `[state, dispatch]`를 반환하지만, “어떻게 바꿀지”는 reducer가 결정함 + +React 공식 문서: *"useReducer is very similar to useState, but it lets you move the state update logic from event handlers into a single function outside of your component."* + +--- + +## 기본 문법 + +```tsx +const [state, dispatch] = useReducer(reducer, initialState); +``` + +| 항목 | 역할 | +| --- | --- | +| `state` | 현재 상태 | +| `dispatch` | 상태 변경을 **요청**하는 함수 (identity가 안정적) | +| `reducer` | `(state, action) => newState` 순수 함수 | +| `initialState` | 초기 상태 | + +세 번째 인자 `init`을 쓰면 `(initialArg) => initialState` 형태로 초기값을 계산할 수 있음 + +### reducer 형태 + +```tsx +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'increment': + return { count: state.count + 1 }; + default: + return state; + } +} +``` + +- reducer는 **순수 함수**여야 함 (기존 state를 직접 mutate하면 안 됨) +- action은 보통 `{ type: string, ...payload }` 형태의 객체 +- 알 수 없는 action은 `return state` 또는 Error throw + +--- + +## Counter 예제 + +```tsx +import { useReducer } from 'react'; + +interface State { + count: number; +} + +type Action = + | { type: 'increment' } + | { type: 'decrement' } + | { type: 'reset' }; + +const initialState: State = { count: 0 }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'increment': + return { count: state.count + 1 }; + case 'decrement': + return { count: state.count - 1 }; + case 'reset': + return { count: 0 }; + default: + return state; + } +} + +export default function Counter() { + const [state, dispatch] = useReducer(reducer, initialState); + + return ( +
+

{state.count}

+ + + +
+ ); +} +``` + +--- + +## useState vs useReducer + +| 항목 | useState | useReducer | +| --- | --- | --- | +| 단순한 값 (boolean, number) | 적합 | 과할 수 있음 | +| 업데이트 로직이 복잡함 | if/else가 컴포넌트에 흩어짐 | reducer에 집중 가능 | +| 여러 종류의 action | 관리 어려움 | switch/discriminated union으로 명확 | +| reducer 테스트 | — | 컴포넌트 밖 함수라 단위 테스트 용이 | +| 하위에 dispatch만 전달 | state 전체 전달 필요 | Drilling 완화에 유리 | + +React 공식 가이드: 다음 중 **하나라도** 해당하면 useReducer 고려 + +- 다음 state가 이전 state에 **크게 의존**할 때 +- 여러 하위 컴포넌트에 **서로 다른 updater**를 전달할 때 +- state 업데이트 로직이 **복잡**할 때 + +--- + +## 객체(폼) 상태 관리 + +```tsx +interface FormState { + name: string; + age: number; +} + +type FormAction = + | { type: 'setName'; payload: string } + | { type: 'setAge'; payload: number } + | { type: 'reset' }; + +const initialForm: FormState = { name: '', age: 0 }; + +function formReducer(state: FormState, action: FormAction): FormState { + switch (action.type) { + case 'setName': + return { ...state, name: action.payload }; + case 'setAge': + return { ...state, age: action.payload }; + case 'reset': + return initialForm; + default: + return state; + } +} +``` + +--- + +## dispatch만 props로 전달 + +state 대신 **dispatch만** 내려보내면, 하위는 “어떤 action을 보낼지”만 알면 됨 + +```tsx +function Child({ dispatch }: { dispatch: React.Dispatch }) { + return ; +} +``` + +- `dispatch`는 렌더마다 identity가 바뀌지 않음 (Effect deps에 넣어도 보통 안전) +- state 값 자체를 중간 컴포넌트에 노출하지 않아도 됨 + +--- + +## useReducer + Context + +규모가 커지면 reducer state와 dispatch를 Context로 묶어 **Redux 없이** 간단한 전역 상태를 만들 수 있음 + +```tsx +const CounterContext = createContext<{ + state: State; + dispatch: React.Dispatch; +} | null>(null); + +function CounterProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState); + return ( + + {children} + + ); +} +``` + +Redux Toolkit의 `createSlice` + `configureStore` 패턴과 개념적으로 유사함 (규모·도구는 다름) + +--- + +## 주의사항 + +- `dispatch` 직후 `state`를 읽으면 **아직 이전 값**임 (다음 렌더에 반영) +- 새 state가 `Object.is`로 이전과 같으면 React는 리렌더를 건너뜀 +- Strict Mode(개발)에서는 reducer가 두 번 호출될 수 있음 → reducer는 반드시 순수해야 함 + +--- + +## 체크리스트 + +- 업데이트 종류(액션)가 여러 개인가? +- state 구조가 객체/배열로 복잡한가? +- reducer를 컴포넌트 밖으로 빼 테스트하고 싶은가? +- dispatch만 내려보내면 props Drilling을 줄일 수 있는가? diff --git a/keyword/chapter09/zustand.md b/keyword/chapter09/zustand.md new file mode 100644 index 00000000..a4d5f151 --- /dev/null +++ b/keyword/chapter09/zustand.md @@ -0,0 +1,290 @@ +# Zustand + +> 참고 +> +> - [Zustand GitHub README](https://github.com/pmndrs/zustand/blob/main/README.md) +> - [Zustand Live Demo](https://zustand-demo.pmnd.rs/) +> - [Beginner TypeScript Guide | Zustand Docs](https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/beginner-typescript.md) + +--- + +## Zustand란? + +독일어 “상태(Zustand)”에서 이름을 딴, **작고 빠른** React용 전역 상태 라이브러리임 + +- **Hook 기반 API** — store 자체가 `useStore` Hook +- **Provider 불필요** (기본 사용) — 컴포넌트 어디서든 import해서 사용 +- **selector**로 필요한 slice만 구독 → 불필요한 리렌더 감소 +- 번들 크기 약 **1KB** 수준 (공식 README / 커뮤니티 벤치마크) + +--- + +## 왜 사용할까? + +| 장점 | 설명 | +| --- | --- | +| 보일러플레이트 적음 | Redux 대비 action/reducer/store 설정이 짧음 | +| 선택적 구독 | `(state) => state.user`처럼 필요한 필드만 구독 | +| Provider 없음 | Context Provider Hell 없이 module-level store | +| React 밖에서도 접근 | `getState()`, `setState()`, `subscribe()` | +| TypeScript | `create()(...)` curried form으로 타입 추론 우수 | +| 미들웨어 | `persist`, `devtools`, `immer` 등 | + +Context API와 달리 **value 전체 구독**이 아니라, selector가 반환한 값만 비교함 (`useSyncExternalStore` 기반) + +--- + +## 기본 사용법 + +### 1) Store 만들기 + +```tsx +import { create } from 'zustand'; + +interface BearStore { + bears: number; + increase: () => void; + removeAll: () => void; +} + +export const useBearStore = create((set) => ({ + bears: 0, + increase: () => set((state) => ({ bears: state.bears + 1 })), + removeAll: () => set({ bears: 0 }), +})); +``` + +- `set` — partial state를 **merge** (기본). 두 번째 인자 `true`면 replace +- `get` — action 안에서 현재 state 읽기 + +```tsx +const useSoundStore = create((set, get) => ({ + sound: 'grunt', + action: () => { + const sound = get().sound; + // sound 기반 로직... + }, +})); +``` + +### 2) 컴포넌트에서 사용 + +```tsx +function BearCounter() { + const bears = useBearStore((state) => state.bears); + const increase = useBearStore((state) => state.increase); + + return ( +
+

{bears} bears

+ +
+ ); +} +``` + +여러 필드를 객체로 selector에 넣을 때는 **매 렌더마다 새 객체**가 생기면 불필요한 리렌더가 날 수 있음 → `useShallow` 사용 권장 + +```tsx +import { useShallow } from 'zustand/react/shallow'; + +const { bears, increase } = useBearStore( + useShallow((state) => ({ bears: state.bears, increase: state.increase })), +); +``` + +--- + +## 중요한 개념 + +### set + +- `set({ bears: 1 })` — 기존 state에 merge +- `set({ bears: 0 }, true)` — state **전체 교체** (actions 함수 등 날아갈 수 있어 주의) +- `set((state) => ({ bears: state.bears + 1 }))` — 함수형 업데이트 + +### get + +action·비동기 로직 안에서 **최신 state**를 읽을 때 사용 + +```tsx +fetchItems: async () => { + const { page } = get(); + const data = await fetchPage(page); + set({ items: data }); +}, +``` + +### 선택적 구독 (selector) + +```tsx +// user만 구독 — theme 변경 시 이 컴포넌트는 리렌더 안 됨 +const user = useUserStore((state) => state.user); + +// theme만 구독 +const theme = useUserStore((state) => state.theme); +``` + +내부적으로 `useSyncExternalStore` + `Object.is` 비교로 selector 결과가 바뀔 때만 리렌더 + +--- + +## 객체 상태 관리 예시 + +```tsx +interface User { + name: string; + role: string; +} + +interface UserStore { + user: User | null; + theme: 'light' | 'dark'; + setUser: (user: User | null) => void; + setTheme: (theme: 'light' | 'dark') => void; +} + +export const useUserStore = create((set) => ({ + user: null, + theme: 'light', + setUser: (user) => set({ user }), + setTheme: (theme) => set({ theme }), +})); +``` + +--- + +## 비동기 로직 예시 + +Zustand는 store 안에서 async action을 자유롭게 정의할 수 있음 + +```tsx +interface TodoStore { + todos: string[]; + loading: boolean; + fetchTodos: () => Promise; +} + +export const useTodoStore = create((set) => ({ + todos: [], + loading: false, + fetchTodos: async () => { + set({ loading: true }); + try { + const res = await fetch('/api/todos'); + const todos = await res.json(); + set({ todos, loading: false }); + } catch { + set({ loading: false }); + } + }, +})); +``` + +서버 데이터 **캐싱·동기화·중복 요청 제거**까지 필요하면 TanStack Query가 더 적합함 (클라이언트 UI state와 분리) + +--- + +## Persist 미들웨어 + +localStorage 등에 state를 자동 저장 + +```tsx +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface AuthStore { + token: string | null; + setToken: (token: string | null) => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + setToken: (token) => set({ token }), + }), + { name: 'auth-storage' }, + ), +); +``` + +--- + +## Immer 미들웨어 + +중첩 객체 업데이트를 mutable 문법으로 작성 + +```tsx +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +interface NestedStore { + user: { profile: { name: string } }; + rename: (name: string) => void; +} + +export const useNestedStore = create()( + immer((set) => ({ + user: { profile: { name: '' } }, + rename: (name) => + set((state) => { + state.user.profile.name = name; + }), + })), +); +``` + +--- + +## Zustand vs Context API + +| 항목 | Context API | Zustand | +| --- | --- | --- | +| 구독 단위 | Provider `value` **전체** | selector로 **일부** | +| Provider | 필수 | 기본 불필요 | +| 리렌더 | value 참조 변경 → 모든 consumer 재평가 | selector 결과 변경 시만 | +| 외부 접근 | 어려움 | `getState()` / `subscribe()` | +| 적합한 경우 | 가끔 바뀌는 전역 값 (테마, locale) | 자주 바뀌거나 필드가 많은 전역 state | + +Context 분리·`useMemo`로 value 안정화해도 **필드 단위 구독**은 기본 제공하지 않음 + +--- + +## React Context와 함께 쓰기 (스코프 store) + +테스트·SSR에서 store를 트리별로 격리하려면 vanilla store + Context 패턴 사용 (v4+, 공식 README) + +```tsx +import { createContext, useContext } from 'react'; +import { createStore, useStore } from 'zustand'; + +type CounterStore = { count: number; inc: () => void }; + +const CounterContext = createContext> | null>(null); + +function CounterProvider({ children }: { children: React.ReactNode }) { + const [store] = useState(() => + createStore((set) => ({ + count: 0, + inc: () => set((s) => ({ count: s.count + 1 })), + })), + ); + return {children}; +} + +function useCounterStore(selector: (s: CounterStore) => T) { + const store = useContext(CounterContext); + if (!store) throw new Error('CounterProvider 필요'); + return useStore(store, selector); +} +``` + +--- + +## 체크리스트 + +- Context로 value 전체 구독 때문에 리렌더가 문제인가? +- selector가 **원시값** 또는 `useShallow`로 안정적인가? +- 서버 API 데이터인가 → Zustand보다 TanStack Query 우선 검토 +- persist/devtools가 필요한가 → middleware 조합 diff --git a/mission/chapter09/eslint.config.js b/mission/chapter09/eslint.config.js new file mode 100644 index 00000000..5e6b472f --- /dev/null +++ b/mission/chapter09/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/mission/chapter09/index.html b/mission/chapter09/index.html new file mode 100644 index 00000000..fcbc9a2a --- /dev/null +++ b/mission/chapter09/index.html @@ -0,0 +1,12 @@ + + + + + + UMC Play List — Shopping Cart + + +
+ + + diff --git a/mission/chapter09/package.json b/mission/chapter09/package.json new file mode 100644 index 00000000..237af8c7 --- /dev/null +++ b/mission/chapter09/package.json @@ -0,0 +1,18 @@ +{ + "name": "chapter09-mission", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.11.2", + "@tailwindcss/vite": "^4.2.2", + "react-redux": "^9.2.0", + "tailwindcss": "^4.2.2" + } +} diff --git a/mission/chapter09/src/App.css b/mission/chapter09/src/App.css new file mode 100644 index 00000000..0e464efd --- /dev/null +++ b/mission/chapter09/src/App.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body, #root { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + background-color: #111111; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} diff --git a/mission/chapter09/src/App.tsx b/mission/chapter09/src/App.tsx new file mode 100644 index 00000000..490799a4 --- /dev/null +++ b/mission/chapter09/src/App.tsx @@ -0,0 +1,17 @@ +import { BrowserRouter, Route, Routes } from 'react-router'; +import RootLayout from './layouts/RootLayout'; +import CartPage from './pages/CartPage'; + +function App() { + return ( + + + }> + } /> + + + + ); +} + +export default App; diff --git a/mission/chapter09/src/components/CartItemCard.tsx b/mission/chapter09/src/components/CartItemCard.tsx new file mode 100644 index 00000000..4b35b685 --- /dev/null +++ b/mission/chapter09/src/components/CartItemCard.tsx @@ -0,0 +1,70 @@ +import type { CartItem } from '../types/cart'; + +interface CartItemCardProps { + item: CartItem; + onIncrease: (id: string) => void; + onDecrease: (id: string) => void; + onRemove: (id: string) => void; +} + +function formatPrice(price: string) { + return `${Number(price).toLocaleString('ko-KR')}원`; +} + +function CartItemCard({ item, onIncrease, onDecrease, onRemove }: CartItemCardProps) { + const lineTotal = item.amount * Number(item.price); + + return ( +
+ {`${item.title} + +
+

{item.title}

+

{item.singer}

+

+ {formatPrice(item.price)} + + × {item.amount} = {lineTotal.toLocaleString('ko-KR')}원 + +

+
+ +
+
+ + + {item.amount} + + +
+ +
+
+ ); +} + +export default CartItemCard; diff --git a/mission/chapter09/src/components/Footer.tsx b/mission/chapter09/src/components/Footer.tsx new file mode 100644 index 00000000..dd450eaf --- /dev/null +++ b/mission/chapter09/src/components/Footer.tsx @@ -0,0 +1,13 @@ +function Footer() { + return ( +
+
+

UMC Play List

+

© {new Date().getFullYear()} UMC 10th Web — Redux Toolkit Mission

+

Mock LP 데이터 · Tailwind CSS · TypeScript

+
+
+ ); +} + +export default Footer; diff --git a/mission/chapter09/src/components/Modal.tsx b/mission/chapter09/src/components/Modal.tsx new file mode 100644 index 00000000..fc6c1738 --- /dev/null +++ b/mission/chapter09/src/components/Modal.tsx @@ -0,0 +1,59 @@ +import { clearCart } from '../features/cart/cartSlice'; +import { closeModal } from '../features/modal/modalSlice'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; + +function Modal() { + const dispatch = useAppDispatch(); + const isOpen = useAppSelector((state) => state.modal.isOpen); + + if (!isOpen) { + return null; + } + + const handleCancel = () => { + dispatch(closeModal()); + }; + + const handleConfirm = () => { + dispatch(clearCart()); + dispatch(closeModal()); + }; + + return ( +
+
+

+ 장바구니 전체 삭제 +

+

+ 장바구니에 담긴 모든 음반을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ ); +} + +export default Modal; diff --git a/mission/chapter09/src/components/Navbar.tsx b/mission/chapter09/src/components/Navbar.tsx new file mode 100644 index 00000000..ab28a542 --- /dev/null +++ b/mission/chapter09/src/components/Navbar.tsx @@ -0,0 +1,31 @@ +import { Link } from 'react-router'; +import { useAppSelector } from '../store/hooks'; + +function Navbar() { + const amount = useAppSelector((state) => state.cart.amount); + + return ( +
+
+ + UMC Play List + + +
+
+ ); +} + +export default Navbar; diff --git a/mission/chapter09/src/constants/cartItems.ts b/mission/chapter09/src/constants/cartItems.ts new file mode 100644 index 00000000..ea1e2329 --- /dev/null +++ b/mission/chapter09/src/constants/cartItems.ts @@ -0,0 +1,102 @@ +import type { CartItem } from '../types/cart'; + +const cartItems: CartItem[] = [ + { + id: 'recB6qcHPxb62YJ75', + title: 'Vancouver', + singer: 'BIG Naughty (서동현)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/500/40752/4075248.jpg', + amount: 1, + }, + { + id: 'recdRxBsE14Rr2VuJ', + title: 'Empty Island', + singer: 'greenblue', + price: '18000', + img: 'https://f4.bcbits.com/img/a1472100223_10.jpg', + amount: 1, + }, + { + id: 'recwTo120XST3PIoW', + title: 'golden hour', + singer: 'JVKE', + price: '28000', + img: 'https://image.bugsm.co.kr/album/images/200/193874/19387484.jpg?version=20230503022513.0', + amount: 1, + }, + { + id: 'rec1JZlfCIBOPdcT2', + title: 'Home Sweet Home(From "어쩌면 우린 헤어졌는지 모른다")', + singer: 'Gogang (고갱)', + price: '20000', + img: 'https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/8d/d7/0f/8dd70fba-0a8f-b7ce-a2d2-f0d32dad2837/8809912894132.jpg/1200x1200bf-60.jpg', + amount: 1, + }, + { + id: 'recwTo160XST3PIoW', + title: 'Lemon', + singer: 'Kenshi Yonezu(켄시 요네즈/米津 玄師)', + price: '30000', + img: 'https://image.bugsm.co.kr/album/images/200/7222/722272.jpg?version=20220514022202.0', + amount: 1, + }, + { + id: 'recaBo120XST3PIoW', + title: '돌멩이', + singer: 'MASYTA (마시따)', + price: '12000', + img: 'https://image.bugsm.co.kr/album/images/200/3271/327113.jpg?version=20230606014806.0', + amount: 1, + }, + { + id: 'recqBo123XST3PIoK', + title: "L'Amour, Les Baguettes, Paris", + singer: '스텔라 장(Stella Jang)', + price: '32000', + img: 'https://image.bugsm.co.kr/album/images/200/40660/4066056.jpg?version=20211020003912.0', + amount: 1, + }, + { + id: 'recqBo133XST3PIoK', + title: 'NO PAIN', + singer: '실리카겔', + price: '22000', + img: 'https://image.bugsm.co.kr/album/images/200/40790/4079061.jpg?version=20220826063340.0', + amount: 1, + }, + { + id: 'recqBo145XST3PIoK', + title: '너에게 (feat. HYUN SEO)', + singer: 'Halsoon', + price: '20000', + img: 'https://image.bugsm.co.kr/album/images/200/204634/20463445.jpg?version=20230110013144.0', + amount: 1, + }, + { + id: 'recqBo129XST3PIoK', + title: '널 떠올리는 중이야(Think About You)', + singer: 'PATEKO (파테코) , Jayci yucca(제이씨 유카)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/200/40581/4058181.jpg?version=20210726063528.0', + amount: 1, + }, + { + id: 'rdaqBo129XST3PIoK', + title: '끝나지 않은 얘기(feat. 다이나믹 듀오)', + singer: '릴러말즈 & TOIL', + price: '23000', + img: 'https://image.bugsm.co.kr/album/images/200/204692/20469237.jpg?version=20220827004220.0', + amount: 1, + }, + { + id: 'rdaqBo149XQT3PIoK', + title: '각자의 밤', + singer: '나상현씨 밴드', + price: '21000', + img: 'https://image.bugsm.co.kr/album/images/200/202235/20223594.jpg?version=20230904194021.0', + amount: 1, + }, +]; + +export default cartItems; diff --git a/mission/chapter09/src/features/cart/cartSlice.ts b/mission/chapter09/src/features/cart/cartSlice.ts new file mode 100644 index 00000000..df31a9ef --- /dev/null +++ b/mission/chapter09/src/features/cart/cartSlice.ts @@ -0,0 +1,69 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import cartItems from '../../constants/cartItems'; +import type { CartState } from '../../types/cart'; + +function computeTotals(items: CartState['cartItems']) { + return { + amount: items.reduce((sum, item) => sum + item.amount, 0), + total: items.reduce((sum, item) => sum + item.amount * Number(item.price), 0), + }; +} + +const initialTotals = computeTotals(cartItems); + +const initialState: CartState = { + cartItems, + amount: initialTotals.amount, + total: initialTotals.total, +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + increase: (state, action: PayloadAction) => { + const item = state.cartItems.find(({ id }) => id === action.payload); + if (item) { + item.amount += 1; + } + const totals = computeTotals(state.cartItems); + state.amount = totals.amount; + state.total = totals.total; + }, + decrease: (state, action: PayloadAction) => { + const item = state.cartItems.find(({ id }) => id === action.payload); + if (!item) return; + + if (item.amount <= 1) { + state.cartItems = state.cartItems.filter(({ id }) => id !== action.payload); + } else { + item.amount -= 1; + } + + const totals = computeTotals(state.cartItems); + state.amount = totals.amount; + state.total = totals.total; + }, + removeItem: (state, action: PayloadAction) => { + state.cartItems = state.cartItems.filter(({ id }) => id !== action.payload); + const totals = computeTotals(state.cartItems); + state.amount = totals.amount; + state.total = totals.total; + }, + clearCart: (state) => { + state.cartItems = []; + state.amount = 0; + state.total = 0; + }, + calculateTotals: (state) => { + const totals = computeTotals(state.cartItems); + state.amount = totals.amount; + state.total = totals.total; + }, + }, +}); + +export const { increase, decrease, removeItem, clearCart, calculateTotals } = + cartSlice.actions; + +export default cartSlice.reducer; diff --git a/mission/chapter09/src/features/modal/modalSlice.ts b/mission/chapter09/src/features/modal/modalSlice.ts new file mode 100644 index 00000000..9f12413c --- /dev/null +++ b/mission/chapter09/src/features/modal/modalSlice.ts @@ -0,0 +1,26 @@ +import { createSlice } from '@reduxjs/toolkit'; + +interface ModalState { + isOpen: boolean; +} + +const initialState: ModalState = { + isOpen: false, +}; + +const modalSlice = createSlice({ + name: 'modal', + initialState, + reducers: { + openModal: (state) => { + state.isOpen = true; + }, + closeModal: (state) => { + state.isOpen = false; + }, + }, +}); + +export const { openModal, closeModal } = modalSlice.actions; + +export default modalSlice.reducer; diff --git a/mission/chapter09/src/layouts/RootLayout.tsx b/mission/chapter09/src/layouts/RootLayout.tsx new file mode 100644 index 00000000..3defbc0c --- /dev/null +++ b/mission/chapter09/src/layouts/RootLayout.tsx @@ -0,0 +1,19 @@ +import { Outlet } from 'react-router'; +import Footer from '../components/Footer'; +import Modal from '../components/Modal'; +import Navbar from '../components/Navbar'; + +function RootLayout() { + return ( +
+ +
+ +
+
+ +
+ ); +} + +export default RootLayout; diff --git a/mission/chapter09/src/main.tsx b/mission/chapter09/src/main.tsx new file mode 100644 index 00000000..5d93e305 --- /dev/null +++ b/mission/chapter09/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import './App.css'; +import App from './App.tsx'; +import { store } from './store/store'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/mission/chapter09/src/pages/CartPage.tsx b/mission/chapter09/src/pages/CartPage.tsx new file mode 100644 index 00000000..d7e56e1f --- /dev/null +++ b/mission/chapter09/src/pages/CartPage.tsx @@ -0,0 +1,94 @@ +import CartItemCard from '../components/CartItemCard'; +import { + calculateTotals, + decrease, + increase, + removeItem, +} from '../features/cart/cartSlice'; +import { openModal } from '../features/modal/modalSlice'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; + +function CartPage() { + const dispatch = useAppDispatch(); + const { cartItems, amount, total } = useAppSelector((state) => state.cart); + + const handleIncrease = (id: string) => { + dispatch(increase(id)); + dispatch(calculateTotals()); + }; + + const handleDecrease = (id: string) => { + dispatch(decrease(id)); + dispatch(calculateTotals()); + }; + + const handleRemove = (id: string) => { + dispatch(removeItem(id)); + dispatch(calculateTotals()); + }; + + const handleOpenClearModal = () => { + dispatch(openModal()); + }; + + return ( +
+
+
+

Shopping Cart

+

+ Redux Toolkit으로 관리되는 LP 장바구니입니다. +

+
+ {cartItems.length > 0 && ( + + )} +
+ + {cartItems.length === 0 ? ( +
+

장바구니가 비어 있습니다.

+

음반을 추가해 보세요.

+
+ ) : ( +
+
+ {cartItems.map((item) => ( + + ))} +
+ + +
+ )} +
+ ); +} + +export default CartPage; diff --git a/mission/chapter09/src/store/hooks.ts b/mission/chapter09/src/store/hooks.ts new file mode 100644 index 00000000..79881337 --- /dev/null +++ b/mission/chapter09/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/mission/chapter09/src/store/store.ts b/mission/chapter09/src/store/store.ts new file mode 100644 index 00000000..c0a99189 --- /dev/null +++ b/mission/chapter09/src/store/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from '../features/cart/cartSlice'; +import modalReducer from '../features/modal/modalSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + modal: modalReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/mission/chapter09/src/types/cart.ts b/mission/chapter09/src/types/cart.ts new file mode 100644 index 00000000..947b8a3f --- /dev/null +++ b/mission/chapter09/src/types/cart.ts @@ -0,0 +1,14 @@ +export interface CartItem { + id: string; + title: string; + singer: string; + price: string; + img: string; + amount: number; +} + +export interface CartState { + cartItems: CartItem[]; + amount: number; + total: number; +} diff --git a/mission/chapter09/tsconfig.app.json b/mission/chapter09/tsconfig.app.json new file mode 100644 index 00000000..2ee5b5f8 --- /dev/null +++ b/mission/chapter09/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/mission/chapter09/tsconfig.json b/mission/chapter09/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/mission/chapter09/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/mission/chapter09/tsconfig.node.json b/mission/chapter09/tsconfig.node.json new file mode 100644 index 00000000..5053a3fb --- /dev/null +++ b/mission/chapter09/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "lib": ["ES2023"], + "types": ["node"], + "jsx": null + }, + "include": ["vite.config.ts"] +} diff --git a/mission/chapter09/vite.config.ts b/mission/chapter09/vite.config.ts new file mode 100644 index 00000000..da17dafa --- /dev/null +++ b/mission/chapter09/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbab8f5a..25a2b137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,42 @@ importers: specifier: ^4.3.6 version: 4.3.6 + mission/chapter08: + dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.74.0(react@19.2.4)) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.2(@types/node@24.12.0)(jiti@2.6.1)) + axios: + specifier: ^1.14.0 + version: 1.14.0 + react-hook-form: + specifier: ^7.74.0 + version: 7.74.0(react@19.2.4) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 + + mission/chapter09: + dependencies: + '@reduxjs/toolkit': + specifier: ^2.11.2 + version: 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.2(@types/node@24.12.0)(jiti@2.6.1)) + react-redux: + specifier: ^9.2.0 + version: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + practice/chapter02: {} practice/chapter03/router: {} @@ -507,6 +543,17 @@ packages: '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.11': resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -608,6 +655,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -742,6 +792,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.57.2': resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1192,6 +1245,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1450,6 +1506,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-router@7.13.2: resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} engines: {node: '>=20.0.0'} @@ -1464,9 +1532,20 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1562,6 +1641,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite-node@6.0.0: resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1932,6 +2016,18 @@ snapshots: '@oxc-project/types@0.122.0': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.4 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0-rc.11': optional: true @@ -1983,6 +2079,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@tailwindcss/node@4.2.2': @@ -2089,6 +2187,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2628,6 +2728,8 @@ snapshots: ignore@7.0.5: {} + immer@11.1.8: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -2834,6 +2936,15 @@ snapshots: dependencies: react: 19.2.4 + react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: cookie: 1.1.1 @@ -2844,8 +2955,16 @@ snapshots: react@19.2.4: {} + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + require-like@0.1.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} rolldown@1.0.0-rc.11: @@ -2938,6 +3057,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vite-node@6.0.0(@types/node@24.12.0)(jiti@2.6.1): dependencies: cac: 7.0.0