From 3745893fab8e052d2d98fed9a74829adedca54bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=ED=98=84?= <77152650+Creative-Lee@users.noreply.github.com> Date: Tue, 26 Dec 2023 17:10:00 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#558=20=EC=8A=A4=EC=99=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84,=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=EC=97=90?= =?UTF-8?q?=20React-Query=20=EC=A0=81=EC=9A=A9=20(#559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * config: react-query, devtools 설치 * feat: 쿼리프로바이더, 데브 툴 적용 * refactor: 양방향 스와이프 로직 React Query 적용 1. useQuery, useInfiteQuery hook으로 기존 extraFetch hook 대체 2. entries api 응답값의 prev, next 사용하지 않게 되었음. * refactor: 코멘트 작성 로직 React Query 적용 1. useQuery, useMutation hook으로 기존 로직 대체 2. remote 함수 인자타입 변경 - mutateFn의 함수는 인자를 1개로 제한 3. onSuccess는 useMutation 과 mutate 함수 순서로 실행됨 * test: 댓글 mock 핸들러 수정 --- frontend/package-lock.json | 53 +++++++++++++++++++ frontend/package.json | 2 + .../comments/components/CommentForm.tsx | 34 +++++++----- .../comments/components/CommentList.tsx | 14 ++--- .../src/features/comments/queries/index.ts | 24 +++++++++ .../src/features/comments/remotes/comments.ts | 10 +++- .../songs/hooks/useExtraSongDetail.ts | 50 ++++++++--------- .../songs/hooks/useSongDetailEntries.ts | 12 ++--- frontend/src/features/songs/queries/index.ts | 50 +++++++++++++++++ frontend/src/index.tsx | 13 +++-- frontend/src/mocks/handlers/songsHandlers.ts | 18 +++++-- frontend/src/pages/SongDetailListPage.tsx | 49 +++++++++++------ 12 files changed, 245 insertions(+), 84 deletions(-) create mode 100644 frontend/src/features/comments/queries/index.ts create mode 100644 frontend/src/features/songs/queries/index.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8eefaf1b..a6a61f1b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "license": "MIT", "dependencies": { + "@tanstack/react-query": "^5.14.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -29,6 +30,7 @@ "@storybook/react": "^7.0.27", "@storybook/react-webpack5": "^7.0.27", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^5.14.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", @@ -5869,6 +5871,57 @@ "node": ">=10" } }, + "node_modules/@tanstack/query-core": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.14.1.tgz", + "integrity": "sha512-TlZarySCVEiap4K7BCvrsYZnX7jBbEkR55YMrk8ELcRbuAx6ydL+qoxqUt8Fq8VMvQyGt52icn6T7eJL1Q35KQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.13.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.13.5.tgz", + "integrity": "sha512-effSYz9AWcZ6sNd+c8LCBYFIuDZApoCTXEpRlEYChBZpMz9QUUVMLToThwCyUY49+T5pANL3XxgZf3HV7hwJlg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.14.1.tgz", + "integrity": "sha512-v7jhe/3jhChiR0XJbGHaG5WNPd/cURwzDGBCr4rzpUTeudPzxrtVRKsF1xJRLcJK3qH/0gIwTYHIPZ3gj+01Yw==", + "dependencies": { + "@tanstack/query-core": "5.14.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.14.1.tgz", + "integrity": "sha512-8fuQs0AMQk8D66JUYqdYA33fOObevuWwm1atOnPbtV8PvIscaU0i/cNTqCl1Y10rgbR/QsqxQSJGBZ5TxxBrlA==", + "dev": true, + "dependencies": { + "@tanstack/query-devtools": "5.13.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.14.1", + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbbc95a82..ab5137b29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "license": "MIT", "dependencies": { + "@tanstack/react-query": "^5.14.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -33,6 +34,7 @@ "@storybook/react": "^7.0.27", "@storybook/react-webpack5": "^7.0.27", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^5.14.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", diff --git a/frontend/src/features/comments/components/CommentForm.tsx b/frontend/src/features/comments/components/CommentForm.tsx index 97d3c60b5..56eb9aba2 100644 --- a/frontend/src/features/comments/components/CommentForm.tsx +++ b/frontend/src/features/comments/components/CommentForm.tsx @@ -7,25 +7,24 @@ import LoginModal from '@/features/auth/components/LoginModal'; import Avatar from '@/shared/components/Avatar'; import useModal from '@/shared/components/Modal/hooks/useModal'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; -import { useMutation } from '@/shared/hooks/useMutation'; -import { postComment } from '../remotes/comments'; +import { usePostCommentMutation } from '../queries'; interface CommentFormProps { - getComments: () => Promise; songId: number; partId: number; } -const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { +const CommentForm = ({ songId, partId }: CommentFormProps) => { const [newComment, setNewComment] = useState(''); const { isOpen, closeModal: closeLoginModal, openModal: openLoginModal } = useModal(); const { user } = useAuthContext(); const isLoggedIn = !!user; - const { mutateData: postNewComment } = useMutation(() => - postComment(songId, partId, newComment.trim()) - ); + const { + postNewComment, + mutations: { isPending: isPendingPostComment }, + } = usePostCommentMutation(); const { showToast } = useToastContext(); @@ -35,14 +34,18 @@ const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { currentTarget: { value }, }) => setNewComment(value); - const submitNewComment: React.FormEventHandler = async (event) => { + const submitNewComment: React.FormEventHandler = (event) => { event.preventDefault(); - await postNewComment(); - - showToast('댓글이 등록되었습니다.'); - resetNewComment(); - await getComments(); + postNewComment( + { songId, partId, content: newComment.trim() }, + { + onSuccess: () => { + showToast('댓글이 등록되었습니다.'); + resetNewComment(); + }, + } + ); }; return ( @@ -53,6 +56,7 @@ const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { color.disabledBackground}; + } `; const FlexEnd = styled.div` diff --git a/frontend/src/features/comments/components/CommentList.tsx b/frontend/src/features/comments/components/CommentList.tsx index 8ddb99001..1595853a8 100644 --- a/frontend/src/features/comments/components/CommentList.tsx +++ b/frontend/src/features/comments/components/CommentList.tsx @@ -1,12 +1,10 @@ -import { useEffect } from 'react'; import { styled } from 'styled-components'; import cancelIcon from '@/assets/icon/cancel.svg'; import BottomSheet from '@/shared/components/BottomSheet/BottomSheet'; import useModal from '@/shared/components/Modal/hooks/useModal'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; -import useFetch from '@/shared/hooks/useFetch'; -import { getComments } from '../remotes/comments'; +import { useCommentsQuery } from '../queries'; import Comment from './Comment'; import CommentForm from './CommentForm'; @@ -17,13 +15,7 @@ interface CommentListProps { const CommentList = ({ songId, partId }: CommentListProps) => { const { isOpen, openModal, closeModal } = useModal(false); - const { data: comments, fetchData: refetchComments } = useFetch(() => - getComments(songId, partId) - ); - - useEffect(() => { - refetchComments(); - }, [partId]); + const { comments } = useCommentsQuery(songId, partId); if (!comments) { return null; @@ -66,7 +58,7 @@ const CommentList = ({ songId, partId }: CommentListProps) => { ))} - + ); diff --git a/frontend/src/features/comments/queries/index.ts b/frontend/src/features/comments/queries/index.ts new file mode 100644 index 000000000..1e52cb109 --- /dev/null +++ b/frontend/src/features/comments/queries/index.ts @@ -0,0 +1,24 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getComments, postComment } from '../remotes/comments'; + +export const useCommentsQuery = (songId: number, partId: number) => { + const { data: comments, ...queries } = useQuery({ + queryKey: ['comments', songId, partId], + queryFn: () => getComments(songId, partId), + }); + + return { comments, queries }; +}; + +export const usePostCommentMutation = () => { + const client = useQueryClient(); + + const { mutate: postNewComment, ...mutations } = useMutation({ + mutationFn: postComment, + onSuccess: (_, { songId, partId }) => { + client.invalidateQueries({ queryKey: ['comments', songId, partId] }); + }, + }); + + return { postNewComment, mutations }; +}; diff --git a/frontend/src/features/comments/remotes/comments.ts b/frontend/src/features/comments/remotes/comments.ts index ed4f68e85..a47d575c4 100644 --- a/frontend/src/features/comments/remotes/comments.ts +++ b/frontend/src/features/comments/remotes/comments.ts @@ -1,7 +1,15 @@ import { client } from '@/shared/remotes/axios'; import type { Comment } from '../types/comment.type'; -export const postComment = async (songId: number, partId: number, content: string) => { +export const postComment = async ({ + songId, + partId, + content, +}: { + songId: number; + partId: number; + content: string; +}) => { await client.post(`/songs/${songId}/parts/${partId}/comments`, { content }); }; diff --git a/frontend/src/features/songs/hooks/useExtraSongDetail.ts b/frontend/src/features/songs/hooks/useExtraSongDetail.ts index 539fb93e4..cbabaf231 100644 --- a/frontend/src/features/songs/hooks/useExtraSongDetail.ts +++ b/frontend/src/features/songs/hooks/useExtraSongDetail.ts @@ -1,31 +1,33 @@ import { useCallback, useRef } from 'react'; -import useExtraFetch from '@/shared/hooks/useExtraFetch'; import useValidParams from '@/shared/hooks/useValidParams'; import createObserver from '@/shared/utils/createObserver'; -import { getExtraNextSongDetails, getExtraPrevSongDetails } from '../remotes/songs'; +import { + useExtraNextSongDetailsInfiniteQuery, + useExtraPrevSongDetailsInfiniteQuery, +} from '../queries'; import type { Genre } from '../types/Song.type'; const useExtraSongDetail = () => { - const { genre: genreParams } = useValidParams(); + const { id: songIdParams, genre: genreParams } = useValidParams(); - const { data: extraPrevSongDetails, fetchData: fetchExtraPrevSongDetails } = useExtraFetch( - getExtraPrevSongDetails, - 'prev' - ); + const { + extraPrevSongDetails, + fetchExtraPrevSongDetails, + infiniteQueries: { isLoading: isLoadingPrevSongDetails, hasPreviousPage }, + } = useExtraPrevSongDetailsInfiniteQuery(Number(songIdParams), genreParams as Genre); - const { data: extraNextSongDetails, fetchData: fetchExtraNextSongDetails } = useExtraFetch( - getExtraNextSongDetails, - 'next' - ); + const { + extraNextSongDetails, + fetchExtraNextSongDetails, + infiniteQueries: { isLoading: isLoadingNextSongDetails, hasNextPage }, + } = useExtraNextSongDetailsInfiniteQuery(Number(songIdParams), genreParams as Genre); const prevObserverRef = useRef(null); const nextObserverRef = useRef(null); const getExtraPrevSongDetailsOnObserve: React.RefCallback = useCallback((dom) => { if (dom !== null) { - prevObserverRef.current = createObserver(() => - fetchExtraPrevSongDetails(getFirstSongId(dom), genreParams as Genre) - ); + prevObserverRef.current = createObserver(() => fetchExtraPrevSongDetails()); prevObserverRef.current.observe(dom); return; @@ -36,9 +38,7 @@ const useExtraSongDetail = () => { const getExtraNextSongDetailsOnObserve: React.RefCallback = useCallback((dom) => { if (dom !== null) { - nextObserverRef.current = createObserver(() => - fetchExtraNextSongDetails(getLastSongId(dom), genreParams as Genre) - ); + nextObserverRef.current = createObserver(() => fetchExtraNextSongDetails()); nextObserverRef.current.observe(dom); return; @@ -47,21 +47,13 @@ const useExtraSongDetail = () => { nextObserverRef.current?.disconnect(); }, []); - const getFirstSongId = (dom: HTMLDivElement) => { - const firstSongId = dom.nextElementSibling?.getAttribute('data-song-id') as string; - - return Number(firstSongId); - }; - - const getLastSongId = (dom: HTMLDivElement) => { - const lastSongId = dom.previousElementSibling?.getAttribute('data-song-id') as string; - - return Number(lastSongId); - }; - return { extraPrevSongDetails, extraNextSongDetails, + isLoadingPrevSongDetails, + isLoadingNextSongDetails, + hasPreviousPage, + hasNextPage, getExtraPrevSongDetailsOnObserve, getExtraNextSongDetailsOnObserve, }; diff --git a/frontend/src/features/songs/hooks/useSongDetailEntries.ts b/frontend/src/features/songs/hooks/useSongDetailEntries.ts index 4667dd4ce..ca0cba30b 100644 --- a/frontend/src/features/songs/hooks/useSongDetailEntries.ts +++ b/frontend/src/features/songs/hooks/useSongDetailEntries.ts @@ -1,21 +1,21 @@ import { useCallback } from 'react'; -import useFetch from '@/shared/hooks/useFetch'; import useValidParams from '@/shared/hooks/useValidParams'; -import { getSongDetailEntries } from '../remotes/songs'; +import { useSongDetailEntriesQuery } from '../queries'; import type { Genre } from '../types/Song.type'; const useSongDetailEntries = () => { const { id: songIdParams, genre: genreParams } = useValidParams(); - const { data: songDetailEntries } = useFetch(() => - getSongDetailEntries(Number(songIdParams), genreParams as Genre) - ); + const { + songDetailEntries, + queries: { isLoading: isLoadingSongDetailEntries }, + } = useSongDetailEntriesQuery(Number(songIdParams), genreParams as Genre); const scrollIntoCurrentSong: React.RefCallback = useCallback((dom) => { if (dom !== null) dom.scrollIntoView({ behavior: 'instant', block: 'start' }); }, []); - return { songDetailEntries, scrollIntoCurrentSong }; + return { songDetailEntries, isLoadingSongDetailEntries, scrollIntoCurrentSong }; }; export default useSongDetailEntries; diff --git a/frontend/src/features/songs/queries/index.ts b/frontend/src/features/songs/queries/index.ts new file mode 100644 index 000000000..12454dc11 --- /dev/null +++ b/frontend/src/features/songs/queries/index.ts @@ -0,0 +1,50 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + getExtraNextSongDetails, + getExtraPrevSongDetails, + getSongDetailEntries, +} from '../remotes/songs'; +import type { Genre } from '../types/Song.type'; + +export const useSongDetailEntriesQuery = (songId: number, genre: Genre) => { + const { data: songDetailEntries, ...queries } = useQuery({ + queryKey: ['songDetailEntries'], + queryFn: () => getSongDetailEntries(songId, genre), + staleTime: Infinity, + }); + + return { songDetailEntries, queries }; +}; + +export const useExtraPrevSongDetailsInfiniteQuery = (songId: number, genre: Genre) => { + const { + data: extraPrevSongDetails, + fetchPreviousPage: fetchExtraPrevSongDetails, + ...infiniteQueries + } = useInfiniteQuery({ + queryKey: ['extraPrevSongDetails'], + queryFn: ({ pageParam }) => getExtraPrevSongDetails(pageParam, genre), + getPreviousPageParam: (firstPage) => firstPage[0]?.id ?? null, + getNextPageParam: () => null, + initialPageParam: songId, + staleTime: Infinity, + }); + + return { extraPrevSongDetails, fetchExtraPrevSongDetails, infiniteQueries }; +}; + +export const useExtraNextSongDetailsInfiniteQuery = (songId: number, genre: Genre) => { + const { + data: extraNextSongDetails, + fetchNextPage: fetchExtraNextSongDetails, + ...infiniteQueries + } = useInfiniteQuery({ + queryKey: ['extraNextSongDetails'], + queryFn: ({ pageParam }) => getExtraNextSongDetails(pageParam, genre), + getNextPageParam: (lastPage) => lastPage.at(-1)?.id ?? null, + initialPageParam: songId, + staleTime: Infinity, + }); + + return { extraNextSongDetails, fetchExtraNextSongDetails, infiniteQueries }; +}; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 67e866ffc..9bbea1b7e 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; @@ -9,6 +11,8 @@ import router from './router'; import ToastProvider from './shared/components/Toast/ToastProvider'; import theme from './shared/styles/theme'; +const queryClient = new QueryClient(); + async function main() { if (process.env.NODE_ENV === 'development') { const { worker } = await import('./mocks/browser'); @@ -30,9 +34,12 @@ async function main() { - - - + + + + + + diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index ca3d78e18..288b69656 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -9,13 +9,21 @@ import type { KillingPartPostRequest } from '@/shared/types/killingPart'; const { BASE_URL } = process.env; +const mockComments = [...comments]; + const songsHandlers = [ rest.get(`${BASE_URL}/songs/:songId/parts/:partId/comments`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(comments)); + return res(ctx.status(200), ctx.json(mockComments)); }), rest.post(`${BASE_URL}/songs/:songId/parts/:partId/comments`, async (req, res, ctx) => { - return res(ctx.status(201)); + mockComments.push({ + id: 123123124124, + content: '댓글 추가 목데이터 테스트입니다.', + createdAt: new Date().toISOString(), + writerNickname: '목데이터', + }); + return res(ctx.status(201), ctx.delay(1000)); }), rest.post(`${BASE_URL}/songs/:songId/parts`, async (req, res, ctx) => { @@ -49,7 +57,7 @@ const songsHandlers = [ rest.get(`${BASE_URL}/songs/high-liked/:songId`, (req, res, ctx) => { // const genre = req.url.searchParams.get('genre') - return res(ctx.status(200), ctx.json(songEntries)); + return res(ctx.status(200), ctx.json(songEntries), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/high-liked/:songId/prev`, (req, res, ctx) => { @@ -59,7 +67,7 @@ const songsHandlers = [ const targetIdx = extraPrevSongDetails.findIndex((song) => song.id === Number(songId)); const sliced = extraPrevSongDetails.slice(0, targetIdx); - return res(ctx.status(200), ctx.json(sliced)); + return res(ctx.status(200), ctx.json(sliced), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/high-liked/:songId/next`, (req, res, ctx) => { @@ -70,7 +78,7 @@ const songsHandlers = [ const targetIdx = extraNextSongDetails.findIndex((song) => song.id === Number(songId)); const sliced = extraNextSongDetails.slice(targetIdx); - return res(ctx.status(200), ctx.json(sliced)); + return res(ctx.status(200), ctx.json(sliced), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/recent`, (req, res, ctx) => { diff --git a/frontend/src/pages/SongDetailListPage.tsx b/frontend/src/pages/SongDetailListPage.tsx index 176bc334d..9aae5dbfa 100644 --- a/frontend/src/pages/SongDetailListPage.tsx +++ b/frontend/src/pages/SongDetailListPage.tsx @@ -17,13 +17,20 @@ const SongDetailListPage = () => { const { extraPrevSongDetails, extraNextSongDetails, + isLoadingNextSongDetails, + isLoadingPrevSongDetails, + hasPreviousPage, + hasNextPage, getExtraPrevSongDetailsOnObserve, getExtraNextSongDetailsOnObserve, } = useExtraSongDetail(); - if (!songDetailEntries) return null; + // Suspense 적용시 워터폴 문제 해결 후 Suspense 적용 + // 적용 시 아래 분기문 사라짐. + if (!songDetailEntries || isLoadingNextSongDetails || isLoadingPrevSongDetails) return null; - const { prevSongs, currentSong, nextSongs } = songDetailEntries; + // 응답값의 prev, next 사용하지 않게 되었음. + const { currentSong } = songDetailEntries; const closeCoachMark = () => { setOnboarding(false); @@ -51,25 +58,35 @@ const SongDetailListPage = () => { )} - );