리액트 쿼리 -2 | 실습 및 앱 개선

·2024년 2월 11일

React

목록 보기
27/29

🔗 레파지토리에서 커밋 히스토리별로 보기

📌 실습 과제

📖 실습 과제 - 스스로 해결하기

  • View Detail 버튼을 눌렀을 때 해당 이벤트의 내용을 가져와서 보이기!
  • detail 페이지에서 delete 버튼을 눌렀을 때 해당 이벤트 삭제 후, 다시 '/events'로 돌아오기

🔗 레파지토리에서 보기


📖 실습 과제 - 해설

💎 http.js

export async function fetchEvent({ id, signal }) {
  const response = await fetch(`http://localhost:3000/events/${id}`, {
    signal,
  });

  if (!response.ok) {
    const error = new Error("An error occurred while fetching the event");
    error.code = response.status;
    error.info = await response.json();
    throw error;
  }

  const { event } = await response.json();

  return event;
}

export async function deleteEvent({ id }) {
  const response = await fetch(`http://localhost:3000/events/${id}`, {
    method: "DELETE",
  });

  if (!response.ok) {
    const error = new Error("An error occurred while deleting the event");
    error.code = response.status;
    error.info = await response.json();
    throw error;
  }

  return response.json();
}

💎 EventsDetail.jsx

import { useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchEvent } from "../../util/http.js";

export default function EventDetails() {
  const params = useParams();
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["events", { id: params.id }],
    queryFn: ({ signal }) => fetchEvent({ signal, id: params.id }),
  });

  const { mutate } = useMutation({
    mutationFn: deleteEvent,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["events"],
      }); // 이벤트를 삭제했으므로 리액트 쿼리가 다시 이벤트 데이터를 가져오도록 해야한다.
      navigate("/events");
    },
  });

  function deleteEventDetail() {
    mutate({ id: params.id }); // 변형함수(Mutation)를 트리거 할 수 있는 mutate 함수
  }

  let content;

  if (isPending) {
    content = (
      <div id="event-details-content" className="center">
        <p> 데이터 가져오는 중.. </p>
      </div>
    );
  }

  if (isError) {
    content = (
      <div id="event-details-content" className="center">
        <ErrorBlock
          title="이벤트 로드에 실패"
          message={
            error.info?.message || "이벤트 데이터를 가져오는데 실패했습니다."
          }
        />
      </div>
    );
  }

  if (data) {
    content = (
      <div id="event-details-content">
        <img src={`http://localhost:3000/${data.image}`} alt={data.title} />
        <div id="event-details-info">
          <div>
            <p id="event-details-location">{data.location}</p>
            <time dateTime={`Todo-DateT$Todo-Time`}>
              {data.date} @ {data.time}
            </time>
          </div>
          <p id="event-details-description">{data.description}</p>
        </div>
      </div>
    );
  }
}
  • 🚨 나는 해당 이벤트의 id를 useParams를 사용하여 가져올 생각을 못하고 로더함수를 통해서 별도로 아이디를 리턴받았다.
  • 물론 동작은 했지만 그래도 앞으로는 useParams를 고려해야겠다!!!! 🚨

📌 데모 앱 개선

📖 무효화 후 자동 다시 가져오기 비활성화

  • 실습 과제에서 데이터를 삭제하고 다시 홈으로 돌아왔을 때 fetch 오류가 발생한 것을 볼 수 있다.

  • 특정 ID가 있는 특정 이벤트에서 오류가 발생했다.
  • 이는 이벤트를 삭제한 후에는 모든 이벤트 관련 쿼리가 무효화되지만 여전히 세부 정보 페이지에 위치하기 때문에 발생한다.
  • 따라서 EventDetails의 queryClient.invalidateQueries()에 다음을 추가해야한다.
// EventDetails.jsx
const { mutate } = useMutation({
  mutationFn: deleteEvent,
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ["events"],
      refetchType: "none", 
    });
    navigate("/events");
  },
});
  • refetchType: "none" 을 추가하여 invalidateQueries를 호출할 때 이 기존 쿼리가 즉시 자동으로 다시 트리거되지 않도록 한다. → 🚨 아직 이벤트 세부정보 페이지 안에 있을 때 즉시 자동으로 트리거 되지 않도록 한다! 🚨

오류 해결!


📖 데모 앱 개선 및 Mutation 개념 반복 | 이벤트 삭제 모달

