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
53 changes: 53 additions & 0 deletions 02-state-management-tools/2.3-TanStack Query/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 02-3. 서버상태관리 - TanStack Query

💡해당 미션은 **여러 사용자가 하나의 서버를 공유하고, 식당 목록이 주기적으로 업데이트되는 환경**을 가정하여 진행합니다.

## 🎯 요구사항

- TanStack Query를 사용해 서버 상태를 클라이언트 상태와 분리하고, 효율적인 데이터 캐싱과 요청 관리를 구현해 보세요.
- 쿼리 및 뮤테이션 설정은 명확한 이유가 있다면 자유롭게 변경해도 좋습니다.
- TanStack Query를 **왜** 사용하는지, 서버 상태와 클라이언트 상태를 분리하였을때 어떤 점이 달랐는지, 또 trade-off가 있는지 적어주세요.
- 기술적인 것도 좋고 개발자의 경험 측면에서도 좋습니다.
- TanStack Query Devtools를 이용하여 Query의 변화와 Mutation의 발생을 확인해보세요.
- (선택) 뮤테이션 로직에 낙관적 업데이트(Optimistic Update)를 적용해 보고 어떤 상황에서 낙관적 업데이트가 효과적인지, 그리고 주의해야 할 점은 무엇인지 적어주세요.
- Browser Throttling 기능을 활용하여 네트워크 속도를 느리게 설정한 뒤 낙관적 업데이트가 실제로 어떻게 동작하는지 확인해 보세요.

### 😗구현 예시

- 컴포넌트의 이름이나 구조를 정한 이유가 명확해야하며 타인에게 설명할 수 있어야합니다.
- 아래는 main.jsx의 설정 모습입니다.

```javascript
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
```

## ✅ 키워드

- props drilling
- 서버 상태관리
- TanStack Query
- QueryClient
- Query Key
- useQuery
- useMutation
- Optimistic Update

## 🧙‍♀️ 진행 가이드

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

## 🔗 참고 문서

