본문 바로가기
React

React Router v6.4 튜토리얼 배우기

by 붕어사랑 티스토리 2022. 9. 16.
반응형

https://reactrouter.com/en/main/start/tutorial

 

Tutorial

Tutorial Welcome to the tutorial! We'll be building a small, but feature-rich app that let's you keep track of your contacts. We expect it to take between 30-60m if you're following along. 👉 Every time you see this it means you need to do something in t

reactrouter.com

All images and contents from above link

 

 

양이 조금 방대해서, 순서나 내용에 오류가 조금 있을 수 있습니다. 참고부탁드립니다.

 

 

 

 

리액트를 공부하다 보면 React Router에 대한 학습은 필수일 것이다.

그런데 인터넷에 있는 자료와 위 사이트의 튜토리얼 자료가 내용이 상당히 다르다.

 

찾아보니 v6.4에서 어마어마하게 업그레이들르 한 모양이다. 심지어 성능도 차이가 난다.

 

기왕 배울거 최신버젼이 낫지! 하면서 이 페이지를 작성한다.

 

 

 

아래와 같은 페이지를 작성할 것이다.

 

해당 페이지를 간단히 설명하자면, 개인 연락처 프로필을 간단하게 서치하여 보여주는 페이지라고 생각하면 된다!

 

1. 셋업

아래와 같이 환경설정을 하자

npm install react-router-dom localforage match-sorter sort-by

 

그리고 src 폴더 밑에 파일들을 싹다 정리 하고 아래와 같은 구조로 잡아준다

 

src
├── contacts.js
├── index.css
└── main.jsx

 

그리고 파일들의 데이터는 아래 링크에 있는 데이터로 채워준다

 

src/contacts.js

src/index.css

 

 

main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
  Route,
} from "react-router-dom";
import "./index.css";

const router = createBrowserRouter([
  {
    path: "/",
    element: <div>Hello world!</div>,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

 

 

6.4버전에서 가장 주목할 특징은 client side browse를 제공한다는 점이다. 즉 서버에 요청하지 않고 클라이언트 사이드에서 페이지 이동 처리할 걸 다 한다. 속도면에서 엄청난 이점이 있다.

 

그리고 loaders, actions, fetchers 라는 data api를 제공한다.

이 세가지 API는 서버에서 해줘야 할 작업들을 클라이언트측으로 옮겨왔다고 생각하면 된다.

 

data api를 요약하면 다음과 같다

 

loader : 컴포넌트가 생성되기 전에 컴포넌트에 데이터를 전달한다

actions : url에 form과 같은 리퀘스트를 보낼 때 데이터를 처리하는 부분이다

fetchers : url을 변경하지 않고, 요청한 url에 데이터를 요청한다

 

해당 내용에 대해 자세한건 차차 알아보도록 하자

 

 

 

 

2. Root Route 만들기

모든 주소의 시작점은 대부분 root로 표현하고 위치는 / 이다

 

아래 커맨드로 routes 들을 만들어주자

mkdir src/routes
touch src/routes/root.jsx

 

src/routes/root.jsx

export default function Root() {
  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <form id="search-form" role="search">
            <input
              id="q"
              aria-label="Search contacts"
              placeholder="Search"
              type="search"
              name="q"
            />
            <div
              id="search-spinner"
              aria-hidden
              hidden={true}
            />
            <div
              className="sr-only"
              aria-live="polite"
            ></div>
          </form>
          <form method="post">
            <button type="submit">New</button>
          </form>
        </div>
        <nav>
          <ul>
            <li>
              <a href={`contacts/1`}>Your Name</a>
            </li>
            <li>
              <a href={`contacts/2`}>Your Friend</a>
            </li>
          </ul>
        </nav>
      </div>
      <div id="detail"></div>
    </>
  );
}

 

 

다음으로는 main.jsx에서 위에 만든 Root 컴포넌트를 element에 맵핑해준다!

 

src/main.jsx

/* existing imports */
import Root from "./routes/root";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

 

 

그럼 아래와 같은 페이지가 나온다.

 

 

3. Error 페이지 핸들링 하기

왼쪽 사이드에 있는 데이터를 클릭하면 아래와 같은 에러페이지가 나온다

당연한 얘기겠지만 이렇게 에러페이지가 나오는것은 유저가 보기에 이쁘지가 않다.

에러페이지를 커스텀하게 꾸미는것은 항상 중요한 일이다. 이는 아래와 같이 구현할 수 있다

 

 

 

먼저 커스텀한 에러페이지를 만들어주자

 

src/error-page.jsx

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

export default function ErrorPage() {
  const error = useRouteError();
  console.error(error);

  return (
    <div id="error-page">
      <h1>Oops!</h1>
      <p>Sorry, an unexpected error has occurred.</p>
      <p>
        <i>{error.statusText || error.message}</i>
      </p>
    </div>
  );
}

 

 

그리고 router에 errorElement라는 항목을 전달해준다!

 

src/main.jsx

/* previous imports */
import ErrorPage from "./error-page";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

 

그럼 아래처럼 에러페이지가 이쁘게 바뀌게 된다.

 

 

 

연락처 페이지 만들기

본격적으로 연락처 페이지를 만들어 보자

 

src/routes/contact.jsx

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

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placekitten.com/g/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          key={contact.avatar}
          src={contact.avatar || null}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter && (
          <p>
            <a
              target="_blank"
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        )}

        {contact.notes && <p>{contact.notes}</p>}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>
          <Form
            method="post"
            action="destroy"
            onSubmit={(event) => {
              if (
                !confirm(
                  "Please confirm you want to delete this record."
                )
              ) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

function Favorite({ contact }) {
  // yes, this is a `let` for later
  let favorite = contact.favorite;
  return (
    <Form method="post">
      <button
        name="favorite"
        value={favorite ? "false" : "true"}
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
      >
        {favorite ? "★" : "☆"}
      </button>
    </Form>
  );
}

 

 

그리고 이를 main.jsx에 연결해준다

/* existing imports */
import Contact from "./routes/contact";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
  },
  {
    path: "contacts/:contactId",
    element: <Contact />,
  },
]);