💎 EventDetail.jsx

  • 삭제하는데 사용자에게 모달로 한번 더 물음으로써 삭제 동작을 시키는 것이 맞는지 확인.
  • 삭제 로딩 문구와 오류 문구 추가
import { Link, useNavigate, Outlet, useParams } from "react-router-dom";

import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchEvent, deleteEvent, queryClient } from "../../util/http.js";
import { useState } from "react";

import Header from "../Header.jsx";
import ErrorBlock from "../UI/ErrorBlock.jsx";
import Modal from "../UI/Modal.jsx";

export default function EventDetails() {
  const [isDeleting, setIsDeleting] = useState(false);

  const params = useParams();
  const navigate = useNavigate();

  const { data, isPending, isError, error } = useQuery({
    queryKey: ["events", { id: params.id }],
    queryFn: ({ signal }) => fetchEvent({ signal, id: params.id }),
  });

  const {
    mutate,
    isPending: isPendingDeletion,
    isError: isErrorDeleting,
    error: errorDeleting,
  } = useMutation({
    mutationFn: deleteEvent,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["events"],
        refetchType: "none", // invalidateQueries를 호출할 때 이 기존 쿼리가 즉시 자동으로 다시 트리거되지 않도록 한다.
        // => 아직 이벤트 세부정보 페이지 안에 있을 때 즉시 자동으로 트리거 되지 않도록 한다!
      }); // 이벤트를 삭제했으므로 리액트 쿼리가 다시 이벤트 데이터를 가져오도록 해야한다.
      navigate("/events");
    },
  });

  function handleStartDelete() {
    setIsDeleting(true);
  }

  function handleStopDelete() {
    setIsDeleting(false);
  }

  function deleteEventDetail() {
    mutate({ id: params.id }); // 변형함수(Mutation)를 트리거 할 수 있는 mutate 함수
  }

  let content;

  if (isPending) {
    content = (
      <div id="event-details-content" className="center">
        <p> 데이터 가져오는 중.. </p>
      </div>
    );
  }

  if (isError) {
    content = (
      <div id="event-details-content" className="center">
        <ErrorBlock
          title="이벤트 로드에 실패"
          message={
            error.info?.message || "이벤트 데이터를 가져오는데 실패했습니다."
          }
        />
      </div>
    );
  }

  if (data) {
    const formattedDate = new Date(data.date).toLocaleDateString("ko-KR", {
      day: "numeric",
      month: "short",
      year: "numeric",
    });

    content = (
      <>
        <header>
          <h1>{data.title}</h1>
          <nav>
            <button onClick={handleStartDelete}>Delete</button>
            <Link to="edit">Edit</Link>
          </nav>
        </header>
        <div id="event-details-content">
          <img src={`http://localhost:3000/${data.image}`} alt={data.title} />
          <div id="event-details-info">
            <div>
              <p id="event-details-location">{data.location}</p>
              <time dateTime={`Todo-DateT$Todo-Time`}>
                {formattedDate} @ {data.time}
              </time>
            </div>
            <p id="event-details-description">{data.description}</p>
          </div>
        </div>
      </>
    );
  }

  return (
    <>
      {isDeleting && (
        <Modal onClose={handleStopDelete}>
          <h2>Are you sure?</h2>
          <p>이 이벤트를 정말 삭제하시겠습니까?</p>
          <div className="form-actions">
            {isPendingDeletion && <p>삭제 중...</p>}
            {!isPendingDeletion && (
              <>
                <button onClick={handleStopDelete} className="button-text">
                  Cancel
                </button>
                <button onClick={deleteEventDetail} className="button">
                  Delete
                </button>
              </>
            )}
          </div>
          {isErrorDeleting && (
            <ErrorBlock
              title="이벤트 삭제에 실패"
              message={
                errorDeleting.info?.message ||
                "이벤트를 삭제하는데 실패했습니다."
              }
            />
          )}
        </Modal>
      )}
      <Outlet />
      <Header>
        <Link to="/events" className="nav-item">
          View all Events
        </Link>
      </Header>
      <article id="event-details">{content}</article>
    </>
  );
}


📖 리액트 쿼리의 실제 이점 | 이벤트 편집하기

💎 EditEvent.jsx

import { Link, useNavigate, useParams } from "react-router-dom";

import { useQuery } from "@tanstack/react-query";
import { fetchEvent } from "../../util/http.js";

import Modal from "../UI/Modal.jsx";
import EventForm from "./EventForm.jsx";
import LoadingIndicator from "../UI/LoadingIndicator.jsx";
import ErrorBlock from "../UI/ErrorBlock.jsx";

