연습 프로젝트 : 리액트 라우터 | 데이터 제출하기

·2024년 2월 8일

React

목록 보기
23/29

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

📌데이터 제출

📖 action() 사용하기

💎 EventForm.js

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

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const navigate = useNavigate();
  function cancelHandler() {
    navigate("..");
  }

  return (
    <Form method="post" className={classes.form}>
      <p>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          name="title"
          required
          defaultValue={event ? event.title : ""}
        />
      </p>
      <p>
        <label htmlFor="image">Image</label>
        <input
          id="image"
          type="url"
          name="image"
          required
          defaultValue={event ? event.image : ""}
        />
      </p>
      <p>
        <label htmlFor="date">Date</label>
        <input
          id="date"
          type="date"
          name="date"
          required
          defaultValue={event ? event.date : ""}
        />
      </p>
      <p>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          name="description"
          rows="5"
          required
          defaultValue={event ? event.description : ""}
        />
      </p>
      <div className={classes.actions}>
        <button type="button" onClick={cancelHandler}>
          Cancel
        </button>
        <button>Save</button>
      </div>
    </Form>
  );
}

export default EventForm;
  • Form은 백엔드로 요청하는 브라우저 기본값을 생략하게 만들고 대신에 전송되었을 요청들을 받아서 액션(action)에 준다. 이때, 각 input에 name 속성이 있어야한다.

💎 NewEventPage.js

import EventForm from "../components/EventForm";
import { json, redirect } from "react-router-dom";

function NewEventPage() {
  return <EventForm />;
}

export default NewEventPage;

export async function action({ request, params }) {
  const data = await request.formData();
  const eventData = {
    title: data.get("title"), // name을 넣는다.
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };
  const response = await fetch("http://localhost:8080/events", {
    method: "POST",
    body: JSON.stringify(eventData),
    headers: {
      "Content-Type": "application/json",
    },
  });

  console.log(response);

  if (!response.ok) {
    throw json(
      { message: "데이터를 전송하는데 실패했습니다." },
      { status: 500 }
    );
  }

  return redirect("/events");
}
  • action()로 로더 함수처럼 리액트 라우터에 의해서 실행되고 유용한 프로퍼티(request, params)들이 포함된 객체를 받는다.

💎 App.js

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

import RootPage from "./pages/RootPage";
import HomePage from "./pages/HomePage";
import EventsPage, { loader as eventsLoader } from "./pages/Events";
import EventDetailPage, {
  loader as eventDetailLoader,
} from "./pages/EventDetailPage";
import NewEventPage, { action as newEventAction } from "./pages/NewEventPage";
import EditEventPage from "./pages/EditEventPage";
import EventsRootLayout from "./pages/EventRoot";
import ErrorPage from "./pages/Error";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootPage />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: "events",
        element: <EventsRootLayout />,
        children: [
          {
            index: true,
            element: <EventsPage />,
            loader: eventsLoader,
          },
          {
            path: ":id",
            id: "event-detail", // 부모라우트의 데이터를 이용하기 위함
            loader: eventDetailLoader, // 공통 loader
            children: [
              {
                index: true,
                element: <EventDetailPage />,
              },
              { path: "edit", element: <EditEventPage /> },
            ],
          },

          { path: "new", element: <NewEventPage />, action: newEventAction },
        ],
      },
    ],
  },
]);

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

export default App;


📖 프로그램적으로 데이터 제출하기(삭제하기) | action()을 트리거하는 또다른 방법

💎 EventDetailPage.js

import {
  useRouteLoaderData,
  json,
  useParams,
  redirect,
} from "react-router-dom";

import EventItem from "../components/EventItem";

function EventDetailPage() {
  // const params = useParams();
  const data = useRouteLoaderData("event-detail");
  // useRouteLoaderData : 부모의 데이터를 받기 위해 사용되는 훅. useLoaderData와 비슷하지만 부모 라우트에서 설정된 아이디값이 필요하다.

  return <EventItem event={data.event} />;
}

export default EventDetailPage;

export async function loader({ request, params }) {
  const id = params.id; // '/events/:id'
  const response = await fetch("http://localhost:8080/events/" + id);

  if (!response.ok) {
    throw json(
      { message: "이벤트 디테일에 대한 정보를 받아올 수 없습니다." },
      { status: 500 }
    );
  } else {
    return response;
  }
}