그리고 프로필을 클릭하면 아래와 같은 페이지가 나온다

 

 

 

 

Nested Route

위 코드에서는 한가지 문제점이 있다. 우리는 아래처럼 root 컴포넌트에 연락처 페이지를 띄우고 싶은데 아예 새창으로 떠버리게 되었다.

 

이를 해결하는 방법은 연락처 페이지를 root 페이지의 child로 두면 된다!

 

 

src/main.jsx

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
      },
    ],
  },
]);

 

하지만 이것으로 끝나는건 아니다. root 컴포넌트에 child route가 어디에 표시해 주어야 할 지 정해야 한다.

 

이때 <Outlet> 이라는 컴포넌트를 사용한다

 

src/routes/root.jsx

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

export default function Root() {
  return (
    <>
      {/* all the other elements */}
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

 

그럼 아래처럼 페이지가 바뀌게 된다

 

 

 

 

Client Side Routing

앞선 예제를 보면서 눈치챘겠지만, 사이드바를 클릭하면 서버에다가 url 요청을 보내서 페이지를 이동한다는 걸 알 수있다.

서버에 요청하니 당연 느리겠쥬?

 

React Router는 여기서 Client Side Routing이라는 것을 제공하는데, 서버에다가 요청하지 않고, 클라이언트 사이드에서 바로 새로운 ui를 렌더링 하는 기능을 제공한다.

 

이를 사용하기위해서는 <Link> 라는 컴포넌트를 이용해야 하고, <a href=....> 이를 대신한다.

 

 

src/routes/root.jsx

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

export default function Root() {
  return (
    <>
      <div id="sidebar">
        {/* other elements */}

        <nav>
          <ul>
            <li>
              <Link to={`contacts/1`}>Your Name</Link>
            </li>
            <li>
              <Link to={`contacts/2`}>Your Friend</Link>
            </li>
          </ul>
        </nav>

        {/* other elements */}
      </div>
    </>
  );
}

이후 F12를 눌러 네트워크 탭을 확인해보면 서버에다가 리퀘스트를 요청하지 않음을 알 수 있다

 

 

 

 

Loader

URL, 레이아웃, 데이터는 뗄래야 뗄 수 없는 관계이다. 셋이 거의 같이 다닌다.

이러한 자연스러운 결합때문에, React Router에서는 컴포넌트에 데이터를 전달하는 api를 제공한다.

이 API는 앞서배운 Loader이다.

 

또한 앞서 Link 컴포넌트를 통해 클라이언트 측으로 요청을 보내는 법을 배웠다.

그럼 서버에서 처리해야 할 작업들은 어디서 해야할 까? 바로 앞서배운 loader, action, fetch 에서 하는것이다!

 

Loader

loader는 아래 내용만 기억하면 된다

 

  • 로더의 호출 시점은 컴포넌트가 렌더링되기 전이다
  • 각 route 파일에 loader라는 함수를 만든뒤 이를 export하여 사용하는것이 일반적이다
  • loader함수가 값을 리턴하면 useLoaderData()로 컴포넌트에서 데이터를 받을 수 있다
  • GET요청을 하면 Loader가 호출된다

 

 

아래처럼 코드를 작성해보자

 

src/routes/root.jsx // 루트컴포넌트에 로더 만들기

import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";

export async function loader() {
  const contacts = await getContacts();
  return { contacts };
}

 

src/main.jsx // 로더를 라우터에 연결하기

import Root, { loader as rootLoader } from "./routes/root";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
      },
    ],
  },
]);

 