export default function EditEvent() {
  const navigate = useNavigate();

  const params = useParams();
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["events", { id: params.id }],
    queryFn: ({ signal }) => fetchEvent({ signal, id: params.id }),
  });

  function handleSubmit(formData) {}

  function handleClose() {
    navigate("../");
  }

  let content;

  if (isPending) {
    content = (
      <div className="center">
        <LoadingIndicator />
      </div>
    );
  }

  if (isError) {
    content = (
      <>
        <ErrorBlock
          title="데이터 로드 실패"
          message={
            error.info?.message || "해당 데이터를 불러오는데 실패했습니다."
          }
        />
        <div className="form-actions">
          <Link to="/events" className="button">
            Okay
          </Link>
        </div>
      </>
    );
  }

  if (data) {
    content = (
      <EventForm inputData={data} onSubmit={handleSubmit}>
        <Link to="../" className="button-text">
          Cancel
        </Link>
        <button type="submit" className="button">
          Update
        </button>
      </EventForm>
    );
  }

  return <Modal onClose={handleClose}>{content}</Modal>;
}
  • 업데이트를 위해 사용한 useQuery에서 동일한 키와 데이터를 이미 해당 이벤트를 불러오는데에서 사용했기 때문에 edit 버튼을 눌렀을 때 모달이 로딩 없이 바로 열리는 것을 볼 수 있다!


📖 Mutation을 이용하여 데이터 업데이트

💎 http.js

export async function updateEvent({ id, event }) {
  const response = await fetch(`http://localhost:3000/events/${id}`, {
    method: "PUT",
    body: JSON.stringify({ event }),
    headers: {
      "Content-Type": "application/json",
    },
  });

  if (!response.ok) {
    const error = new Error("해당 이벤트를 업데이트하는데 실패했습니다.");
    error.code = response.status;
    error.info = await response.json();
    throw error;
  }

  return response.json();
}

💎 EditEvent.jsx

import { useMutation } from "@tanstack/react-query";
import { updateEvent } from "../../util/http.js";

export default function EditEvent() {
  const { mutate } = useMutation({
    mutationFn: updateEvent,
  });

  function handleSubmit(formData) {
    mutate({ id: params.id, event: formData });
    navigate("../"); // 업데이트 모달 닫기 -> 세부 이벤트 페이지
  }
}

📖 낙관적 업데이트 | 업데이트된 상태 반영하기

  • 이전에는 mutation안에 onSuccess를 이용해서 변형을 완료 시켰다.
  • 이번에는 onSuccess를 사용하지 않고 낙관적 업데이트라는 작업을 통해 UI가 즉시 업데이트되도록 할 것이다.

💎 EditEvent.jsx

const { mutate } = useMutation({
  mutationFn: updateEvent,
  onMutate: async (data) => {
    const newEvent = data.event;

    await queryClient.cancelQueries({
      queryKey: ["events", { id: params.id }],
    });

    queryClient.setQueriesData(["events", { id: params.id }], newEvent);
  },
});
  • onMutatemutate를 호출하는 즉시 실행된다.
    • data : onMutate의 값으로 mutate에 전달된다. → { id: params.id, event: formData }
    • queryClient.setQueriesData( 편집하려는 쿼리의 키, 해당 쿼리 키 아래에서 저장하려는 새로운 데이터 ) : 이미 저장된 데이터를 응답을 기다리지 않고 수정할 것이다.
    • queryClient.cancelQueries : 특정 키의 모든 활성 쿼리를 취소한다.
      • 이 코드를 실행하면 해당 키에 대해 나가는 쿼리가 있는 경우 해당 쿼리가 취소되도록 한다. → 해당 쿼리의 응답 데이터와 낙관적으로 업데이트된 쿼리 데이터가 충돌되지 않는다.
      • 업데이트 요청이 완료되기 전에 진행중인 요청이 완료되면 이전 데이터를 다시 가져오게 된다(원하지 않는 동작).
      • 해당 함수는 프로미스를 반환하므로 await을 사용한다.


