diff --git a/02-state-management-tools/2.2-Zustand/README.md b/02-state-management-tools/2.2-Zustand/README.md new file mode 100644 index 0000000..c40ef92 --- /dev/null +++ b/02-state-management-tools/2.2-Zustand/README.md @@ -0,0 +1,46 @@ +# 02-2. 전역상태관리 - Zustand + +## 🎯 요구사항 + +- Context API로 구성한 애플리케이션을 Zustand 기반 전역 상태로 마이그레이션하세요. +- props에 대한 요구사항은 2-1 요구사항과 같습니다. +- Zustand를 **왜** 사용하는지, Context API와 비교했을때 어떤 점이 달랐는지, 또 trade-off가 있는지 적어주세요. + - 기술적인 것도 좋고 개발자의 경험 측면에서도 좋습니다. +- (선택): 카테고리 필터의 선택된 카테고리가 새로고침 후에도 유지되도록 구현해보세요 + +### 😗구현 예시 + +- 컴포넌트의 이름이나 구조를 정한 이유가 명확해야하며 타인에게 설명할 수 있어야합니다. +- 아래 코드는 Zustand 스토어를 설정하는 예시입니다. + +```javascript +import { create } from "zustand"; + +// Zustand 스토어 예시 +export const useBear = create((set) => ({ + // state + bears: 0, + + // actions + increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), + removeAllBears: () => set({ bears: 0 }), + updateBears: (newBears) => set({ bears: newBears }), +})); +``` + +## ✅ 키워드 + +- props drilling +- 전역상태관리 + - Zustand + - create + - set / get + +## 🧙‍♀️ 진행 가이드 + +- 진행시간 : 2시간 내에 완료하는 것을 목표로 합니다. + +## 🔗 참고 문서 + +- [Zustand 공식문서](https://recoiljs.org/docs/introduction/installation/) +- [Zustand와 React Context](https://tkdodo.eu/blog/zustand-and-react-context) \ No newline at end of file diff --git a/README.md b/README.md index 20db4bc..76b832a 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,155 @@ -# Context API를 사용해서 전역상태관리하기 +# Zustand를 사용해서 전역 상태 관리하기 ## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드 이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다. -1. `createContext`, `Provider`, `useContext` 세 가지 개념의 역할을 명확히 이해하고 직접 구현한다. -2. props drilling이 발생하는 지점을 코드에서 직접 파악하고 Context로 해결하는 경험을 쌓는다. -3. UI 상태와 데이터 상태를 구분해서 Context 적용 범위를 스스로 판단하는 능력을 기른다. +1. Context API로 구현된 앱을 Zustand로 마이그레이션하면서 두 방식의 차이를 체감한다. +2. `create`, `set`, `get`, selector 개념을 직접 사용하며 Zustand 스토어 구조를 익힌다. +3. 전역 상태로 관리할 것과 로컬 상태로 유지할 것을 스스로 판단하는 능력을 기른다. --- ## 📝 기능 구현 목록 -- [x] `RestaurantsContext` 생성 및 `RestaurantsProvider` 구현 -- [x] `useRestaurants` 훅의 데이터(`restaurants`, `addRestaurant`, `isLoading`, `error`)를 Context로 관리 -- [x] `RestaurantList`에서 `useContext`로 `restaurants`, `isLoading`, `error` 직접 구독 -- [x] `AddRestaurantModal`에서 `useContext`로 `addRestaurant` 직접 호출 (`onSubmit` prop 제거) -- [x] `App.jsx`에서 음식점 관련 state/handler 제거, UI 상태(`selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen`)만 유지 +- [x] `useRestaurantStore` 생성 — `restaurants`, `addRestaurant`, `isLoading`, `error`, `fetchRestaurants` 포함 +- [x] `RestaurantList`에서 selector로 스토어 구독, `useEffect`로 초기 데이터 fetch +- [x] `AddRestaurantModal`에서 스토어의 `addRestaurant` 직접 호출 +- [x] `App.jsx`에서 `RestaurantsProvider` 제거 +- [x] `selectedCategory`를 스토어로 이동 및 `persist` 미들웨어로 새로고침 후에도 유지 --- ## 📚 학습 내용 -### Context API란? +### Zustand 핵심 개념 -컴포넌트 트리 전체에 데이터를 전달할 수 있는 React 내장 기능이다. props를 통해 중간 컴포넌트를 거치지 않고, 필요한 컴포넌트가 데이터를 직접 꺼내 쓸 수 있게 한다. - -### createContext / Provider / useContext - -세 가지 API가 각각 다른 역할을 담당한다. - -| 개념 | 역할 | 설명 | -|---|---|---| -| `createContext` | Context 객체 생성 | 데이터를 담을 컨테이너를 정의한다. 초기값을 인자로 받으며, Provider 없이 `useContext`를 호출했을 때 이 초기값이 반환된다. | -| `Provider` | Context 값 공급 | `value` prop으로 전달한 데이터를 하위 컴포넌트 트리 전체에 주입한다. `value`가 변경되면 해당 Context를 구독 중인 모든 컴포넌트가 리렌더링된다. | -| `useContext` | Context 값 구독 | 가장 가까운 상위 Provider의 `value`를 반환한다. Provider가 없으면 `createContext`의 초기값을 반환한다. | +| 개념 | 설명 | +|---|---| +| `create` | 스토어를 생성한다. 반환값이 훅이라 `useRestaurantStore()`로 바로 사용한다. | +| `set` | 상태를 업데이트한다. 얕은 병합(shallow merge)이라 바꾸지 않는 필드는 그대로 유지된다. | +| `get` | 액션 안에서 현재 스토어 상태를 읽거나 다른 액션을 호출할 때 사용한다. | +| selector | `useStore((state) => state.xxx)` 형태로 필요한 상태만 구독한다. 해당 값이 바뀔 때만 리렌더링된다. | ```js -// 1. createContext — 공간 생성 -export const RestaurantsContext = createContext(null); - -// 2. Provider — 데이터를 채워서 하위 컴포넌트에 공급 -export function RestaurantsProvider({ children }) { - const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); - return ( - - {children} - - ); -} - -// 3. useContext — 필요한 컴포넌트에서 직접 꺼냄 -const { restaurants } = useContext(RestaurantsContext); +const useRestaurantStore = create((set, get) => ({ + // 상태 + restaurants: [], + isLoading: false, + error: null, + + // 액션 + fetchRestaurants: async () => { + set({ isLoading: true, error: null }); + try { + const data = await getRestaurants(); + set({ restaurants: data }); + } catch { + set({ error: "음식점 목록을 불러오지 못했습니다." }); + } finally { + set({ isLoading: false }); + } + }, + addRestaurant: async (restaurant) => { + await createRestaurant(restaurant); + await get().fetchRestaurants(); // 다른 액션 호출 + }, +})); ``` -### React 18 vs React 19 Provider 문법 차이 +### persist 미들웨어 -React 19부터는 Context 객체 자체를 Provider로 사용할 수 있다. 공식문서가 React 19 기준으로 업데이트되었으므로 버전 확인이 필요하다. +스토어의 상태를 localStorage에 자동으로 저장/복원해주는 Zustand 내장 미들웨어다. `partialize`로 저장할 상태만 선택할 수 있다. -```jsx -// React 18 — .Provider 필요 - - -// React 19 — Context 자체를 Provider로 사용 가능 - +```js +const useRestaurantStore = create( + persist( + (set, get) => ({ ... }), + { + name: "restaurant-storage", + partialize: (state) => ({ selectedCategory: state.selectedCategory }), + } + ) +); ``` +`restaurants`는 서버에서 매번 가져오므로 저장할 필요가 없고, `selectedCategory`만 persist 대상으로 지정했다. + --- -## 🤔 고민했던 문제와 해결 과정에서 배운 점 +## 🤔 Zustand를 왜 사용하는가 — Context API와 비교 -### 무엇을 Context에 넣을 것인가 +### Context API는 전역 상태 관리 도구가 아니다 -Context에 모든 state를 넣는 게 아니라, **데이터 도메인**과 **UI 상태**를 구분하는 것이 핵심이다. +Context API는 원래 **prop drilling 해결 도구**다. 상태 관리를 하려면 `useState`/`useReducer`를 별도로 조합해야 하고, 그 결과물을 Provider로 감싸야 한다. 이번 마이그레이션에서 `useRestaurants` 훅 + `RestaurantsContext` + `useRestaurantsContext` 세 파일이 `useRestaurantStore` 하나로 줄어든 게 그 차이다. -| 구분 | 상태 | 이유 | -|---|---|---| -| Context (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 | -| props / 로컬 state (UI 상태) | `selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen` | 특정 화면의 인터랙션 상태로, App이 관리하는 게 자연스러움 | +### 달랐던 점 -판단 기준은 "그 데이터가 App 고유의 책임인가?"다. `addRestaurant`는 음식점 데이터 도메인의 책임이므로 Context가 적합하고, `selectedCategory`는 화면 UI의 책임이므로 로컬 state가 적합하다. +**Provider가 없다** -### Provider 위치 결정 +Context는 ``로 트리를 감싸야 했지만, Zustand는 Provider 없이 어떤 컴포넌트에서든 스토어에 바로 접근한다. -처음에는 `RestaurantsProvider`를 App의 return 안에 배치했다. 이 경우 App 자신이 `useContext`로 Context 데이터를 꺼낼 수 없다는 문제가 생긴다. `useContext`는 자신보다 **상위에 있는** Provider를 찾기 때문이다. +**리렌더링 최적화** -해결책은 두 가지였다. +Context는 value 안의 어떤 값이 바뀌어도 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다. Zustand는 selector로 필요한 상태만 구독하기 때문에, `restaurants`가 바뀌어도 `addRestaurant` 액션만 구독하는 컴포넌트는 리렌더링되지 않는다. -1. Provider를 `main.jsx`로 올려서 App도 Context에 접근 가능하게 만들기 -2. App이 Context를 쓸 필요가 없도록 구조를 바꾸기 +```js +// AddRestaurantModal — addRestaurant만 구독하므로 +// restaurants, isLoading, error가 바뀌어도 리렌더링되지 않는다 +const addRestaurant = useRestaurantStore((state) => state.addRestaurant); +``` -`AddRestaurantModal`이 `addRestaurant`를 Context에서 직접 꺼내도록 하면 App은 `addRestaurant`를 전혀 알 필요가 없어진다. `isLoading`, `error`도 `RestaurantList`가 직접 보여주면 된다. 결과적으로 App이 Context를 쓰지 않아도 되는 구조가 만들어졌고, Provider 위치 문제도 자연스럽게 해결됐다. +### Trade-off -### filteredRestaurants를 어디서 처리할 것인가 +**Context가 나은 경우** -기존에는 `App`이 `filteredRestaurants`를 계산해서 `RestaurantList`에 내려줬다. Context 도입 후 `restaurants`는 Context에서 오고, `selectedCategory`는 UI 상태로 props로 전달하게 됐다. +- 외부 라이브러리 없이 React만으로 해결 가능 +- 테마, 로케일처럼 변경이 거의 없는 정적 값은 Context가 오히려 적합 +- Provider 범위로 상태의 생명주기가 명확하게 제어되어야 할 때 -``` -// 변경 후 -RestaurantList에서 Context로 restaurants를 꺼내고, -props로 받은 selectedCategory로 필터링을 직접 처리 -``` +**Zustand의 단점** -`selectedCategory`를 props로 유지한 이유: UI 상태이므로 Context보다 props가 더 자연스럽다. 미션 요구사항에도 "props를 쓴다면 그 이유를 PR에 적어주세요"라고 명시되어 있으므로, 이 판단 근거를 기록한다. +- 스토어가 전역이라 어디서든 접근 가능한 게 장점이지만, 반대로 상태가 어디서 변경되는지 추적하기 어려워질 수 있다 +- Context는 Provider 범위로 상태의 생명주기가 명확한 반면, Zustand 스토어는 앱 전체에서 살아있다 --- -## 🛠 리팩토링 +## 🤔 고민했던 문제와 해결 과정에서 배운 점 -### App.jsx — 음식점 데이터 책임 제거 +### 무엇을 스토어에 넣을 것인가 -Context 도입 전 App은 `useRestaurants`를 직접 호출하고, 모든 handler를 가지고 있었다. 리팩토링 후 App은 UI 상태만 관리한다. +스토어에 모든 상태를 넣는 게 아니라, **전역 상태가 필요한 조건**을 기준으로 판단했다. -```js -// before — App이 데이터와 UI 상태를 모두 관리 -const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); -const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); -async function handleRestaurantSubmit(restaurant) { ... } - -// after — UI 상태만 남음 -const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY); -const [clickedRestaurant, setClickedRestaurant] = useState(null); -const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] = useState(false); -``` +| 구분 | 상태 | 이유 | +|---|---|---| +| 스토어 (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 | +| 스토어 (persist 목적) | `selectedCategory` | UI 상태이지만 새로고침 후 유지를 위해 스토어로 이동 | +| 로컬 state (UI 상태) | `clickedRestaurant`, `isAddRestaurantModalOpen` | 해당 컴포넌트에서만 쓰이는 인터랙션 상태 | + +`selectedCategory`는 본래 UI 상태이므로 `useState`가 자연스럽다. 다만 새로고침 후 유지(`persist`)는 Zustand 스토어에만 적용할 수 있기 때문에 기술적인 이유로 스토어로 이동했다. 이 판단은 목적이 명확하므로 정당하지만, persist 요구사항이 없었다면 로컬 state로 유지하는 것이 맞다. + +### fetchRestaurants 호출 위치 -### RestaurantList — Context 구독 및 필터링 내부화 +Zustand 스토어는 React 컴포넌트가 아니라 `useEffect`를 쓸 수 없다. 초기 데이터 fetch는 데이터를 보여주는 컴포넌트(`RestaurantList`)가 마운트될 때 `useEffect`로 호출하는 방식으로 해결했다. ```js -// before -export default function RestaurantList({ restaurants, onRestaurantClick }) { ... } - -// after -export default function RestaurantList({ selectedCategory, onRestaurantClick }) { - const { restaurants, isLoading, error } = useContext(RestaurantsContext); - const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); - ... -} +const fetchRestaurants = useRestaurantStore((state) => state.fetchRestaurants); + +useEffect(() => { + fetchRestaurants(); +}, [fetchRestaurants]); ``` -### AddRestaurantModal — onSubmit prop 제거 +### 이벤트 핸들러와 스토어 액션의 분리 + +스토어 액션은 순수한 값만 받도록 하고, 이벤트 객체 처리는 컴포넌트에 남겼다. ```js -// before — App으로부터 onSubmit을 받아서 호출 -export default function AddRestaurantModal({ onSubmit, onClose }) { - function handleFormSubmit(e) { - e.preventDefault(); - onSubmit({ category, name, description }); - } -} +// 스토어 액션 — 값만 받는다 +setSelectedCategory: (category) => set({ selectedCategory: category }), -// after — Context에서 addRestaurant를 직접 꺼내서 호출 -export default function AddRestaurantModal({ onClose }) { - const { addRestaurant } = useContext(RestaurantsContext); - async function handleFormSubmit(e) { - e.preventDefault(); - try { - await addRestaurant({ category, name, description }); - onClose(); - } catch { - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - } - } +// 컴포넌트 — 이벤트에서 값을 꺼내는 건 UI 로직 +function handleCategoryChange(e) { + setSelectedCategory(e.target.value); } ``` - -### styled-components 선언 위치 — 컴포넌트 하단으로 이동 - -파일을 열었을 때 가장 먼저 보고 싶은 건 컴포넌트 로직이지, 스타일 세부사항이 아니다. styled-components 선언이 상단에 있으면 컴포넌트 함수가 한참 아래로 밀려 가독성이 떨어진다. 모든 컴포넌트 파일에서 styled-components 선언을 컴포넌트 함수 아래로 이동했다. diff --git a/package-lock.json b/package-lock.json index e9995c3..d46a1e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "styled-components": "^6.4.2" + "styled-components": "^6.4.2", + "zustand": "^5.0.14" }, "devDependencies": { "@types/react": "^18.2.66", @@ -1189,13 +1190,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.2.73", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.73.tgz", "integrity": "sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4364,6 +4365,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index f236331..fcd53d9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "styled-components": "^6.4.2" + "styled-components": "^6.4.2", + "zustand": "^5.0.14" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/src/App.jsx b/src/App.jsx index 85e4ae6..3531734 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,11 +5,15 @@ import RestaurantList from "./components/RestaurantList.jsx"; import { useState } from "react"; import RestaurantDetailModal from "./components/RestaurantDetailModal.jsx"; import AddRestaurantModal from "./components/AddRestaurantModal.jsx"; -import { ALL_CATEGORY } from "./constants/categories.js"; -import { RestaurantsProvider } from "./context/RestaurantsContext.jsx"; +import useRestaurantStore from "./store/useRestaurantStore.js"; function App() { - const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY); + const selectedCategory = useRestaurantStore( + (state) => state.selectedCategory, + ); + const setSelectedCategory = useRestaurantStore( + (state) => state.setSelectedCategory, + ); const [clickedRestaurant, setClickedRestaurant] = useState(null); const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] = useState(false); @@ -36,7 +40,7 @@ function App() { } return ( - + <>
)} - + ); } diff --git a/src/components/AddRestaurantModal.jsx b/src/components/AddRestaurantModal.jsx index d1639dc..2d9e4c5 100644 --- a/src/components/AddRestaurantModal.jsx +++ b/src/components/AddRestaurantModal.jsx @@ -2,13 +2,14 @@ import { useState } from "react"; import Modal from "./Modal.jsx"; import { CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; -import { useRestaurantsContext } from "../context/useRestaurantsContext.js"; +import useRestaurantStore from "../store/useRestaurantStore.js"; export default function AddRestaurantModal({ onClose }) { const [category, setCategory] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const { addRestaurant } = useRestaurantsContext(); + + const addRestaurant = useRestaurantStore((state) => state.addRestaurant); async function handleFormSubmit(e) { e.preventDefault(); diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index 21a0e93..1e4a969 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -1,15 +1,26 @@ import { CATEGORY_IMAGES } from "../constants/categoryImages.js"; import styled from "styled-components"; -import { useRestaurantsContext } from "../context/useRestaurantsContext.js"; import { filterRestaurants } from "../utils/filterRestaurants.js"; +import useRestaurantStore from "../store/useRestaurantStore.js"; +import { useEffect } from "react"; export default function RestaurantList({ selectedCategory, onRestaurantClick, }) { - const { restaurants, isLoading, error } = useRestaurantsContext(); + const restaurants = useRestaurantStore((state) => state.restaurants); + const isLoading = useRestaurantStore((state) => state.isLoading); + const error = useRestaurantStore((state) => state.error); + const fetchRestaurants = useRestaurantStore( + (state) => state.fetchRestaurants, + ); + const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); + useEffect(() => { + fetchRestaurants(); + }, [fetchRestaurants]); + return ( <> {isLoading &&

불러오는 중...

} diff --git a/src/context/RestaurantsContext.jsx b/src/context/RestaurantsContext.jsx deleted file mode 100644 index 73f1d72..0000000 --- a/src/context/RestaurantsContext.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from "react"; -import { useRestaurants } from "../hooks/useRestaurants.js"; - -export const RestaurantsContext = createContext(null); - -export function RestaurantsProvider({ children }) { - const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); - return ( - - {children} - - ); -} diff --git a/src/context/useRestaurantsContext.js b/src/context/useRestaurantsContext.js deleted file mode 100644 index 7de8263..0000000 --- a/src/context/useRestaurantsContext.js +++ /dev/null @@ -1,9 +0,0 @@ -import { useContext } from "react"; -import { RestaurantsContext } from "./RestaurantsContext"; - -export function useRestaurantsContext() { - const context = useContext(RestaurantsContext); - if (context === null) - throw new Error("RestaurantsProvider 내부에서만 사용할 수 있습니다."); - return context; -} diff --git a/src/hooks/useRestaurants.js b/src/hooks/useRestaurants.js deleted file mode 100644 index e9130eb..0000000 --- a/src/hooks/useRestaurants.js +++ /dev/null @@ -1,31 +0,0 @@ -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); - } - }, []); - - useEffect(() => { - void fetchRestaurants(); - }, [fetchRestaurants]); - - async function addRestaurant(restaurant) { - await createRestaurant(restaurant); - await fetchRestaurants(); - } - - return { restaurants, addRestaurant, isLoading, error }; -} diff --git a/src/store/useRestaurantStore.js b/src/store/useRestaurantStore.js new file mode 100644 index 0000000..31a81f0 --- /dev/null +++ b/src/store/useRestaurantStore.js @@ -0,0 +1,41 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { getRestaurants, createRestaurant } from "../api.js"; +import { ALL_CATEGORY } from "../constants/categories.js"; + +const useRestaurantStore = create( + persist( + (set, get) => ({ + // 상태 + restaurants: [], + isLoading: false, + error: null, + selectedCategory: ALL_CATEGORY, + + // 액션 + fetchRestaurants: async () => { + set({ isLoading: true, error: null }); + try { + const data = await getRestaurants(); + set({ restaurants: data }); + } catch { + set({ error: "음식점 목록을 불러오지 못했습니다." }); + } finally { + set({ isLoading: false }); + } + }, + addRestaurant: async (restaurant) => { + await createRestaurant(restaurant); + await get().fetchRestaurants(); + }, + setSelectedCategory: (category) => set({ selectedCategory: category }), + }), + { + name: "restaurant-storage", + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ selectedCategory: state.selectedCategory }), + }, + ), +); + +export default useRestaurantStore;