src/routes/root.jsx // 로더에서 리턴된 값을 컴포넌트에서 useLoaderData()로 받아오기

import {
  Outlet,
  Link,
  useLoaderData,
} from "react-router-dom";
import { getContacts } from "../contacts";

/* other code */

export default function Root() {
  const { contacts } = useLoaderData();
  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        {/* other code */}

        <nav>
          {contacts.length ? (
            <ul>
              {contacts.map((contact) => (
                <li key={contact.id}>
                  <Link to={`contacts/${contact.id}`}>
                    {contact.first || contact.last ? (
                      <>
                        {contact.first} {contact.last}
                      </>
                    ) : (
                      <i>No Name</i>
                    )}{" "}
                    {contact.favorite && <span>★</span>}
                  </Link>
                </li>
              ))}
            </ul>
          ) : (
            <p>
              <i>No contacts</i>
            </p>
          )}
        </nav>

        {/* other code */}
      </div>
    </>
  );
}

 

 

위에까지 따라 왔으면 아래 페이지처럼 될 것이다. 아직은 데이터가 없어 텅 비었다.

 

 

Action, Data Writes + HTML Forms

다음으로 action에 대해 배워보도록 하겠다.

재차 강조하지만, React Router 6.4의 가장 큰 특징은 client side routing이다.

 

우리가 action을 배울때 주목해야할 부분은 HTML form이다. HTML form은 특정 url에 데이터를 전송해서 처리하는 요청과정이다. 그리고 그 요청을 처리할 주소값은 보통 action에다가 정의한다.

 

 

그럼 클라이언트 사이드에서 form을 처리하려면?

리액트 라우터는 이를 처리하기 위해 Form 이라는 것을 사용한다. 그리고 이는 html form을 모방하여 클라인트 측에다 리퀘스트를 날린다.

 

아니 이게 시방 뭔소리에요? 하는 사람들은 자세히 보라. 앞에 F가 대문자이다.

<form> // html 폼
<Form> // react router의 폼. 클라이언트 사이드에서 처리한다

 

즉 <form>을 사용하면 서버에다가 리퀘스트를 날리는거고, <Form>을 사용하면 클라이언트 측에다가 리퀘스트를 날리는거다.

그리고 클라이언트측에서 리퀘스트를 받았다면, 이는 action에서 처리한다.

 

 

한가지 더 기억할건, action은 post로 보내야 호출된다. get으로 보내면 loader가 불린다.

 

 

 

지금 앞선 예제까지 따라왔다면 New버튼을 클릭해 사이드바에 레코드를 만든뒤 레코드를 누루면 아래처럼 에러가 날 것이다. 왜냐하면 html form을 사용했기에 서버에 리퀘스트를 날리는데 서버는 구축되지 않았기 떄문!

 

이제 이것을 client side에서 처리 할 수 있도록 바꿔보자

 

 

 

src/routes/root.jsx // <form>을 <Form>으로 바꾸기, action함수 정의하기

import {
  Outlet,
  Link,
  useLoaderData,
  Form,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";

export async function action() {
  await createContact();
}

/* other code */

export default function Root() {
  const { contacts } = useLoaderData();
  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          {/* other code */}
          <Form method="post">
            <button type="submit">New</button>
          </Form>
        </div>

        {/* other code */}
      </div>
    </>
  );
}

 

 

src/main.jsx // 라우터에 action 연결해주기

import Root, {
  loader as rootLoader,
  action as rootAction,
} from "./routes/root";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
      },
    ],
  },
]);

 

 

이후 아래 그림에서 new 버튼을 눌러보면 새로운 레코드가 생기는걸 알 수있다. 일단 아래 그림 예시는 아무것도 안누른 상태.

 

 

 

URL params in Loaders

위 예시에서 No name을 클릭해보라. 그럼 아래처럼 페이지가 뜰 것이다.

그리고 url을 자세히 보면, 이제 진짜로 contact id가 생기었다(wa1iy3x 요거)

여기기서 라우터의 config를 다시 보면 아래와 같이 되어있다

[
  {
    path: "contacts/:contactId",
    element: <Contact />,
  },
];

 

여기서 :contactId 란 URL 파라미터를 의미한다. url을 작성하던 도중 세미콜론(:)을 붙이면 이는 URL 파라미터를 설정하는 것이고 이는 Loader에 params라는 이름으로 전달된다.

 

 

그럼 이제 loader에서 params를 통해 URL 파라미터를 사용해 보고, 이를 컴포넌트에서 useLoaderData로 받아와보자!

 

 