const { mutate } = useMutation({
  mutationFn: updateEvent,
  onMutate: async (data) => {
    const newEvent = data.event;

    await queryClient.cancelQueries({
      queryKey: ["events", { id: params.id }],
    });

    const previousEvent = queryClient.getQueryData([
      "events",
      { id: params.id },
    ]); // 수정하려는 데이터에 오류가 있을 때, 이전의 데이터로 다시 롤백할 수 있도록 따로 이전 데이터 저장

    queryClient.setQueryData(["events", { id: params.id }], newEvent);

    return { previousEvent }; // context를 위한 객체 리턴
  },
  onError: (error, data, context) => {
    // 실패하게 되는 에러 객체를 받고 mutation에 전송되었던 data를 받고 + context(롤백하기위한 데이터를 받음)도 받음
    // 이벤트 변형이 실패하는 경우 다시 previousEvent로 롤백.
    queryClient.setQueryData(
      ["events", { id: params.id }],
      context.previousEvent
    );
  },
  onSettled: () => {
    // 성공 여부와 관계없이 mutation이 완료될때마다 실행.
    // 이 경우 낙관적 업데이트를 실행하고 오류가 발생하면 롤백하더라도 mutation이 완료될 때마다 여전히 백엔드에서 최신 이벤트를 가져왔는지 확인할 수 있다.
    // 백엔드와 프론트가 동기화되기 위함.
    queryClient.invalidateQueries(["events", { id: params.id }]);
  },
});
  • 수정하려는 데이터에 오류가 있을 때(비어있는 제목..), 이전의 데이터로 다시 롤백할 필요가 있다.
  • previousEvent, onError가 이에 해당되는 로직이다.
  • onSettled는 성공 여부와 관계없이 백엔드와 프론트엔드가 서로 동기화 되기 위해서 invalidateQueries를 이용한다.

📖 쿼리 키를 쿼리 함수 입력으로 사용하기 | 최근 이벤트 데이터만 출력하기

  • Recently added events에 말 그대로 최근에 추가된 이벤트만 보이게 할 것이다.(일부의 이벤트만 보일 필요가 있다.)

💎 backend/app.js

app.get("/events", async (req, res) => {
  const { max, search } = req.query;
  const eventsFileContent = await fs.readFile("./data/events.json");
  let events = JSON.parse(eventsFileContent);

  if (search) {
    events = events.filter((event) => {
      const searchableText = `${event.title} ${event.description} ${event.location}`;
      return searchableText.toLowerCase().includes(search.toLowerCase());
    });
  }

  if (max) {
    events = events.slice(events.length - max, events.length);
  }

  res.json({
    events: events.map((event) => ({
      id: event.id,
      title: event.title,
      image: event.image,
      date: event.date,
      location: event.location,
    })),
  });
});
  • max 속성이 정의되어있다면 해당 숫자 만큼까지의 이벤트만 표현!

💎 http.js

export async function fetchEvents({ signal, searchTerm, max }) {
  console.log(searchTerm);
  let url = "http://localhost:3000/events";
  if (searchTerm && max) {
    url += "?search=" + searchTerm + "&max=" + max;
  } else if (searchTerm) {
    url += "?search=" + searchTerm;
  } else if (max) {
    url += "?max=" + max;
  }

  const response = await fetch(url, { signal: signal });

  // ...
}

💎 NewEventsSection.jsx

export default function NewEventsSection() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["events", { max: 3 }],
    queryFn: ({ signal, queryKey }) => fetchEvents({ signal, ...queryKey[1] }), // queryKey의 {max : 3}을 해당 쿼리함수에 전달
    staleTime: 5000,
  });
}

이러한 방식은 FindEventSection에도 적용이 가능하다!

// FindEventSection
const { data, isLoading, isError, error } = useQuery({
  queryKey: ["events", { searchTerm: searchTerm }],
  queryFn: ({ signal, queryKey }) => fetchEvents({ signal, ...queryKey[1] }),
  enabled: searchTerm !== undefined,
});


📖 리액트 쿼리와 리액트 라우터

  • 리액트 쿼리와 라우터를 함께 사용하는 방식이다.

💎 EditEvent.jsx

import {
  Link,
  redirect,
  useNavigate,
  useParams,
  useSubmit,
  useNavigation,
} from "react-router-dom";

import { useQuery } from "@tanstack/react-query";
import { fetchEvent, updateEvent, queryClient } from "../../util/http.js";

import Modal from "../UI/Modal.jsx";
import EventForm from "./EventForm.jsx";
import ErrorBlock from "../UI/ErrorBlock.jsx";