export async function action({ request, params }) {
  const id = params.id;
  const method = request.method;
  const response = await fetch("http://localhost:8080/events/" + id, {
    method: method,
  });

  if (!response.ok) {
    throw json(
      { message: "이벤트를 삭제하는데 실패했습니다." },
      { status: 500 }
    );
  }
  return redirect("/events");
}
  • action 함수를 적고, 해당 액션의 method를 받아와서 동작하도록 하였다.

💎 App.js

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

import RootPage from "./pages/RootPage";
import HomePage from "./pages/HomePage";
import EventsPage, { loader as eventsLoader } from "./pages/Events";
import EventDetailPage, {
  loader as eventDetailLoader,
  action as deleteEventAction,
} from "./pages/EventDetailPage";
import NewEventPage, { action as newEventAction } from "./pages/NewEventPage";
import EditEventPage from "./pages/EditEventPage";
import EventsRootLayout from "./pages/EventRoot";
import ErrorPage from "./pages/Error";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootPage />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: "events",
        element: <EventsRootLayout />,
        children: [
          {
            index: true,
            element: <EventsPage />,
            loader: eventsLoader,
          },
          {
            path: ":id",
            id: "event-detail", // 부모라우트의 데이터를 이용하기 위함
            loader: eventDetailLoader, // 공통 loader
            children: [
              {
                index: true,
                element: <EventDetailPage />,
                action: deleteEventAction, // delete action 추가
              },
              { path: "edit", element: <EditEventPage /> },
            ],
          },

          { path: "new", element: <NewEventPage />, action: newEventAction },
        ],
      },
    ],
  },
]);

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

export default App;

💎 EventItem.js

import classes from "./EventItem.module.css";
import { Link, useSubmit } from "react-router-dom";

function EventItem({ event }) {
  const submit = useSubmit(); //

  function startDeleteHandler() {
    const proceed = window.confirm("Are you sure?"); // 불리언값 리턴 받음
    if (proceed) {
      submit(null, { method: "delete" }); // submit( {제출하고자하는 데이터}, { method: , action: '/any-different-path'} )
      // 제출하고자하는 데이터는 formData로 자동으로 감싸지게 될 것이다.
      // 만일 액션이 다른 라우트 경로에서 정의된다면 action키를 다른 경로로 설정할 수 있다.
      // 해당 컴포넌트가 속한 라우트가 같거나 이 컴포넌트가 렌더링되는 라우트가 같은 라우트 내에서 정의되므로 따로 action 정의하지 않아도 된다.
    }
  }

  return (
    <article className={classes.event}>
      <img src={event.image} alt={event.title} />
      <h1>{event.title}</h1>
      <time>{event.date}</time>
      <p>{event.description}</p>
      <menu className={classes.actions}>
        <Link to="edit">Edit</Link>
        <button onClick={startDeleteHandler}>Delete</button>
      </menu>
    </article>
  );
}

export default EventItem;
  • 버튼이 눌렸을 때 startDeleteHandler 함수가 동작한다.
  • 사용자가 정말로 삭제를 원하는지 한번 더 물어본다 (proceed)
  • proceed가 true 이면, 삭제 동작을 한다. 이때, useSubmit 훅을 사용한다.
  • submit 함수에서 우리는 삭제만을 원하기 때문에 별도의 데이터를 전달하지 않고 null을 전달한다.
  • submit 함수에서 메서드와 액션 키를 통해서 동작을 제어할 수 있다. 만일 action이 다른 라우트 경로에서 정의되었다면 다른 경로로 지정할 수 있으나 우리의 경우, EventItem과 action이 같은 라우트 내에 정의 되었다.


📖 폼의 제출 상태를 이용하여 UI 상태 업데이트 하기

💎 EventForm.js

import { useNavigate, Form, useNavigation } from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const navigate = useNavigate();

  // navigation의 상태를 이용해서 해당 상태에 따른 UI 업데이트
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  function cancelHandler() {
    navigate("..");
  }

  return (
    <Form method="post" className={classes.form}>
      <p>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          name="title"
          required
          defaultValue={event ? event.title : ""}
        />
      </p>
      <p>
        <label htmlFor="image">Image</label>
        <input
          id="image"
          type="url"
          name="image"
          required
          defaultValue={event ? event.image : ""}
        />
      </p>
      <p>
        <label htmlFor="date">Date</label>
        <input
          id="date"
          type="date"
          name="date"
          required
          defaultValue={event ? event.date : ""}
        />
      </p>
      <p>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          name="description"
          rows="5"
          required
          defaultValue={event ? event.description : ""}
        />
      </p>
      <div className={classes.actions}>
        {/* disabled={isSubmitting} */}
        <button type="button" onClick={cancelHandler} disabled={isSubmitting}>
          취소하기
        </button>
        <button disabled={isSubmitting}>
          {isSubmitting ? "저장 중..." : "저장하기"}
        </button>
      </div>
    </Form>
  );
}