src/routes/contact.jsx // contact 페이지에 로더를 추가하고, 이를 컴포넌트에서 받아오기

import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";

export async function loader({ params }) {
  return getContact(params.contactId);
}

export default function Contact() {
  const contact = useLoaderData();
  // existing code
}

 

src/main.jsx // 컨택트 페이지의 로더를 라우터에 붙이기

/* existing code */
import Contact, {
  loader as contactLoader,
} from "./routes/contact";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
      },
    ],
  },
]);

 

자 이제 no name을 눌러보면 아래처럼 나오게 된다.

 

 

 

Updating Data

보통 데이터를 업데이트 하기 위해서는 form을 사용한다. 우리는 클라이언트 사이드에서 업데이트 할 것이니 Form을 사용하자.(대문자 F를 잘 보세요)

 

먼저 edit 페이지를 만들어줍니다

src/routes/edit.jsx

import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";

export function loader({ params }) {
  return getContact(params.contactId);
}

export default function Edit() {
  const contact = useLoaderData();

  return (
    <Form method="post" id="contact-form">
      <p>
        <span>Name</span>
        <input
          placeholder="First"
          aria-label="First name"
          type="text"
          name="first"
          defaultValue={contact.first}
        />
        <input
          placeholder="Last"
          aria-label="Last name"
          type="text"
          name="last"
          defaultValue={contact.last}
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          type="text"
          name="twitter"
          placeholder="@jack"
          defaultValue={contact.twitter}
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          placeholder="https://example.com/avatar.jpg"
          aria-label="Avatar URL"
          type="text"
          name="avatar"
          defaultValue={contact.avatar}
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea
          name="notes"
          defaultValue={contact.notes}
          rows={6}
        />
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}

 

다음으로 edit 페이지를 라우터에 연결해 줍니다.

import EditContact from "./routes/edit";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
      },
      {
        path: "contacts/:contactId/edit",
        element: <EditContact />,
        loader: contactLoader,
      },
    ],
  },
]);

 

여기서 우리는 root 페이지의 outlet에 edit 페이지를 연결해 주고 싶으니 root의 자식으로 연결해주는 것을 볼 수 있습니다.

다 완료하시고 edit 버튼을 누르면 아래와 같은 페이지가 나옵니다.

 

 

 

Update Contact with FormData

자 이제 에딧 페이지를 만들었으니, 실제로 edit 페이지가 데이터를 업데이트 하도록 만들어 보자

이는 앞서배운 action으로 처리한다. 그리고 업데이트 요청이 끝나면 contact 페이지가 뜨도록 페이지를 redirect 해 주도록 하자

 

 

src/routes/edit.jsx // action을 추가해준뒤, 리다이렉트 해준다.

import {
  Form,
  useLoaderData,
  redirect,
} from "react-router-dom";
import { getContact, updateContact } from "../contacts";

export async function action({ request, params }) {
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
}

 

 

src/routes/main.jsx  // 액션을 라우터에 연결한다

/* existing code */
import EditContact, {
  action as editAction,
} from "./routes/edit";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
      },
      {
        path: "contacts/:contactId/edit",
        element: <EditContact />,
        loader: contactLoader,
        action: editAction,
      },
    ],
  },
]);

 

 

폼을 작성해서 프로필을 업데이트 해보자! 그럼 아래그림처럼 프로필이 업데이트 된다.

 

 

 

 

Redirect new records to the edit page

edit 페이지를, new 버튼이 누르면 바로 뜨도록 연결해보자. 이는 앞서배운 redirect를 사용하면 된다.

 

src/routes/root.jsx

