diff --git a/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md index 62b9686..160afd6 100644 --- a/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md +++ b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md @@ -1,4 +1,4 @@ -## πŸ”— [Path To A Clean(er) React Architecture (Part 7) - Domain Logic](https://overreacted.io/goodbye-clean-code/) +## πŸ”— [Path To A Clean(er) React Architecture (Part 7) - Domain Logic](https://profy.dev/article/react-architecture-domain-logic) ### πŸ—“οΈ λ²ˆμ—­ λ‚ μ§œ: 2024.07.17 diff --git a/July/article/Path-To-A-Clean(er)-React-Architecture-(Part8)-React-Query.md b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part8)-React-Query.md new file mode 100644 index 0000000..feb9ef0 --- /dev/null +++ b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part8)-React-Query.md @@ -0,0 +1,544 @@ +## πŸ”— [Path To A Clean(er) React Architecture (Part 8) - React-Query](https://profy.dev/article/react-architecture-tanstack-query) + +### πŸ—“οΈ λ²ˆμ—­ λ‚ μ§œ: 2024.07.21 + +### 🧚 λ²ˆμ—­ν•œ 크루: λ§ˆμŠ€ν„°μœ„(λͺ…μž¬μœ„) + +--- + +## κΉ”λ”ν•œ λ¦¬μ•‘νŠΈ μ„€κ³„λ‘œμ˜ κΈΈ(Part8) React-Query + +Johannes Kettmann +Updated On July 19, 2024 +Keyword : **React**, **Refactoring**, **Component**, **React-Query** + +λ¦¬μ•‘νŠΈμ˜ λΉ„μ˜μ‘΄μ μΈ νŠΉμ„±μ€ μ–‘λ‚ μ˜ κ²€μž…λ‹ˆλ‹€: + +- ν•œνŽΈμœΌλ‘œλŠ” μ„ νƒμ˜ 자유λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. +- λ‹€λ₯Έ ν•œνŽΈμœΌλ‘œλŠ” λ§Žμ€ ν”„λ‘œμ νŠΈκ°€ μ»€μŠ€ν…€ν™”λ˜κ³  μ’…μ’… ν˜Όλž€μŠ€λŸ¬μš΄ μ•„ν‚€ν…μ²˜λ‘œ λλ‚˜κ²Œ λ©λ‹ˆλ‹€. + +이 글은 μ†Œν”„νŠΈμ›¨μ–΄ μ•„ν‚€ν…μ²˜μ™€ λ¦¬μ•‘νŠΈ 앱에 λŒ€ν•œ μ‹œλ¦¬μ¦ˆμ˜ μ—¬λŸ 번째 λΆ€λΆ„μœΌλ‘œ, λ§Žμ€ 잘λͺ»λœ 관행이 μžˆλŠ” μ½”λ“œλ² μ΄μŠ€λ₯Ό λ‹¨κ³„μ μœΌλ‘œ λ¦¬νŒ©ν† λ§ν•˜λŠ” 과정을 λ‹€λ£Ήλ‹ˆλ‹€. + +μ΄μ „μ˜ κΈ€μ—μ„œλŠ”, + +- [we created the initial API layer and extracted fetch functions](https://profy.dev/article/react-architecture-api-layer-and-fetch-functions) +- [added data transformations](https://profy.dev/article/react-architecture-api-layer-and-data-transformations) +- [separated domain entities and DTOs](https://profy.dev/article/react-architecture-domain-entities-and-dtos) +- [introduced infrastructure services using dependency injection and](https://profy.dev/article/react-architecture-infrastructure-services-and-dependency-injection) +- separated [business logic](https://profy.dev/article/react-architecture-business-logic-and-dependency-injection) and [domain logic](https://profy.dev/article/react-architecture-business-logic-and-dependency-injection) from the components. + +이λ₯Ό 톡해 μš°λ¦¬λŠ” UI μ½”λ“œλ₯Ό μ„œλ²„λ‘œλΆ€ν„° λΆ„λ¦¬ν•˜κ³ , λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ„ UI ν”„λ ˆμž„μ›Œν¬μ™€ λ…λ¦½μ μœΌλ‘œ λ§Œλ“€λ©°, ν…ŒμŠ€νŠΈ κ°€λŠ₯성을 높일 수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€. + +ν•˜μ§€λ§Œ μš°λ¦¬λŠ” 아직 ν”„λ‘œλ•μ…˜ λ¦¬μ•‘νŠΈ μ•±μ—μ„œ κ°€μž₯ μ€‘μš”ν•œ 도ꡬ 쀑 ν•˜λ‚˜μΈ react-query λ˜λŠ” λ‹€λ₯Έ μ„œλ²„ μƒνƒœ 관리 라이브러리λ₯Ό μ†Œκ°œν•˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. + +이것이 이번 κΈ€μ˜ μ£Όμ œμž…λ‹ˆλ‹€. + +μ‹œμž‘ν•˜κΈ° 전에 κ°„λ‹¨ν•œ λ©”λͺ¨: react-query의 μ„€μ • λ°©λ²•μ΄λ‚˜ κΈ°λŠ₯을 μžμ„Ένžˆ μ„€λͺ…ν•˜μ§€λŠ” μ•Šκ² μŠ΅λ‹ˆλ‹€. 이에 μ΅μˆ™ν•˜λ‹€κ³  κ°€μ •ν•©λ‹ˆλ‹€. 그렇지 μ•Šλ‹€λ©΄ λ¬Έμ„œλ‚˜ λ§Žμ€ νŠœν† λ¦¬μ–Όμ„ μ°Έκ³ ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +λ˜ν•œ 이 μ‹œλ¦¬μ¦ˆμ˜ λ‹€λ₯Έ 글을 읽지 μ•Šμ•˜λ‹€λ©΄, κ³„μ†ν•˜κΈ° 전에 μ½μ–΄λ³΄λŠ” 것을 ꢌμž₯ν•©λ‹ˆλ‹€. + +## λͺ©μ°¨ + +- λ¬Έμ œκ°€ μžˆλŠ” μ½”λ“œ μ˜ˆμ‹œ 1: μ„œλ²„ 데이터λ₯Ό μˆ˜λ™μœΌλ‘œ 관리 + + - 문제: λ³΄μΌλŸ¬ν”Œλ ˆμ΄νŠΈ 및 μƒνƒœ 관리 + - ν•΄κ²°μ±…: react-query ν›… 생성 + +- λ¬Έμ œκ°€ μžˆλŠ” μ½”λ“œ μ˜ˆμ‹œ 2: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직과 react-query + + - 문제: 쀑볡 μš”μ²­ + - ν•΄κ²°μ±…: react-query 데이터와 변이 μ‚¬μš© + +- λ‹€μŒ λ¦¬νŒ©ν† λ§ 단계 + +[유튜브 링크](https://youtu.be/A2FOOudW5GQ) + +## λ¬Έμ œκ°€ μžˆλŠ” μ½”λ“œ μ˜ˆμ‹œ 1: μ„œλ²„ 데이터λ₯Ό μˆ˜λ™μœΌλ‘œ 관리 + +λ¬Έμ œκ°€ μžˆλŠ” μ½”λ“œ μ˜ˆμ‹œλ₯Ό μ‚΄νŽ΄λ³΄κ² μŠ΅λ‹ˆλ‹€. λ‹€μŒμ€ ν˜„μž¬ 둜그인된 μ‚¬μš©μžλ₯Ό κ°€μ Έμ˜€κ³ , μ‚¬μš©μžκ°€ λ©”μ‹œμ§€(aka shout)에 λ‹΅μž₯ν•  수 μžˆλŠ” 폼을 λ‹€μ΄μ–Όλ‘œκ·Έμ— λ Œλ”λ§ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. + +[전체 μ†ŒμŠ€ μ½”λ“œλ₯Ό ν¬ν•¨ν•œ λͺ¨λ“  λ³€κ²½ 사항은 여기에 μžˆμŠ΅λ‹ˆλ‹€.](https://github.com/jkettmann/react-architecture/pull/15) + +```tsx +import { useEffect, useState } from "react"; + +import { isAuthenticated as isUserAuthenticated } from "@/domain/me"; +import UserService from "@/infrastructure/user"; + +... + +export function ReplyDialog({ + recipientHandle, + children, + shoutId, +}: ReplyDialogProps) { + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [hasError, setHasError] = useState(false); + + ... + // μ‚­μ œλ  λΆ€λΆ„ + useEffect(() => { + UserService.getMe() + .then(isUserAuthenticated) + .then(setIsAuthenticated) + .catch(() => setHasError(true)) + .finally(() => setIsLoading(false)); + }, []); + // μ‚­μ œλ  λΆ€λΆ„ + + if (hasError || !isAuthenticated) { + return {children}; + } + + async function handleSubmit(event: React.FormEvent) { + // 이 뢀뢄을 λ‚˜μ€‘μ— λ‹€μ‹œ λ³Όκ²λ‹ˆλ‹€ + } + + return ( + + {/* μ»΄ν¬λ„ŒνŠΈμ˜ λ‚˜λ¨Έμ§€ λΆ€λΆ„ */} + + ); +} +``` + +## 문제: λ³΄μΌλŸ¬ν”Œλ ˆμ΄νŠΈ 및 μƒνƒœ 관리 + +react-queryλ₯Ό μ‚¬μš©ν•΄ λ³Έ 적이 μžˆλ‹€λ©΄ μ•„λ§ˆλ„ 이 문제λ₯Ό μ•Œκ³  μžˆμ„ κ²ƒμž…λ‹ˆλ‹€. + +- λ‘œλ”© 및 였λ₯˜ μƒνƒœλ₯Ό μˆ˜λ™μœΌλ‘œ κ΄€λ¦¬ν•˜λ©΄ λ³΄μΌλŸ¬ν”Œλ ˆμ΄νŠΈ μ½”λ“œκ°€ λ°œμƒν•©λ‹ˆλ‹€. +- λ™μ‹œμ—, API 응닡을 μΊμ‹œν•˜μ§€ μ•Šμ•„μ„œ 쀑볡 API μš”μ²­μ΄ λ°œμƒν•˜κ²Œ λ©λ‹ˆλ‹€. +- 이 두 가지 문제λ₯Ό react-queryκ°€ ν•΄κ²°ν•΄ 쀄 수 μžˆμŠ΅λ‹ˆλ‹€. + +## ν•΄κ²°μ±…: react-query ν›… 생성 + +λ‹€μŒμ€ `useEffect`λ₯Ό λŒ€μ²΄ν•  수 μžˆλŠ” query ν›…μž…λ‹ˆλ‹€. + +```tsx +import { useQuery } from '@tanstack/react-query'; + +import UserService from '@/infrastructure/user'; + +export function getQueryKey() { + return ['me']; +} + +export function useGetMe() { + return useQuery({ + queryKey: getQueryKey(), + queryFn: () => UserService.getMe(), + }); +} +``` + +쿼리 ν•¨μˆ˜λŠ” λ³€ν™˜λœ API 응닡을 λ°˜ν™˜ν•˜λŠ” `UserService`([이전 κΈ€μ—μ„œ μƒμ„±λœ](https://profy.dev/article/react-architecture-domain-entities-and-dtos))λ₯Ό ν˜ΈμΆœν•©λ‹ˆλ‹€. 이 뢀뢄은 μ—¬κΈ°μ„œ 크게 μ€‘μš”ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + +이제 μ»΄ν¬λ„ŒνŠΈμ—μ„œ `useEffect` λŒ€μ‹  μƒˆ 쿼리 훅을 κ°„λ‹¨νžˆ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +```tsx +import { useState } from "react"; + +import { useGetMe } from "@/application/queries/get-me"; +import { useReplyToShout } from "@/application/reply-to-shout"; +import { isAuthenticated } from "@/domain/me"; + +... + +export function ReplyDialog({ + recipientHandle, + children, + shoutId, +}: ReplyDialogProps) { + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [replyError, setReplyError] = useState(); + const replyToShout = useReplyToShout(); + const me = useGetMe(); // ν•΄λ‹Ή μ½”λ“œ μΆ”κ°€ + + if (me.isError || !isAuthenticated(me.data)) { // ν•΄λ‹Ή μ½”λ“œ μΆ”κ°€ + return {children}; + } + + async function handleSubmit(event: React.FormEvent) { + // 이 뢀뢄을 곧 λ‹€μ‹œ λ³Όκ²λ‹ˆλ‹€ + } + + return ( + + {/* μ»΄ν¬λ„ŒνŠΈμ˜ λ‚˜λ¨Έμ§€ λΆ€λΆ„ */} + + ); +} +``` + +μš°λ¦¬λŠ” 였λ₯˜ 및 λ‘œλ”© μƒνƒœ μ²˜λ¦¬μ™€ 같은 λ§Žμ€ λ³΄μΌλŸ¬ν”Œλ ˆμ΄νŠΈ μ½”λ“œλ₯Ό μ œκ±°ν–ˆμ„ 뿐만 μ•„λ‹ˆλΌ, 응닡 캐싱, μž¬μ‹œλ„ λ“± λ§Žμ€ κΈ°λŠ₯을 기본적으둜 얻을 수 μžˆμŠ΅λ‹ˆλ‹€. + +μš”μ•½ν•˜λ©΄, 쿼리 훅을 μ»΄ν¬λ„ŒνŠΈμ™€ μ„œλΉ„μŠ€ λ ˆμ΄μ–΄ μ‚¬μ΄μ˜ μΌμ’…μ˜ ν”„λ‘μ‹œλ‘œ μ‚¬μš©ν•©λ‹ˆλ‹€. + +이제 더 λ³΅μž‘ν•œ 예제λ₯Ό μ‚΄νŽ΄λ³΄κ² μŠ΅λ‹ˆλ‹€. + +## λ¬Έμ œκ°€ μžˆλŠ” μ½”λ“œ μ˜ˆμ‹œ 2: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직과 react-query + +이전 μ½”λ“œ μ˜ˆμ œμ—μ„œλŠ” submit ν•Έλ“€λŸ¬λ₯Ό μ‚΄νŽ΄λ³΄μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. μ—¬κΈ°λ₯Ό 보면: + +```tsx +import { useReplyToShout } from "@/application/reply-to-shout"; + +... + +export function ReplyDialog({ + recipientHandle, + children, + shoutId, +}: ReplyDialogProps) { + ... + + const replyToShout = useReplyToShout(); // μ‚­μ œλ  λΆ€λΆ„ + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setIsLoading(true); + + const message = event.currentTarget.elements.message.value; + const files = Array.from(event.currentTarget.elements.image.files ?? []); + + // μ‚­μ œλ  λΆ€λΆ„ + const result = await replyToShout({ + recipientHandle, + message, + files, + shoutId, + }); + // μ‚­μ œλ  λΆ€λΆ„ + + if (result.error) { + setReplyError(result.error); + } else { + setOpen(false); + } + setIsLoading(false); + } + + return ( + + {/* μ»΄ν¬λ„ŒνŠΈμ˜ λ‚˜λ¨Έμ§€ λΆ€λΆ„ */} + + ); +} +``` + +[이전 κΈ€](https://profy.dev/article/react-architecture-business-logic-and-dependency-injection)μ—μ„œ μš°λ¦¬λŠ” submit ν•Έλ“€λŸ¬μ˜ λ§Žμ€ λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ„ μœ„μ—μ„œ κ°•μ‘°ν•œ `useReplyToShout` ν•¨μˆ˜λ‘œ μΆ”μΆœν–ˆμŠ΅λ‹ˆλ‹€. + +ν˜„μž¬ `useReplyToShout` 훅은 μ˜μ‘΄μ„± μ£Όμž…μ„ 톡해 `replyToShout` ν•¨μˆ˜μ— λͺ‡ 가지 μ„œλΉ„μŠ€ ν•¨μˆ˜λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. + +```tsx +import { useCallback } from "react"; + +import { hasExceededShoutLimit } from "@/domain/me"; +import { hasBlockedUser } from "@/domain/user"; +import MediaService from "@/infrastructure/media"; +import ShoutService from "@/infrastructure/shout"; +import UserService from "@/infrastructure/user"; + +... + +// μ‚­μ œλ  λΆ€λΆ„ +const dependencies = { + getMe: UserService.getMe, + getUser: UserService.getUser, + saveImage: MediaService.saveImage, + createShout: ShoutService.createShout, + createReply: ShoutService.createReply, +}; +// μ‚­μ œλ  λΆ€λΆ„ + +export async function replyToShout( + { recipientHandle, shoutId, message, files }: ReplyToShoutInput, + { getMe, getUser, saveImage, createReply, createShout }: typeof dependencies +) { + const me = await getMe(); // μ‚­μ œλ  λΆ€λΆ„ + if (hasExceededShoutLimit(me)) { + return { error: ErrorMessages.TooManyShouts }; + } + + const recipient = await getUser(recipientHandle); // μ‚­μ œλ  λΆ€λΆ„ + if (!recipient) { + return { error: ErrorMessages.RecipientNotFound }; + } + if (hasBlockedUser(recipient, me.id)) { + return { error: ErrorMessages.AuthorBlockedByRecipient }; + } + + try { + let image; + if (files?.length) { + image = await saveImage(files[0]); // μ‚­μ œλ  λΆ€λΆ„ + } + + const newShout = await createShout({ // μ‚­μ œλ  λΆ€λΆ„ + message, + imageId: image?.id, + }); + + await createReply({ // μ‚­μ œλ  λΆ€λΆ„ + shoutId, + replyId: newShout.id, + }); + + return { error: undefined }; + } catch { + return { error: ErrorMessages.UnknownError }; + } +} + +export function useReplyToShout() { + return useCallback( + (input: ReplyToShoutInput) => replyToShout(input, dependencies), // μ‚­μ œλ  λΆ€λΆ„ + [] + ); +} +``` + +## 문제: 쀑볡 μš”μ²­ + +`replyToShout` ν•¨μˆ˜λŠ” μ—¬λŸ¬ API μš”μ²­μ„ λ³΄λƒ…λ‹ˆλ‹€. κ·Έ 쀑 `UserService.getMe(...)`와 `UserService.getUser(...)` 호좜이 ν¬ν•¨λ©λ‹ˆλ‹€. κ·ΈλŸ¬λ‚˜ 이 λ°μ΄ν„°λŠ” 이미 μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ˜ λ‹€λ₯Έ λΆ€λΆ„μ—μ„œ 가져와 react-query μΊμ‹œμ— μ‘΄μž¬ν•©λ‹ˆλ‹€. + +λ˜ν•œ, λ‹€μ‹œ λ‘œλ”© μƒνƒœλ₯Ό μˆ˜λ™μœΌλ‘œ 관리해야 ν•©λ‹ˆλ‹€. + +## ν•΄κ²°μ±…: react-query 데이터와 mutation μ‚¬μš© + +이전 μ˜ˆμ œμ—μ„œ `useGetMe` 쿼리 훅을 λ„μž…ν–ˆμŠ΅λ‹ˆλ‹€. 이제 μ‚¬μš©μžμ˜ 핸듀을 기반으둜 μ‚¬μš©μžλ₯Ό κ°€μ Έμ˜€λŠ” 또 λ‹€λ₯Έ 쿼리 훅을 μΆ”κ°€ν•΄ λ³΄κ² μŠ΅λ‹ˆλ‹€. + +```tsx +import { useQuery } from '@tanstack/react-query'; + +import UserService from '@/infrastructure/user'; + +interface GetUserInput { + handle?: string; +} + +export function getQueryKey(handle?: string) { + return ['user', handle]; +} + +export function useGetUser({ handle }: GetUserInput) { + return useQuery({ + queryKey: getQueryKey(handle), + queryFn: () => UserService.getUser(handle), + }); +} +``` + +그런 λ‹€μŒ ν•„μš”ν•œ mutation 훅을 μƒμ„±ν•©λ‹ˆλ‹€. μ—¬κΈ° shoutλ₯Ό μƒμ„±ν•˜λŠ” 예제 훅이 μžˆμŠ΅λ‹ˆλ‹€. + +```tsx +import { useMutation } from '@tanstack/react-query'; + +import ShoutService from '@/infrastructure/shout'; + +interface CreateShoutInput { + message: string; + imageId?: string; +} + +export function useCreateShout() { + return useMutation({ + mutationFn: (input: CreateShoutInput) => ShoutService.createShout(input), + }); +} +``` + +이제 μ΄λŸ¬ν•œ 훅을 `useReplyToShout` ν›… λ‚΄μ—μ„œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +첫 번째 λ‹¨κ³„λ‘œ, `dependencies` 객체λ₯Ό TypeScript μΈν„°νŽ˜μ΄μŠ€λ‘œ λŒ€μ²΄ν•˜κ³ , `replyToShout` ν•¨μˆ˜λ₯Ό 이에 맞게 μ‘°μ •ν•©λ‹ˆλ‹€. + +```tsx +import { Me, hasExceededShoutLimit, isAuthenticated } from "@/domain/me"; +import { Image } from "@/domain/media"; +import { Shout } from "@/domain/shout"; +import { User, hasBlockedUser } from "@/domain/user"; + +import { useCreateShout } from "../mutations/create-shout"; +import { useCreateShoutReply } from "../mutations/create-shout-reply"; +import { useSaveImage } from "../mutations/save-image"; +import { useGetMe } from "../queries/get-me"; +import { useGetUser } from "../queries/get-user"; + +... + +// μΆ”κ°€ν•  λΆ€λΆ„ +interface Dependencies { + me: ReturnType["data"]; + recipient: ReturnType["data"]; + saveImage: ReturnType["mutateAsync"]; + createShout: ReturnType["mutateAsync"]; + createReply: ReturnType["mutateAsync"]; +} + +export async function replyToShout( + { shoutId, message, files }: ReplyToShoutInput, + { me, recipient, saveImage, createReply, createShout }: Dependencies // μΆ”κ°€ν•  λΆ€λΆ„ +) { + if (!isAuthenticated(me)) { // μΆ”κ°€ν•  λΆ€λΆ„ + return { error: ErrorMessages.NotAuthenticated }; + } + if (hasExceededShoutLimit(me)) { // μΆ”κ°€ν•  λΆ€λΆ„ + return { error: ErrorMessages.TooManyShouts }; + } + + if (!recipient) { // μΆ”κ°€ν•  λΆ€λΆ„ + return { error: ErrorMessages.RecipientNotFound }; + } + if (hasBlockedUser(recipient, me.id)) { // μΆ”κ°€ν•  λΆ€λΆ„ + return { error: ErrorMessages.AuthorBlockedByRecipient }; + } + + try { + let image; + if (files?.length) { + image = await saveImage(files[0]); + } + + const newShout = await createShout({ + message, + imageId: image?.id, + }); + + await createReply({ + shoutId, + replyId: newShout.id, + }); + + return { error: undefined }; + } catch { + return { error: ErrorMessages.UnknownError }; + } +} +``` + +λ‹€μŒμœΌλ‘œ, `useReplyToShout` 훅을 λ‹€μ‹œ μž‘μ„±ν•΄μ•Ό ν•©λ‹ˆλ‹€. + +λ‹¨μˆœνžˆ dependencies 객체λ₯Ό `replyToShout` ν•¨μˆ˜μ— μ œκ³΅ν•˜κ³  λ°˜ν™˜ν•˜λŠ” λŒ€μ‹ , + +- 쿼리와 mutation 훅을 톡해 λͺ¨λ“  쒅속성을 μˆ˜μ§‘ν•©λ‹ˆλ‹€. +- `mutateAsync` ν•¨μˆ˜λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€(이름은 μž„μ˜μ΄μ§€λ§Œ react-query mutation ν›…κ³Ό API 일관성을 μœ μ§€ν•©λ‹ˆλ‹€). +- λͺ¨λ“  쿼리와 mutation ν›…μ˜ λ‘œλ”© μƒνƒœλ₯Ό 병합(merge)ν•©λ‹ˆλ‹€. +- 두 쿼리 ν›…μ˜ 였λ₯˜λ₯Ό λ³‘ν•©ν•©λ‹ˆλ‹€. + +```tsx +interface UseReplyToShoutInput { + recipientHandle: string; +} + +export function useReplyToShout({ recipientHandle }: UseReplyToShoutInput) { + const me = useGetMe(); + const user = useGetUser({ handle: recipientHandle }); + const saveImage = useSaveImage(); + const createShout = useCreateShout(); + const createReply = useCreateShoutReply(); + + return { + mutateAsync: (input: ReplyToShoutInput) => + replyToShout(input, { + me: me.data, + recipient: user.data, + saveImage: saveImage.mutateAsync, + createShout: createShout.mutateAsync, + createReply: createReply.mutateAsync, + }), + isLoading: + me.isLoading || + user.isLoading || + saveImage.isPending || + createShout.isPending || + createReply.isPending, + isError: me.isError || user.isError, + }; +} +``` + +이 λ³€κ²½μœΌλ‘œ `ReplyDialog` μ»΄ν¬λ„ŒνŠΈκ°€ λ Œλ”λ§λ˜μžλ§ˆμž `me`와 `user`λ₯Ό μ–»κΈ° μœ„ν•œ API μš”μ²­μ΄ μ „μ†‘λ©λ‹ˆλ‹€. λ™μ‹œμ—, 이전에 데이터λ₯Ό κ°€μ Έμ™”μœΌλ©΄ 두 응닡 λͺ¨λ‘ μΊμ‹œμ—μ„œ 제곡될 수 μžˆμŠ΅λ‹ˆλ‹€. + +μ‚¬μš©μžκ°€ μƒ€μš°νŠΈμ— λ‹΅μž₯ν•  λ•Œ μ΄λŸ¬ν•œ μš”μ²­μ„ 기닀릴 ν•„μš”κ°€ μ—†μ–΄ μ „λ°˜μ μΈ μ‚¬μš©μž κ²½ν—˜μ΄ κ°œμ„ λ©λ‹ˆλ‹€. + +이 μ ‘κ·Ό λ°©μ‹μ˜ 또 λ‹€λ₯Έ μž₯점은, μ„œλ²„ 데이터 관리λ₯Ό μœ„ν•΄ react-queryλ₯Ό μ‚¬μš©ν•˜λ©΄μ„œλ„ `replyToShout` ν•¨μˆ˜μ™€ λͺ¨λ“  λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ„ κ°œλ³„μ μœΌλ‘œ ν…ŒμŠ€νŠΈν•  수 μžˆλ‹€λŠ” κ²ƒμž…λ‹ˆλ‹€. + +μ΄λŸ¬ν•œ λ³€κ²½ μ‚¬ν•­μœΌλ‘œ `ReplyToDialog` μ»΄ν¬λ„ŒνŠΈλ₯Ό κ°„μ†Œν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€. μ‘°μ •λœ useReplyToShout ν›…μ—μ„œ `isLoading`κ³Ό `hasError` μƒνƒœλ₯Ό μ œκ³΅ν•˜λ―€λ‘œ 더 이상 ν•„μš”ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + +```tsx +import { useState } from "react"; + +import { useGetMe } from "@/application/queries/get-me"; +import { useReplyToShout } from "@/application/reply-to-shout"; +import { LoginDialog } from "@/components/login-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { isAuthenticated } from "@/domain/me"; + +... + +export function ReplyDialog({ + recipientHandle, + children, + shoutId, +}: ReplyDialogProps) { + const [open, setOpen] = useState(false); + const [replyError, setReplyError] = useState(); + const replyToShout = useReplyToShout({ recipientHandle }); + const me = useGetMe(); + + if (me.isError || !isAuthenticated(me.data)) { + return {children}; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const message = event.currentTarget.elements.message.value; + const files = Array.from(event.currentTarget.elements.image.files ?? []); + + const result = await replyToShout.mutateAsync({ // 좔가될 λΆ€λΆ„ + recipientHandle, + message, + files, + shoutId, + }); + + if (result.error) { + setReplyError(result.error); + } else { + setOpen(false); + } + } + + ... + + return ( + + {/* μ»΄ν¬λ„ŒνŠΈμ˜ λ‚˜λ¨Έμ§€ λΆ€λΆ„ */} + + ); +} +``` + +λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ„ `useReplyToShout` ν›…μœΌλ‘œ μΆ”μΆœν•œ 또 λ‹€λ₯Έ μž₯점이 이제 λͺ…ν™•ν•΄μ§‘λ‹ˆλ‹€: + +μ„œλ²„ 데이터 κ΄€λ¦¬μ˜ κΈ°λ³Έ λ©”μ»€λ‹ˆμ¦˜μ„ ν›… λ‚΄λΆ€μ—μ„œ μƒλ‹Ήνžˆ λ³€κ²½ν–ˆμ§€λ§Œ, μ»΄ν¬λ„ŒνŠΈμ—μ„œμ˜ 쑰정은 μ΅œμ†Œν•œμ΄μ—ˆμŠ΅λ‹ˆλ‹€. + +## λ‹€μŒ λ¦¬νŒ©ν† λ§ 단계 + +이번 글은 μ—¬κΈ°κΉŒμ§€μž…λ‹ˆλ‹€. μš°λ¦¬λŠ” react-queryλ₯Ό React μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ•„ν‚€ν…μ²˜μ— μ„±κ³΅μ μœΌλ‘œ ν†΅ν•©ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μŒ λ²ˆμ—λŠ” 더 일반적인 κΈ°λŠ₯ μ€‘μ‹¬μ˜ 폴더 ꡬ쑰에 λ§žμΆ”κΈ° μœ„ν•΄ 폴더 ꡬ쑰λ₯Ό λ¦¬νŒ©ν† λ§ν•  κ²ƒμž…λ‹ˆλ‹€.