- [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview)
- [TanStack Query 메인테이너 Tk Dodo 님의 블로그](https://tkdodo.eu/blog/tags/react-query)
- [테코톡(시모의 Tanstack Query)](https://www.youtube.com/watch?v=RfK15tw8H-I)
247 changes: 156 additions & 91 deletions README.md

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"server": "npx json-server db.json"
},
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": "^6.4.2",
Expand Down
8 changes: 3 additions & 5 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ import RestaurantList from "./components/RestaurantList.jsx";
import { useState } from "react";
import RestaurantDetailModal from "./components/RestaurantDetailModal.jsx";
import AddRestaurantModal from "./components/AddRestaurantModal.jsx";
import useRestaurantStore from "./store/useRestaurantStore.js";
import useFilterStore from "./store/useFilterStore.js";

function App() {
const selectedCategory = useRestaurantStore(
(state) => state.selectedCategory,
);
const setSelectedCategory = useRestaurantStore(
const selectedCategory = useFilterStore((state) => state.selectedCategory);
const setSelectedCategory = useFilterStore(
(state) => state.setSelectedCategory,
);
const [clickedRestaurant, setClickedRestaurant] = useState(null);
Expand Down
17 changes: 9 additions & 8 deletions src/components/AddRestaurantModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@ import { useState } from "react";
import Modal from "./Modal.jsx";
import { CATEGORIES } from "../constants/categories.js";
import styled from "styled-components";
import useRestaurantStore from "../store/useRestaurantStore.js";
import { useAddRestaurantMutation } from "../queries/useAddRestaurantMutation.js";

export default function AddRestaurantModal({ onClose }) {
const [category, setCategory] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");

const addRestaurant = useRestaurantStore((state) => state.addRestaurant);
const mutation = useAddRestaurantMutation();

async function handleFormSubmit(e) {
e.preventDefault();

try {
await addRestaurant({ category, name, description });
onClose();
} catch {
alert("음식점 추가에 실패했습니다. 다시 시도해주세요.");
}
mutation.mutate(
{ category, name, description },
{
onSuccess: () => onClose(),
onError: () => alert("음식점 추가에 실패했습니다. 다시 시도해주세요."),
},
);
Comment on lines +17 to +23

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.

[논의]
onMutate에서 목록을 낙관적으로 업데이트하면서도 모달은 onSuccess(서버 응답 후)에서 닫히도록 구현하셨는데, 이 조합에서 목록에는 새 항목이 이미 보이는데 모달은 아직 열려있는 상태가 짧게 발생하지 않나요? 저는 낙관적 업데이트라면 모달도 onMutate에서 함께 닫는 게 일관성 있다고 판단해서 그렇게 구현했는데, 혹시 다르게 판단하신 이유가 있으신지 궁금합니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

지적하신 부분은 맞습니다. 하지만 의도가 있는 구현이었는데 서버 실패 시 유저가 폼을 다시 채울 수 있게 하려고 했습니다. onMutate에서 모달을 먼저 닫아버리면 실패 시 유저가 입력했던 내용이 사라집니다. 하지만 onSuccess에서 모달을 닫으면 실패했을 때 모달이 그대로 남아 있어서 유저가 바로 수정하고 다시 제출할 수 있습니다.

}

return (
Expand Down
19 changes: 5 additions & 14 deletions src/components/RestaurantList.jsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,21 @@
import { CATEGORY_IMAGES } from "../constants/categoryImages.js";
import styled from "styled-components";
import { filterRestaurants } from "../utils/filterRestaurants.js";
import useRestaurantStore from "../store/useRestaurantStore.js";
import { useEffect } from "react";
import { useRestaurantsQuery } from "../queries/useRestaurantsQuery.js";

export default function RestaurantList({
selectedCategory,
onRestaurantClick,
}) {
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 { data: restaurants, isLoading, error } = useRestaurantsQuery();

const filteredRestaurants = filterRestaurants(restaurants, selectedCategory);
if (isLoading) return <p>불러오는 중...</p>;
if (error) return <p>{error.message}</p>;

useEffect(() => {
fetchRestaurants();
}, [fetchRestaurants]);
const filteredRestaurants = filterRestaurants(restaurants, selectedCategory);

return (
<>
{isLoading && <p>불러오는 중...</p>}
{error && <p>{error}</p>}
<List>
{filteredRestaurants.map((restaurant) => {
return (
Expand Down
11 changes: 9 additions & 2 deletions src/main.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools />
</QueryClientProvider>
</React.StrictMode>,
);
3 changes: 3 additions & 0 deletions src/queries/queryKeys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const restaurantKeys = {
all: () => ["restaurants"],
};
29 changes: 29 additions & 0 deletions src/queries/useAddRestaurantMutation.js

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.

[배움]
useQuery, useMutationsrc/queries/ 폴더에 커스텀 훅으로 분리한 구조가 인상적이었습니다. 저는 컴포넌트 안에 직접 작성했는데, 이렇게 분리하면 같은 쿼리를 여러 컴포넌트에서 재사용할 때나 queryKey 변경 시 한 곳만 수정하면 된다는 장점이 있을 것 같습니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createRestaurant } from "../api";
import { restaurantKeys } from "./queryKeys";

export function useAddRestaurantMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: createRestaurant,
onMutate: async (newRestaurant) => {
await queryClient.cancelQueries({ queryKey: restaurantKeys.all() });

const previousRestaurants = queryClient.getQueryData(restaurantKeys.all());

queryClient.setQueryData(restaurantKeys.all(), (old) => [
...old,
{ ...newRestaurant, id: crypto.randomUUID() },
]);

return { previousRestaurants };
},
onError: (_err, _newRestaurant, context) => {
queryClient.setQueryData(restaurantKeys.all(), context.previousRestaurants);
},
Comment on lines +22 to +24

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.

[배움]
onError: (_err, _newRestaurant, context) 처럼 미사용 파라미터에 _ 접두사를 각각 붙이셨는데, 저는 (err, _, context)처럼 _ 단독으로 썼습니다. _err, _newRestaurant 방식이 파라미터 자리 의미(어떤 인자인지)를 유지하면서 의도적으로 사용하지 않음을 명시적으로 표현한다는 점이 더 명확한 것 같아서 좋은 것 같습니다!

onSettled: () => {
queryClient.invalidateQueries({ queryKey: restaurantKeys.all() });
},
});
}
10 changes: 10 additions & 0 deletions src/queries/useRestaurantsQuery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getRestaurants } from "../api";
import { restaurantKeys } from "./queryKeys";

export function useRestaurantsQuery() {
return useQuery({
queryKey: restaurantKeys.all(),
queryFn: getRestaurants,
});
}
21 changes: 21 additions & 0 deletions src/store/useFilterStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { ALL_CATEGORY } from "../constants/categories.js";

const useFilterStore = create(
persist(
(set) => ({
// 상태
selectedCategory: ALL_CATEGORY,

// 액션
setSelectedCategory: (category) => set({ selectedCategory: category }),
}),
{
name: "category-filter",
storage: createJSONStorage(() => sessionStorage),
},
),
);

export default useFilterStore;
41 changes: 0 additions & 41 deletions src/store/useRestaurantStore.js

This file was deleted.