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();
}
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>
);
}
}
useParams를 사용하여 가져올 생각을 못하고 로더함수를 통해서 별도로 아이디를 리턴받았다.useParams를 고려해야겠다!!!! 🚨

queryClient.invalidateQueries()에 다음을 추가해야한다.// EventDetails.jsx
const { mutate } = useMutation({
mutationFn: deleteEvent,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["events"],
refetchType: "none",
});
navigate("/events");
},
});
refetchType: "none" 을 추가하여 invalidateQueries를 호출할 때 이 기존 쿼리가 즉시 자동으로 다시 트리거되지 않도록 한다. → 🚨 아직 이벤트 세부정보 페이지 안에 있을 때 즉시 자동으로 트리거 되지 않도록 한다! 🚨
오류 해결!
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>
</>
);
}

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 버튼을 눌렀을 때 모달이 로딩 없이 바로 열리는 것을 볼 수 있다!
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();
}
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("../"); // 업데이트 모달 닫기 -> 세부 이벤트 페이지
}
}
onSuccess를 이용해서 변형을 완료 시켰다.onSuccess를 사용하지 않고 낙관적 업데이트라는 작업을 통해 UI가 즉시 업데이트되도록 할 것이다.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);
},
});
onMutate는 mutate를 호출하는 즉시 실행된다.data : onMutate의 값으로 mutate에 전달된다. → { id: params.id, event: formData }queryClient.setQueriesData( 편집하려는 쿼리의 키, 해당 쿼리 키 아래에서 저장하려는 새로운 데이터 ) : 이미 저장된 데이터를 응답을 기다리지 않고 수정할 것이다.queryClient.cancelQueries : 특정 키의 모든 활성 쿼리를 취소한다.
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를 이용한다.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,
})),
});
});
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 });
// ...
}
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,
});

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를 없애도 되긴 하지만 캐시 작업을 위해서 남겨두었다.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;
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>
</>
);
}