export default function EditEvent() {
  const navigate = useNavigate();

  const { state } = useNavigation();
  const submit = useSubmit();

  const params = useParams();
  const { data, isError, error } = useQuery({
    queryKey: ["events", { id: params.id }],
    queryFn: ({ signal }) => fetchEvent({ signal, id: params.id }),
    staleTime: 10000, // 캐시된 데이터가 10초 미만인 경우 내부적으로 다시 가져오지 않고 이미 있는 데이터를 사용한다.
  });

  function handleSubmit(formData) {
    submit(formData, { method: "PUT" }); // 액션 함수를 트리거한다.
  }

  function handleClose() {
    navigate("../");
  }

  let content;

  if (isError) {
    content = (
      <>
        <ErrorBlock
          title="데이터 로드 실패"
          message={
            error.info?.message || "해당 데이터를 불러오는데 실패했습니다."
          }
        />
        <div className="form-actions">
          <Link to="/events" className="button">
            Okay
          </Link>
        </div>
      </>
    );
  }

  if (data) {
    content = (
      <EventForm inputData={data} onSubmit={handleSubmit}>
        {state === "submitting" ? (
          <p>전송 중...</p>
        ) : (
          <>
            <Link to="../" className="button-text">
              Cancel
            </Link>
            <button type="submit" className="button">
              Update
            </button>
          </>
        )}
      </EventForm>
    );
  }

  return <Modal onClose={handleClose}>{content}</Modal>;
}

export function loader({ params }) {
  // 해당 컴포넌트가 실행되기 전에 로더함수 먼저 실행

  // 쿼리를 프로그래매틱 방식으로 트리거.
  return queryClient.fetchQuery({
    queryKey: ["events", { id: params.id }],
    queryFn: ({ signal }) => fetchEvent({ signal, id: params.id }),
  });
  // 이렇게 하면 컴포넌트를 렌더링하기 전에 해당 프로미스가 해결될 때까지 기다릴 수 있다.
  // 그러나 컴포넌트 내부에서 useQuery를 사용하는 것이 더 좋다... => 캐시 때문에
}

export async function action({ request, params }) {
  const formData = await request.formData();
  const updatedEventData = Object.fromEntries(formData);
  await updateEvent({ id: params.id, event: updatedEventData });

  // 이제, 낙관적 업데이트 실행되지 않음
  await queryClient.invalidateQueries(["events"]);

  return redirect("../"); // 세부 정보 페이지로 이동
}
  • loader,action함수를 이용해 편집 동작을 실행한다. 해당 컴포넌트 안에 useQuery를 없애도 되긴 하지만 캐시 작업을 위해서 남겨두었다.

💎 App.jsx

import {
  Navigate,
  RouterProvider,
  createBrowserRouter,
} from "react-router-dom";

import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./util/http.js";

import Events from "./components/Events/Events.jsx";
import EventDetails from "./components/Events/EventDetails.jsx";
import NewEvent from "./components/Events/NewEvent.jsx";
import EditEvent, {
  loader as editEventLoader, // loader 추가
  action as editEventAction, // action 추가
} from "./components/Events/EditEvent.jsx";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Navigate to="/events" />,
  },
  {
    path: "/events",
    element: <Events />,

    children: [
      {
        path: "/events/new",
        element: <NewEvent />,
      },
    ],
  },
  {
    path: "/events/:id",
    element: <EventDetails />,
    children: [
      {
        path: "/events/:id/edit",
        element: <EditEvent />,
        loader: editEventLoader, // 로더
        action: editEventAction, // 액션
      },
    ],
  },
]);

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

export default App;
  • 로더와 액션을 연결하였다.

💎 Header.jsx

import { useIsFetching } from "@tanstack/react-query";

export default function Header({ children }) {
  const fetching = useIsFetching(); // 리액트 쿼리가 어플리케이션 어딘가에서 데이터를 가져오는지 확인할 수 있는 값을 얻게 된다.
  // 리액트 쿼리가 데이터를 가져오면 0보다 높은 숫자를, 데이터를 가져오지 않으면 0이 된다.
  return (
    <>
      <div id="main-header-loading">{fetching > 0 && <progress />}</div>
      <header id="main-header">
        <div id="header-title">
          <h1>React Events</h1>
        </div>
        <nav>{children}</nav>
      </header>
    </>
  );
}
  • 리액트 쿼리가 어플리케이션 어딘가에서 데이터를 가지고 오는지를 판별하여 진행 바를 통해 이벤트 데이터가 편집됨(그 외의 다른 동작에도 진행바가 보이긴 한다.)을 표현하고자 한다.

0개의 댓글