export default EventForm;
  • 작성한 폼을 제출 중(Save)이라면 버튼 disabled하고 '저장 중' 이라는 문구 띄우기


📖 사용자 입력을 검증하고 검증 요류 출력하기

💎 NewEventPage.js

import EventForm from "../components/EventForm";
import { json, redirect } from "react-router-dom";

function NewEventPage() {
  return <EventForm />;
}

export default NewEventPage;

export async function action({ request, params }) {
  const data = await request.formData();
  const eventData = {
    title: data.get("title"), // name을 넣는다.
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };
  const response = await fetch("http://localhost:8080/events", {
    method: "POST",
    body: JSON.stringify(eventData),
    headers: {
      "Content-Type": "application/json",
    },
  });

  if (response.status === 422) {
    // backend의 검증 코드
    return response;
    // 리턴된 action 데이터도 페이지와 컴포넌트에서 사용할 수 있다.(로더와 마찬가지)
  }

  if (!response.ok) {
    throw json(
      { message: "데이터를 전송하는데 실패했습니다." },
      { status: 500 }
    );
  }

  return redirect("/events");
}
  • 로더와 마찬가지로 리턴된 action 데이터도 페이지와 컴포넌트에서 사용할 수 있다.

💎 EventForm.js

import {
  useNavigate,
  Form,
  useNavigation,
  useActionData,
} from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const data = useActionData(); // action에서 온 데이터를 받음.
  const navigate = useNavigate();

  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  function cancelHandler() {
    navigate("..");
  }

  return (
    <Form method="post" className={classes.form}>
      {/* action이 진행된 후 에러가 발생했을 때, 해당 에러에 대한 메시지 출력*/}
      {data && data.errors && (
        <ul>
          {Object.values(data.errors).map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      )}
      <p>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          name="title"
          required
          defaultValue={event ? event.title : ""}
        />
      </p>
      <p>
        <label htmlFor="image">Image</label>
        <input
          id="image"
          type="url"
          name="image"
          required
          defaultValue={event ? event.image : ""}
        />
      </p>
      <p>
        <label htmlFor="date">Date</label>
        <input
          id="date"
          type="date"
          name="date"
          required
          defaultValue={event ? event.date : ""}
        />
      </p>
      <p>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          name="description"
          rows="5"
          required
          defaultValue={event ? event.description : ""}
        />
      </p>
      <div className={classes.actions}>
        <button type="button" onClick={cancelHandler} disabled={isSubmitting}>
          취소하기
        </button>
        <button disabled={isSubmitting}>
          {isSubmitting ? "저장 중..." : "저장하기"}
        </button>
      </div>
    </Form>
  );
}

export default EventForm;
  • useActionData : action이 리턴한 데이터에 엑세스 할 수 있다.


📖 액션 재사용하기 | EditEventPage

  • EditEvent는 새로운 NewEvent를 생성하는 action과 꽤 비슷하다.
  • 이 액션을 재사용하면 좋을 듯 하다.

💎 NewEventPage.js

import EventForm from "../components/EventForm";

function NewEventPage() {
  return <EventForm method="post" />;
}

export default NewEventPage;
  • action 함수를 EventForm.js로 이동.

💎 EventForm.js

import {
  useNavigate,
  Form,
  useNavigation,
  useActionData,
  json,
  redirect,
} from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const data = useActionData(); // action에서 온 데이터를 받음.
  const navigate = useNavigate();

  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  function cancelHandler() {
    navigate("..");
  }

  return (
    <Form method={method} className={classes.form}>
      {data && data.errors && (
        <ul>
          {Object.values(data.errors).map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      )}
      <p>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          name="title"
          required
          defaultValue={event ? event.title : ""}
        />
      </p>
      <p>
        <label htmlFor="image">Image</label>
        <input
          id="image"
          type="url"
          name="image"
          required
          defaultValue={event ? event.image : ""}
        />
      </p>
      <p>
        <label htmlFor="date">Date</label>
        <input
          id="date"
          type="date"
          name="date"
          required
          defaultValue={event ? event.date : ""}
        />
      </p>
      <p>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          name="description"
          rows="5"
          required
          defaultValue={event ? event.description : ""}
        />
      </p>
      <div className={classes.actions}>
        <button type="button" onClick={cancelHandler} disabled={isSubmitting}>
          취소하기
        </button>
        <button disabled={isSubmitting}>
          {isSubmitting ? "저장 중..." : "저장하기"}
        </button>
      </div>
    </Form>
  );
}