import {
  Outlet,
  Link,
  useLoaderData,
  Form,
  redirect,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";

export async function action() {
  const contact = await createContact();
  return redirect(`/contacts/${contact.id}/edit`);
}

 

그럼 new 버튼을 누르자마자 레코드가 추가되고, 이에 대한 데이터 업데이트를 할 수 있다

아래 그림처럼 데이터를 잔뜩 추가해보자!

 

 

 

 

Active Link Styling

위 페이지에서 한가지 문제점이 있다. 데이터는 많은데 우리가 어떤 데이터를 선택했는지 헤깔린다.

이에 우리는 NavLink 라는걸 만들어 볼 것이다.

 

NavLink란?

특별한 버전에 Link 컴포넌트라고 생각하면 된다. Link에 css를 활용하여 이게 active인지 아닌지 구분할 수 있도록 해주는 기능을 제공한다.

 

src/routes/root.jsx

import {
  Outlet,
  NavLink,
  useLoaderData,
  Form,
  redirect,
} from "react-router-dom";

export default function Root() {
  return (
    <>
      <div id="sidebar">
        {/* other code */}

        <nav>
          {contacts.length ? (
            <ul>
              {contacts.map((contact) => (
                <li key={contact.id}>
                  <NavLink
                    to={`contacts/${contact.id}`}
                    className={({ isActive, isPending }) =>
                      isActive
                        ? "active"
                        : isPending
                        ? "pending"
                        : ""
                    }
                  >
                    {/* other code */}
                  </NavLink>
                </li>
              ))}
            </ul>
          ) : (
            <p>{/* other code */}</p>
          )}
        </nav>
      </div>
    </>
  );
}

위 예시를 보면 className에 isActive와 isPending을 활용하여 css를 구분하는 모습을 볼 수 있다.

 

isActive가 true일 때 -> 유저가 NavLink의 URL 안에 있을 때

isPending가 true일 때 -> active가 막 되려고 할 때, 즉 데이터가 로딩되고 있을 때

 

 

 

그럼 아래처럼 링크가 선택되면 파란색으로 이쁘게 꾸며진다.

 

 

 

 

Global Pending UI

유저가 페이지를 이동한때 리액트 라우터는 이전페이지를 로딩중에 화면에 남겨둔다. 이렇게 되면 유저입장에서는 잠깐동안 페이지가 멈춰보이는 것 처럼 보인다. 이를 해결하기 위해 페이지 로딩중에 css를 추가하여 로딩되는 중이라는 느낌을 남기면 멈추는듯한 느낌을 없앨 수 있다.

 

여기서 우리는 Navigation을 사용할 것이다. 리액트 라우터는 이 네비게이션을 이용해 스크린들의 모든 스테이트를 관리하고 있다.

 

 

src/routes/root.jsx

import {
  // existing code
  useNavigation,
} from "react-router-dom";

// existing code

export default function Root() {
  const { contacts } = useLoaderData();
  const navigation = useNavigation();

  return (
    <>
      <div id="sidebar">{/* existing code */}</div>
      <div
        id="detail"
        className={
          navigation.state === "loading" ? "loading" : ""
        }
      >
        <Outlet />
      </div>
    </>
  );
}

위 예시에서 navigation.state를 활용하여 css를 다르게 주는 모습을 볼 수 있다.

 

navigation state는 아래 세가지중 하나의 값을 가진다

"idle" | "submitting" | "loading"

 

 

 

적용해보면 로딩중에 아래와 같이 fade 효과가 주어지는걸 볼 수 있다.

 

 

Delete Record

우리가 만든 페이지를 보면 Delete 버튼이 있는 것을 볼 수 있다.

이를 통해 레코드를 지워보도록 하자.

 

 

src/routes/contact.jsx

<Form
  method="post"
  action="destroy"
  onSubmit={(event) => {
    if (
      !confirm(
        "Please confirm you want to delete this record."
      )
    ) {
      event.preventDefault();
    }
  }}
>
  <button type="submit">Delete</button>
</Form>

여기서 우리는 action에 destroy 값을 준걸 알 수 있다. Form에서의 경로는 Link to와 같이 상대경로이다. 즉 위 경로는 contact/:contactId/destroy 가 된다.

 

 

자 그럼 destroy의 라우트를 만든 뒤 action을 추가해주자

 

src/routes/destroy.jsx

import { redirect } from "react-router-dom";
import { deleteContact } from "../contacts";

export async function action({ params }) {
  await deleteContact(params.contactId);
  return redirect("/");
}

src/main.jsx // 라우터에 추가

/* existing code */
import { action as destroyAction } from "./routes/destroy";

const router = createBrowserRouter([
  {
    path: "/",
    /* existing root route props */
    children: [
      /* existing routes */
      {
        path: "contacts/:contactId/destroy",
        action: destroyAction,
      },
    ],
  },
]);

/* existing code */

 

 

 

Child 항목의 Error Page 

간단하게 에러를 강제로 발생시켜보자

 

src/routes/destroy.jsx

export async function action({ params }) {
  throw new Error("oh dang!");
  await deleteContact(params.contactId);
  return redirect("/");
}

위 페이지는 앞서 정의한 페이지이다. 화면 하나를 꽉 채워버린다. 유저 입장에서 위 페이지가 뜬다면 리프레시 버튼 누르는거 말고 다시 페이지를 고칠 방법이 없다.

 

즉 상황에 맞는 에러페이지가 필요하다. 아래처럼 child 항목에 errorElement를 추가로 정의해주자

 

src/main.jsx

[
  /* other routes */
  {
    path: "contacts/:contactId/destroy",
    action: destroyAction,
    errorElement: <div>Oops! There was an error.</div>,
  },
];

그럼 위와같이 상황에 맞는 에러페이지가 뜬다! 유저는 다시 사이드바의 레코드를 클릭해서 request를 다시 날릴 수 있다.

 

 

 

 

 

Index Routes

리액트 앱을 실행시켜보면 아래처럼 텅빈 페이지가 뜬다. 개발자는 이를 해결하기위해 초기 페이지를 지정해 주고 싶을 것이다. 이때 사용되는 것이 Index Route 이다.

 

먼저 인덱스 페이지를 만든다.

 

src/routes/index.jsx

export default function Index() {
  return (
    <p id="zero-state">
      This is a demo for React Router.
      <br />
      Check out{" "}
      <a href="https://reactrouter.com/">
        the docs at reactrouter.com
      </a>
      .
    </p>
  );
}

이후 메인페이지에 index 값을 true로 주면 index 컴포넌트가 초기 페이지로 설정되어 outlet에 올라간다.

 

src/main.jsx

// existing code
import Index from "./routes/index";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      { index: true, element: <Index /> },
      /* existing routes */
    ],
  },
]);

 

 

 

Cancle Button 만들기

 

취소버튼을 구현하려면 useNavigate를 사용하면 된다. 여기서 -1 값을 주면 백버튼을 누르는것과 동일하다

 

src/routes/edit.jsx

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

export default function Edit() {
  const contact = useLoaderData();
  const navigate = useNavigate();

  return (
    <Form method="post" id="contact-form">
      {/* existing code */}

      <p>
        <button type="submit">Save</button>
        <button
          type="button"
          onClick={() => {
            navigate(-1);
          }}
        >
          Cancel
        </button>
      </p>
    </Form>
  );
}

 

 

 

URL Search Params and GET Submissions

앞서 강조했던것 처럼 Form이 리퀘스트를 날릴 때 GET으로 보내면 loader가 호출되고, POST로 보내면 action이 호출된다.

 

사이드바의 서치콘솔로 데이터를 검색해보면 아래와 같이 나온다

 

http://127.0.0.1:5173/?q=ryan

 

여기서 q는 root 컴포넌트의 form에서 name의 이름을 보면 알 수있다.

src/routes/root.jsx

<form id="search-form" role="search">
  <input
    id="q"
    aria-label="Search contacts"
    placeholder="Search"
    type="search"
    name="q"
  />
  <div id="search-spinner" aria-hidden hidden={true} />
  <div className="sr-only" aria-live="polite"></div>
</form>

만약 name을 abc 이런식으로 바꾸면 url 형태는 ?abc= 이런식으로 나오게 될 것이다.

 

여기서 주목할점은 form이 method="post" 를 담고있지 않다는 점이다. form의 기본 데이터 전송형식은 get이다. 그래서 검색을 할 때 url에 데이터가 노출되게 디ㅗㄴ다.

 

 

 

 

GET Submissions with Client Side Routing

자 이제 검색창의 form도 클라이언트 사이드의 Form으로 바꿔주도록 하자

 

src/routes/root.jsx

<Form id="search-form" role="search">
  <input
    id="q"
    aria-label="Search contacts"
    placeholder="Search"
    type="search"
    name="q"
  />
  <div id="search-spinner" aria-hidden hidden={true} />
  <div className="sr-only" aria-live="polite"></div>
</Form>

export async function loader({ request }) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return { contacts };
}

 

앞서 말한것 처럼, 현재 Form 데이터 전송형식은 GET이기에 action이 불리지 않고 loader가 불리게 된다. 이에 loader에 검색기능을 넣어주어야 한다.

 

 

 

Synchronizing URLs to Form State

현재까지 잘 따라왔다면, 현상태의 코드에서 아래의 두가지 이슈가 있음을 알 수 있다

 

  • 검색이후 백버튼을 누르면, 폼필드에 여전히 검색했던 값이 남아있고, 리스트들은 필터링 되지 않고 있다.
  • 검색이후 리프레시 버튼을 누르면, 폼필드에는 데이터가 없는데 리스트에는 데이터가 필터링 되고 있다.

다른말로 하면, URL과 form state가 sync가 맞지 않고 있다.

 

 

이제 문제를 해결해보자

 

 

loader에서 q값을 리턴하고 이것을 search field에 default value로 설정하기

export async function loader({ request }) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return { contacts, q };
}

export default function Root() {
  const { contacts, q } = useLoaderData();
  const navigation = useNavigation();

  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <Form id="search-form" role="search">
            <input
              id="q"
              aria-label="Search contacts"
              placeholder="Search"
              type="search"
              name="q"
              defaultValue={q}
            />
            {/* existing code */}
          </Form>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
      {/* existing code */}
    </>
  );
}

위 방법으로 2번째 문제를 해결할 수 있다.

 

 

다음으로 1번 문제를 해결해보자

 