export default EventForm;

export async function action({ request, params }) {
  const method = request.method;

  const data = await request.formData();
  const eventData = {
    title: data.get("title"), // name을 넣는다.
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };

  let url = "http://localhost:8080/events";

  if (method === "PATCH") {
    // edit의 경우
    const eventId = params.id;
    url = "http://localhost:8080/events/" + eventId;
  }

  const response = await fetch(url, {
    method: method,
    body: JSON.stringify(eventData),
    headers: {
      "Content-Type": "application/json",
    },
  });

  if (response.status === 422) {
    // backend의 검증 코드
    return response;
    // 리턴된 action 데이터도 페이지와 컴포넌트에서 사용할 수 있다.(로더와 마찬가지)
  }

  if (!response.ok) {
    throw json(
      { message: "데이터를 전송하는데 실패했습니다." },
      { status: 500 }
    );
  }

  return redirect("/events");
}
  • 해당 Form을 재사용 가능하게 하기 위해선 Form method를 변경할 필요가 있다. New 이벤트를 만들기 위해서의 method는 POST, Edit 이벤트를 위해서 method는 PATCH이다.
  • Form의 method를 동적으로 받아오기 위해서 NewEventPage, EditEventPage에 method 속성을 전달 받는다.
  • action은 request를 이용해 method를 받아오고 해당 method가 PATCH이면 fetch 시, url을 변경할 수 있도록 한다.

💎 EditEventForm.js

import { useRouteLoaderData } from "react-router-dom";
import EventForm from "../components/EventForm";

function EditEventPage() {
  const data = useRouteLoaderData("event-detail");
  const event = data.event;

  return <EventForm event={event} method="patch" />;
}

export default EditEventPage;

💎 App.js

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

import RootPage from "./pages/RootPage";
import HomePage from "./pages/HomePage";
import EventsPage, { loader as eventsLoader } from "./pages/Events";
import EventDetailPage, {
  loader as eventDetailLoader,
  action as deleteEventAction,
} from "./pages/EventDetailPage";
import NewEventPage from "./pages/NewEventPage";
import EditEventPage from "./pages/EditEventPage";
import EventsRootLayout from "./pages/EventRoot";
import ErrorPage from "./pages/Error";
import { action as manipulateEventAction } from "./components/EventForm";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootPage />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: "events",
        element: <EventsRootLayout />,
        children: [
          {
            index: true,
            element: <EventsPage />,
            loader: eventsLoader,
          },
          {
            path: ":id",
            id: "event-detail", // 부모라우트의 데이터를 이용하기 위함
            loader: eventDetailLoader, // 공통 loader
            children: [
              {
                index: true,
                element: <EventDetailPage />,
                action: deleteEventAction,
              },
              {
                path: "edit",
                element: <EditEventPage />,
                action: manipulateEventAction,
              },
            ],
          },

          {
            path: "new",
            element: <NewEventPage />,
            action: manipulateEventAction,
          },
        ],
      },
    ],
  },
]);

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

export default App;
  • EventForm으로부터 action을 받아와 NewEvent, EditEvent의 action에 사용한다.


📌 추가

📖 useFetcher()를 이용한 배후 작업

💎 NewsletterSignup.js

import { useEffect } from "react";
import classes from "./NewsletterSignup.module.css";

import { useFetcher } from "react-router-dom";

function NewsletterSignup() {
  const fetcher = useFetcher();
  const { data, state } = fetcher;

  useEffect(() => {
    if (state === "idle" && data && data.message) {
      window.alert("등록 성공");
    }
  }, [data, state]);

  return (
    <fetcher.Form
      method="post"
      action="/newsletter"
      className={classes.newsletter}
    >
      <input
        type="email"
        placeholder="Sign up for newsletter..."
        aria-label="Sign up for newsletter"
      />
      <button>Sign up</button>
    </fetcher.Form>
  );
}