useEffect를 사용하여 검색창에 데이터 초기화 하기

import { useEffect } from "react";

// existing code

export default function Root() {
  const { contacts, q } = useLoaderData();
  const navigation = useNavigation();

  useEffect(() => {
    document.getElementById("q").value = q;
  }, [q]);

  // existing code
}

 

 

 

 

Submitting Forms 'onChange'

이번에는 검색창의 값이 바뀔 때 마다 검색이 되도록 만들어 보겠다.

 

이때 useSubmit을 사용한다.

src/routes/root.jsx

// existing code
import {
  // existing code
  useSubmit,
} from "react-router-dom";

export default function Root() {
  const { contacts, q } = useLoaderData();
  const navigation = useNavigation();
  const submit = useSubmit();

  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <Form id="search-form" role="search">
            <input
              id="q"
              aria-label="Search contacts"
              placeholder="Search"
              type="search"
              name="q"
              defaultValue={q}
              onChange={(event) => {
                submit(event.currentTarget.form);
              }}
            />
            {/* existing code */}
          </Form>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
      {/* existing code */}
    </>
  );
}

 

 

 

 

 

Add Search Spinner

위에처럼 타자를 타이핑 할 때 마다 검색을 하면 매번 데이터베이스에 쿼리를 날리니 많은 시간이 걸린다.

이에따라 유저 입장에서는 답답함을 느낄 수 있으므로 UI적으로 로딩되고 있다고 피드백으 줘보자.

 

여기서 다시 useNavigation을 사용한다.

 

// existing code

export default function Root() {
  const { contacts, q } = useLoaderData();
  const navigation = useNavigation();
  const submit = useSubmit();

  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  useEffect(() => {
    document.getElementById("q").value = q;
  }, [q]);

  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <Form id="search-form" role="search">
            <input
              id="q"
              className={searching ? "loading" : ""}
              // existing code
            />
            <div
              id="search-spinner"
              aria-hidden
              hidden={!searching}
            />
            {/* existing code */}
          </Form>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
      {/* existing code */}
    </>
  );
}

 

 

 

 

 

Managing the History Stack

매번 검색엔진에 키보드를 입력 할 때 마다 폼에다가 리퀘스트를 날리니 키보드 하나하나마다 히스토리 스택이 쌓이게 된다. 

 

위 문제를 해결하려면 submit을 사용할때 replace를 막아주면 된다

 

// existing code

export default function Root() {
  // existing code

  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <Form id="search-form" role="search">
            <input
              id="q"
              // existing code
              onChange={(event) => {
                const isFirstSearch = q == null;
                submit(event.currentTarget.form, {
                  replace: !isFirstSearch,
                });
              }}
            />
            {/* existing code */}
          </Form>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
      {/* existing code */}
    </>
  );
}

 

 

 

Mutation Without Navigation

지금까지 우리는 form에 데이터를 날려서 navigate 되는 형태로 코드를 작성해왔다. 이러한 방법이 정상적일 수 있으나 때때로는 navigate 하지 않고 데이터를 변화시키고 싶을 때가 있따. 이때 사용하는 것이 fetcher이다

 

 

fetcher는 navigate 하지 않고 Form을 이용해 loader와 action과 커뮤니케이션할 수 있다

 

 

fetcher를 사용하여 Favorite 기능을 구현해보자!

 

src/routes/contact.js

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

// existing code

function Favorite({ contact }) {
  const fetcher = useFetcher();
  let favorite = contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        name="favorite"
        value={favorite ? "false" : "true"}
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
}

 

위코드를 보면 contact에서 favorite 값을 받아온다. 그리고 이를통해 css를 분기한다. 마지막으로 fetcher를 이용하여 favorite 값을 바꾸도록 action에 데이터를 보내는 형태로 되어있다!

 

 

이제 action을 정의하자

src/routes/contact.jsx

// existing code
import { getContact, updateContact } from "../contacts";

export async function action({ request, params }) {
  let formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true",
  });
}

export default function Contact() {
  // existing code
}

 

 

 

마지막으로 route를 연결해준다

src/main.jsx

// existing code
import Contact, {
  loader as contactLoader,
  action as contactAction,
} from "./routes/contact";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootLoader,
    action: rootAction,
    children: [
      { index: true, element: <Index /> },
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
        action: contactAction,
      },
      /* existing code */
    ],
  },
]);

 

 

그러면 아래처럼 favorite 버튼기능이 완성된다!

마지막으로 기억해야 할 것은 Fetcher를 이용하면 navigate가 발생하지 않는다는 점이다! 이것은 그냥 Form을 사용했을 때와 가장 큰 차이이다

 

 

 

 

 

Optimistic UI

아마도 눈치챘겠지만, favorite 버튼을 누르면 역시나 약간의 레이턴시가 발생한다. 앞서 했던 방법과 동일하게 우리는 fetcher.state(navigation.state와 비슷한) 를 통하여 css를 분기해 유저에게 로딩되고 있다는 걸 알릴 수 있다.

 

허나 이번에는 다른 방법을 적용해보자. optimistic UI 라는 방법이다!

 

아이디어는 다음과 같다. fetcher는 어떤 데이터가 action에 보내질지 알고있다. 그리고 이는 fetcher.formData로 꺼내올 수 있다. 우리는 우리의 리퀘스트가 성공하리라 믿고 이 데이터를 그냥 바로 가져다 써버리면 레이턴시 없이 바로 ui를 업데이트 할 수 있다. (성공한다고 가정하니 optimistic) 만약 리퀘스트가 실패한다면 UI는 revert 될 것이다.

 

 

src/routes/contact.jsx

// existing code

function Favorite({ contact }) {
  const fetcher = useFetcher();

  let favorite = contact.favorite;
  if (fetcher.formData) {
    favorite = fetcher.formData.get("favorite") === "true";
  }

  return (
    <fetcher.Form method="post">
      <button
        name="favorite"
        value={favorite ? "false" : "true"}
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
}

 

위 예시에서 fetcher.state가 idle 에서 submit시 GET이면 loading으로, 이외에 POST나 PUT, PATCH, DELETE일 경우 submitting으로 바뀐다. 아무튼 idle이 아니면 formData가 존재하므로, 내가 submit 한 데이터로 바로 ui를 업데이트 한다.

 

이후 리퀘스트가 끝난 뒤 실제 데이터를 받아오고 혹시나 실패했다면 실패한 값으로 ui를 업데이트 한다.

 

 

 

 

 

Not Found Data

 

만약에 contact 데이터가 없으면 어떻게 될까? 

 

위 사진처럼 null 때메 죽었다고 나왔다. 뭐 문제는 없지만 보다 명확하게 무엇때문에 에러가 나왔는지 명시하면 디버깅하기 좋을 것이다! 또한 개발을 잘 모르는 유저 입장에서 null때메 죽었다기 보다, 데이터가 없어서 실패했다고 표현하는게 더 좋은 방법일 것이다.

 

에러가 나는 상황에서 예외처리를 하여 throw를 던질 때 우리는 에러나는 상황을 보다 명확하게 표현할 수 있다

 

 

src/routes/contact.jsx

export async function loader({ params }) {
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("", {
      status: 404,
      statusText: "Not Found",
    });
  }
  return contact;
}

 

위에처럼 null때문에 죽었다 하지 말고 Not Found라고 적으면 아래처럼 나온다

 

 

 

Pathless Routes

마지막으로 에러페이지가 페이지 전체를 가리기 보다는, child 컴포넌트의 자리에서 에러페이지가 띄워지는게 유저입장에서 refresh 할 수 있는 방밥이 더 많아 좋을 것이다.

 

즉 다시말하면 error 페이지가 Outlet에 띄워지는게 더 바람직하다. 아래 그림처럼 말이다.

이러게 구현하기 위해서, 우리는 모든 child route에 에러페이지를 지정해 줄 수 있지만 아래처럼 구현도 가능하다.

 

src/main.jsx

createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: rootLoader,
    action: rootAction,
    errorElement: <ErrorPage />,
    children: [
      {
        errorElement: <ErrorPage />,
        children: [
          { index: true, element: <Index /> },
          {
            path: "contacts/:contactId",
            element: <Contact />,
            loader: contactLoader,
            action: contactAction,
          },
          /* the rest of the routes */
        ],
      },
    ],
  },
]);

 

children안에 path가 없는 error페이지를 넣고 다시한번 children을 추가한다. 이러게 path없이 route를 추가하는 방법을 Pathless Routes라고 한다.

 

 

JSX Routes

자이제 마지막 트릭이다. 우리는 여지껏 path 경로를 자바스크립트로 작성했지만 JSX로도 작성할 수 있다. 이때 사용하는 메소드는 createRoutesFromElements이다

 

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

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route
      path="/"
      element={<Root />}
      loader={rootLoader}
      action={rootAction}
      errorElement={<ErrorPage />}
    >
      <Route errorElement={<ErrorPage />}>
        <Route index element={<Index />} />
        <Route
          path="contacts/:contactId"
          element={<Contact />}
          loader={contactLoader}
          action={contactAction}
        />
        <Route
          path="contacts/:contactId/edit"
          element={<EditContact />}
          loader={contactLoader}
          action={editAction}
        />
        <Route
          path="contacts/:contactId/destroy"
          action={destroyAction}
        />
      </Route>
    </Route>
  )
);
반응형

댓글