export default NewsletterSignup;
  • useFetcher : 훅이 실행되면 객체를 주고, 이 객체에는 유용한 프로퍼티와 메서드가 있다.
    • Form 컴포넌트 -> 실제로 액션을 트리거. 하지만 라우트 전환을 시작하지 않는다.
  • fetcher는 액션을 트리거하거나 fetcher.load의 도움으로 로더를 트리거하지만 실제로 그 loader가 속한 페이지 또는 그 action이 속한 페이지로 이동하지 않을 때 사용해야한다.
  • action="/newsletter" → newsletter 라우트의 액션을 트리거한다.
  • 즉 Event 창에서 입력하고 버튼을 눌르면 transition(전환)되지 않고 폼을 제출하고 있다.
  • useFetcher 은 전환하지 않은 채로 액션이나 로더와 상호작용하려는 경우에 사용해야하는 툴이다.(라우트 변경을 트리거 하지 않는 경우)

📖 defer() 함수로 데이터 가져오기를 연기하는 방법

  • 데이터가 로딩되는 때를 연기할 수 있게 하는 기능이다.
  • 데이터가 다 도착하지 않았어도 컴포넌트를 미리 렌더링하여 사용자 경험 개선할 수 있다.

💎 Events.js

import { useLoaderData, json, defer, Await } from "react-router-dom";
import EventsList from "../components/EventsList";
import { Suspense } from "react";

function EventsPage() {
  const { events } = useLoaderData();
  console.log(events);
  // resolve는 연기된 값 중 하나를 값으로 취한다.
  return (
    // Suspense : 다른 데이터가 도착하길 기다리는 동안 폴백을 도와주는 특정한 상황에서 사용할 수 있다.
    <Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>
      <Await resolve={events}>
        {/* 데이터가 도착하면(프로미스가 리졸빙되고 데이터가 도착하면) 실행할 함수 */}
        {(loadedEvents) => <EventsList events={loadedEvents} />}
      </Await>
    </Suspense>
  );
}

export default EventsPage;

async function loadEvents() {
  const response = await fetch("http://localhost:8080/events");
  if (!response.ok) {
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    const resData = await response.json();
    return resData.events;
  }
}

export function loader() {
  return defer({
    events: loadEvents(),
  });
}
  • loader에서 fetch동작을 따로 loadEvents 함수로 분리시킨다. 이때, 바로 response를 반환하는 것이 아니다!
  • loadEvents함수는 Promise를 리턴한다.
  • 기존의 loader 함수 안에 defer()를 이용하여 loadEvents()의 결과값을 불러오고 defer()는 객체를 입력받는다. 해당 객체 안에는 해당 페이지에서 오갈 수 있는 모든 HTTP 요청을 넣어줘야한다.
  • loadEvents 함수로 받아온 프로미스를 defer 안의 events 키에 저장된다.


📖 연기해야할 데이터를 제어하는 방법

💎 EventDetailPage.js

import {
  useRouteLoaderData,
  json,
  redirect,
  defer,
  Await,
} from "react-router-dom";

import EventItem from "../components/EventItem";
import EventsList from "../components/EventsList";
import { Suspense } from "react";

function EventDetailPage() {
  // const params = useParams();
  const { event, events } = useRouteLoaderData("event-detail");
  // useRouteLoaderData : 부모의 데이터를 받기 위해 사용되는 훅. useLoaderData와 비슷하지만 부모 라우트에서 설정된 아이디값이 필요하다.

  return (
    <>
      <Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>
        <Await resolve={event}>
          {(loadedEvent) => <EventItem event={loadedEvent} />}
        </Await>
        <Await resolve={events}>
          {(loadedEvents) => <EventsList events={loadedEvents} />}
        </Await>
      </Suspense>
    </>
  );
}

export default EventDetailPage;

async function loadEvent(id) {
  const response = await fetch("http://localhost:8080/events/" + id);

  if (!response.ok) {
    throw json(
      { message: "이벤트 디테일에 대한 정보를 받아올 수 없습니다." },
      { status: 500 }
    );
  } else {
    const resData = await response.json();
    return resData.event;
  }
}

async function loadEvents() {
  const response = await fetch("http://localhost:8080/events");
  if (!response.ok) {
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    const resData = await response.json();
    return resData.events;
  }
}

export async function loader({ request, params }) {
  const id = params.eventId; // '/events/:id'
  console.log(id);

  return defer({
    event: await loadEvent(id), // EventDetail이 로딩되기를 기다린 다음에 해당 페이지를 렌더링
    events: loadEvents(),
  });
}

export async function action({ request, params }) {
  const id = params.eventId;
  const method = request.method;
  const response = await fetch("http://localhost:8080/events/" + id, {
    method: method,
  });

  if (!response.ok) {
    throw json(
      { message: "이벤트를 삭제하는데 실패했습니다." },
      { status: 500 }
    );
  }
  return redirect("/events");
}

0개의 댓글