React Router - React-Router-DOM V6 튜토리얼 뿌시기

ChoiYongHyeun·2024년 3월 9일
2

리액트

목록 보기
17/31
post-thumbnail

본 게시글의 내용은 모두 공식문서인 React Router 를 토대로 하고 있습니다.


Overview

Client Side Rendering

전통적인 브라우저는 클라이언트의 요청에 따라 이미 만들어둔 도큐먼트를 웹 서버에 요청 , 받는

서버 사이드 렌더링이 일어났다.

서버단에서 렌더링 해둔 도큐먼트를 클라이언트에게 전달해주는 렌더링 기법

하지만 이는 매 요청마다 새로운 도큐먼트를 받아 파싱해야 하기 때문에 성능 저하 뿐이 아니라 사용자 입장에서도

화면이 깜박거리는 등 낮은 UX 를 선사했다.

네이버 지도에서 좌표를 조금만 이동해도 새로운 페이지를 받는다 생각해보자 생각만해도 열받는다

하지만 서버 단에서 렌더링 된 도큐먼트를 받는 것이 아니라

서버에게는 새롭게 렌더링에 필요한 자료를 요청하고 , 요청 받은 자료를 가지고

클라이언트 단에서 동적으로 렌더링 하는 클라이언트 사이드 렌더링 기법이 생겨났다.

Client Side Routing

클라이언트 사이드 렌더링 기법은 하나의 페이지에서 인터렉션에 따라 렌더링 하는 화면을 다르게 하는 것일뿐

렌더링 되는 화면과 페이지의 주소가 일치하지 않는다는 문제가 있었다.

이런 문제를 해결 할 수 있도록 브라우저 객체인 Windowhistory 객체를 이용하여

렌더링 되는 화면이 변경됨에 따라 브라우저의 주소도 같이 변경해주도록 하였다.

이 부분과 관련되 내용은 React Router - 라우터 톺아보기 (리액트가 아닌 VanilaJS 에서의 SPA 라우터) 을 보면 좋을 것같다.

꼭 모든 인터렉티브하게 렌더링 되는 화면과 주소가 일치 될 필요는 없지만

특정 링크나 , 클라이언트를 네비게이트 시키는 경우에는 렌더링 되는 화면과 주소가 일치되는 편이

클라이언트가 링크를 공유하거나 , 북마크 할 때 간편할 것이다.

예를 들어 리액트 공식문서에서 useState 를 검색하면 내 URL 이 여전히 https://react.dev/ 인 것 보다 https://react.dev/reference/react/useState 인 편이 북마크하거나 공유하기에 훨씬 좋다.

이와 같이 전체 화면을 리로드 하지 않고 (MPA 때 처럼) 다른 페이지로 가도록 하는 일련의 과정을 Routing 이라고 한다.


SPA Routing 의 기본 전제조건 : Routing Layer

react router dom 뿐만 아니라 SPA 에서 라우팅 할 때 사용하는 기존 로직이 존재한다.

이는 Routing layer 를 구현해두는 것이다.

클라이언트가 다른 페이지로 네비게이팅 되고자 할 때 라우팅 레이어를 통해

어떤 페이지로 가기를 원하고 , 어떤 화면이 렌더링 되어야 하는지 를 확인한다.

react-router-dom 의 라우팅 레이어는 기본적으로 두 가지 모습을 따른다.

PathConstants

const PathConstants = {
  TEAM: '/team',
  REPORT_ANALYSIS: 'reports/:reportId/analysis',
  // ...
};

모든 페이지의 라우팅 될 주소를 저장하고 있는 객체이다.

routes

const routes = [
  { path: PathConstants.TEAM, element: <TeamPage /> },
  { path: PathConstants.REPORT_ANALYSIS, element: <ReportAnalysisPage /> },
  // ...
];

라우팅 되었을 때 렌더링 될 컴포넌트들의 정보를 담고 있는 객체들을 담고 있는 배열이다.

사용 예시

위처럼 라우팅 될 주소를 담고 있는 PathConstants 객체와 라우팅 시 렌더링 될 컴포넌트의 정보를 담고 있는 routes 객체를 이용하면

<a href={PathConstants.TEAM}>Go to the team page!</a>

다음과 같이 팀 페이지로 라우팅 시키는 태그를 클라이언트가 클릭하여 라우팅 되었을 때

PathConstants 에 의해 주소창은 /team 으로 변경 될 것이고

routes 를 통해 페이지에 렌더링 되는 화면은 <TeamPage /> 로 변경될 것이다.

중간에 라우팅 될 페이지와 렌더링 시키는 로직이 존재하는 컴포넌트들을 사용해야 하기는 한다.
그것은 ~~ react router dom 에서 제공하는 ~~ 컴포넌트들 ~~


React Router DOM Tutorial

해당 페이지에서 제공하는 튜토리얼 페이지를 따라가며 이해해보자

튜토리얼 링크

우선 리액트 폴더를 만든 후 페이지에서 요구하는 프로젝트 구조에 맞춰 구현해주도록 하자

│  ├─ public
│  │  ├─ index.html
│  ├─ README.md
│  └─ src
│     ├─ contacts.js
│     ├─ index.css
│     ├─ index.js (entry file)

튜토리얼을 따라가다보면 생기는 완성본은 다음과 같은 쌈뽕한 파일인데 이를 위한 css 파일을 튜토리얼 링크에서 제공한다.

쌈뽕한 index.css , index.css 에 옮겨담아주자

index.js (entry file)

엔트리 파일을 다음과 같이 구성해준다.

import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import './index.css';
import Root from './routes/root';

/* root route 설정 */
const router = createBrowserRouter([{ path: '/', element: <Root /> }]);

/* root node 하위에 렌더링 될 모든 컴포넌트에게 
RouterProvider 를 통해 context 로 router를 건내줌 
*/

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

기존 리액트 파일에서 추가된 점은 routercreateBrowserRouter 를 이용해 생성해주고

root node 에서 Routerprovider 를 통해 router 를 전역적으로 제공한다.

이 때 createBrowserRouter 로 생성되는 배열은 라우팅 시킬 주소를 담은 path 프로퍼티와

렌더링 할 element 객체를 담아주도록 한다.

Root.jsx

그러면 / 일 때 (기본 페이지) 라우팅 되기로 약속한 Root 엘리먼트를 만들어주자

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> // '/contacts/1' 로 라우팅
              시키는 태그
            </li>
            <li>
              <a href={`/contacts/2`}>Your Friend</a> // '/contacts/2' 로 라우팅
              시키는 태그
            </li>
          </ul>
        </nav>
      </div>
      <div id='detail'></div>
    </>
  );
}

Root 컴포넌트는 /contacts/:id 로 라우팅 시키는 두 개의 태그가 존재한다.

이렇게 하고 npm start 를 통해 페이지를 살펴보자

Handling Not Found Errors

이렇게 작성하고 나면 에러가 발생한다.

그 이유는 Root 엘리먼트에서 라우팅 시킬 주소인 /contacts/:id 들에 대한 페이지가 존재하지 않기 때문이다.

이렇게 존재하지 않는 페이지로 접근하고자 할 때 렌더링 할 컴포넌트를 생성해주자

/* 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>미안 ~ 예기치 못한 에러가 발생했어 ~!!</p>
      <p>
        <i>{error.statusText || error.message}</i>
      </p>
    </div>
  );
}

이후 생성한 에러 컴포넌트를 entry fileindex.js 에서

전역 router 내부에서 생성해주자

/* src/index.js */
...
import ErrorPage from './error-page';

/* root route 설정 */
const router = createBrowserRouter([
  { path: '/', element: <Root />, errorElement: <ErrorPage /> }, // errorElement 에 추가
]);

이후의 리액트 라우터는 에러 핸들링을 가능하게 할 errorElement 프로퍼티가 존재하니

문제 없이 초기 렌더링이 된다.

그리고 contact/:id 로 라우팅 시키는 YourName 을 클릭하니 해당 렌더링 할 컴포넌트를 찾지 못해

위에서 제공한 ErrorPage 컴포넌트를 렌더링 하는 모습을 볼 수 있다.

useRouteError

useRouteErrorreact-router-dom 에서 제공하는 훅으로

에러가 발생 시 에러와 관련된 객체인 ErrorResponselmpl 객체를 반환한다.
해당 객체에는 애러 상태 코드와 상태 텍스트 등의 정보들을 담고 있다.

The Contact Route UI

에러를 핸들링 할 페이지는 만들었으니 그럼 라우팅 될 페이지를 생성해보자

우리는 YourName 이나 Your Friend 를 클릭하면 라우팅 할 컴포넌트를 생성해보도록 하자

/*src/contact
튜토리얼에서 제공하는 쌈뽕한 컴포넌트 
*/

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
          src={contact.avatar || null}
          alt={contact.first + contact.last}
          key={contact.avatar}
        />
      </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>
        )}

        <div>
          <Form action='edit'>
            <button type='submit'>Edit</button>
          </Form>
          <Form
            method='post'
            action='destory'
            onSubmit={(event) => {
              if (!window.confirm('너 진짜로 삭제할거야 ?')) {
                // window.confirm 은 확인과 취소 두 버튼을 가지며 메시지를 지정 할 수 있는
                // 모달 대화 상자를 띄운다.
                event.preventDefault();
              }
            }}
          >
            <button type='submit'>Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

function Favorite({ contact }) {
  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>
  );
}

Nested Router

현재의 라우팅은 / 일 때는 Root 컴포넌트가 렌더링 되고

/contact/:contactId 일 때는 Contact 컴포넌트가 렌더링 된다.

다음과 같은 결과물을 얻기 위해서는 어떻게 해야 할까

문제를 먼저 파악해보자

/* root route  */
const router = createBrowserRouter([
  { path: '/', element: <Root />, errorElement: <ErrorPage /> },
  { path: 'contacts/:contactId', element: <Contact /> }, // contacts/:contactId 로 라우팅 될 경우
]);

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  // root 의 innerHTML 은 모두 <Contact > 컴포넌트가 된다.
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
);

우리가 원하는 것은 여전히 <Root> 컴포넌트가 렌더링 되며

동시에 <Contact> 컴포넌트가 함께 렌더링 되는 것을 기대한다.

하지만 현재 <Root> 컴포넌트와 <Contact> 컴포넌트의 라우터 경로는

//contacts/:contactId 라는 두 개의 독립적인 관계로 구성되어 있다.

Render an <Outlet>

<Contact> 컴포넌트가 <Root> 컴포넌트의 하위 컴포넌트로 렌더링 되기를 기대하기 때문에

두 컴포넌트의 라우터 레이어를 계층적 구조로 구성해주자

/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      // Contact 컴포넌트를 Root 컴포넌트의 하위 컴포넌트로 생성
      { path: 'contacts/:contactId', element: <Contact /> },
    ],
  },
]);

PathConstantsrouter 자료구조에서 <Root> 컴포넌트의 children 으로 <Contact> 컴포넌트를 넣어준다.

이를 통해 contacts/:contactId 로 라우팅 되기 위해서는 부모 라우터 주소인 / 가 먼저 렌더링 되기어

<Root> 컴포넌트가 렌더링 된 후

/contacts/:contactId 가 라우팅 된다.

URL 주소의 계층적 구조를 구성해주었기 때문에 / 로 라우팅 된 브라우저에서

하위 계층으로 라우팅 되더라도 여전히 부모 컴포넌트는 렌더링이 마저 되고 있는 모습을 볼 수 있다.

하지만 아직 자식 컴포넌트는 렌더링 되고 있지 않다.

이는 부모 컴포넌트에서 하위 컴포넌트를 렌더링 할 위치를 지정해주지 않았기 때문이다.

import { Outlet } from 'react-router-dom'; // Outlet 컴포넌트 import

export default function Root() {
  return (
    <>
      /* .. 기존 작성된 다른 태그들 .. */
      <div id='detail'>
        <Outlet />
      </div>
    </>
  );
}

<Outlet> 컴포넌트는 routes 자료구조에 저장된 children 에 담긴 라우터 객체들 중 현재 라우팅 된 URL의 주소와 매칭되는 컴포넌트를 찾아 렌더링 한다.

마치 props 로 전달 받은 children 컴포넌트를 내부에서 정의하는 것과 비슷하다.
하지만 따로 props 로 전달해주지 않으니 Context 를 이용하는 것 같다.

지피티를 좀 더 닥달하니 리액트 라우터 돔의 내부 훅인 useParams , useLocation , useNavigate 등의 훅을 이용한다고 하는데 해당 훅은 리액트의 useContext 를 활용했다고 한다.


Client Side Routing

That was not Client side routing !

다만 지금까지 한 것들이 Client Side Routing 이 아니라면 .. 믿으시겠습니까

다른 페이지로 라우팅 될 때 마다 네트워크 요청이 지속적으로 가는 모습을 볼 수 있다.

그 이유는 Root 컴포넌트에서 다른 페이지를 라우팅 하는 태그가 a 태그로 되어 있기 때문이다.

export default function Root() {
  return (
    /* .. 다른 컴포넌트 내용들 */
    <ul>
      <li>
        <a href={`/contacts/1`}>Your Name</a>
      </li>
      <li>
        <a href={`/contacts/2`}>Your Friend</a>
      </li>
    </ul>
    /* .. 다른 컴포넌트 내용들 */
  );
}

a 태그의 기본적 이벤트는 href 에 적힌 주소로 서버에 GET 요청을 보내는 것이다.

이에 해당 태그를 누르면 클라이언트는 서버에 basename/contacts/1 요청을 보내게 된다.

하지만 싱글 페이지를 제공하는 서버에서는 어떤 주소의 요청에 대해서도 기본 path 를 갖는 엔트리 파일만을 제공하기 때문에

index.js 가 제공 된다.

URL 주소는 태그를 눌러 요청한 /contacts/1 이다.

이후 코드를 파싱하는 과정에서 현재의 URL./contacts/1 이기 때문에 리액트 라우터 돔은

해당 path 에 맞는 컴포넌트를 렌더링 하는 것이다.

지금의 과정들은 일반적인 MPA 에서 라우팅 하는 방식과 차이가 없다.

import { Outlet, Link } from 'react-router-dom'; // Link 컴포넌트 import

export default function Root() {
  return (
    <>
	/* { 다른 작성된 태그들 .. }*/
        <nav>
          <ul>
            <li>
              <Link to={`/contacts/1`}>Your Name</Link> // a href -> Link to
            </li>
            <li>
              <Link to={`/contacts/2`}>Your Friend</Link>
            </li>
          </ul>
        </nav>
      /* { 다른 작성된 태그들 .. }*/
    </>
  );
}

<Link> 컴포넌트와 그의 propsto 는 다음과 같은 의미를 갖는다.

  1. <Link> 컴포넌트는 해당 태그를 클릭하면 to 로 지정된 페이지로 라우팅 하도록 한다.
  2. 해당 페이지를 라우팅 할 때 새로운 페이지를 요청하는 것이 아니라 브라우저의 히스토리 스택에 해당 to 에 적힌 path 를 추가한다.
  3. 2번 과정을 통해 브라우저의 뒤로 가기 및 앞으로 가기 버튼을 통한 탐색이 가능하다 .
  4. 리액트 라우터 돔은 변경된 path 에 맞춰 적절한 컴포넌트를 렌더링한다.

<a href=..> 와 다르게 <Link to=..>페이지 요청 없이 URL 주소만 변경하기 때문에 훨씬 빠르며 SPA스럽다.

출처 : How to Build a Routing Layer in React and Why You Need It

React Router tutorial


Loading Data

이전 게시글에서 라우팅 되는 URL 주소와 렌더링 되는 컴포넌트들을 이용해

SPA 에서 라우팅 기능을 구현했었다.

주소에 맞춰 컴포넌트를 렌더링 할 때에는 각 렌더링 할 컴포넌트에 대한 Data 가 필요하다.

이전 게시글에서 한 예시는 우선 데이터가 고정된 컴포넌트를 가지고 렌더링 하여,

어떤 곳으로 라우팅되든 상관 없이 항상 같은 컴포넌트가 렌더링 되었다.

라우팅 되는 컴포넌트들이 데이터를 받아 렌더링 될 수 있도록 설정해주자

다음처럼 src/contact.js 를 추가해주자

이 부분이 튜토리얼 내에서 내용이 없어서 스택 오버 플로우를 찾아 gist 링크를 가져왔다.
https://gist.githubusercontent.com/ryanflorence/1e7f5d3344c0db4a8394292c157cd305/raw/f7ff21e9ae7ffd55bfaaaf320e09c6a08a8a6611/contacts.js)
contact.js 파일은 가상의 네트워크에 쿼리문을 날려 계정을 만들거나 조회 , 삭제 하는 등의 로직을 가상으로 적어둔 파일이다.
현재 만들어준 파일을 통해 서버와 소통한다고 생각해보자

/* src/routes/root.jsx */

export async function loader() {
  const { contacts } = await getContacts(); 
  /* getContacts 메소드는 가상의 서버에서 Contact 정보들을 가져오는 함수 */

  return contacts;
}

root.jsx 파일에서 정보를 가져오는 함수를 생성하고 export 해주자

/* src/root.jsx */
// {다른 import 문들 .. }
import { Root, loader as rootloader } from './routes/root';


/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader, // routes layer 에서 loader 를 설정해준다. 
    children: [{ path: 'contacts/:contactId', element: <Contact /> }],
  },
]);

그리고 엔트리 파일에서 라우트 레어에를 담은 자료구조에서 <Root> 컴포넌트에서 사용 할

로더로 불러와 설정해주도록 하자

loader 메소드의 역할

loader 메소드는 라우팅 되어 컴포넌트를 해당 주소에 맞춰 렌더링 하기 전

비동기적으로 필요한 데이터를 가져오도록 한다.

위 예시에서는 path/ , /conatcts/:contactId 일 때에 비동기적으로

lodaer 로 정의된 메소드가 실행된다.

데이터를 불러오는 관심사를 컴포넌트 밖에서 정의해줌으로서

컴포넌트는 주어진 데이터를 렌더링 하는데 집중하도록 할 수 있다.

useLoaderData 메소드의 역할

/* { 다른 import 문들 .. } */
import { getContacts } from '../contact';

export function Root() {
  // useLoaderData 훅을 이용해 routes 에서 정의된 loader 메소드가
  // 반환하는 값을 컴포넌트 내부에서 불러와 사용한다. 
  const { contacts } = useLoaderData();

  return (
    <>
      /* {다른 기존 태그들 .. } */
		<nav>
          <ul>
            {contacts.length ? (
              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>
              ))
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </ul>
        </nav>
      /* {다른 기존 태그들 .. } */
    </>
  );
}

createBrowserRouter 에서 / 라는 URL 에 대해서 렌더링 할 엘리먼트는 Root 라고 하였다.

이 때 Root 컴포넌트를 렌더링 할 때 Root 컴포넌트에서 useLoaderData 를 이용해

loader 메소드에 정의된 함수가 반환하는 반환값을 Context 처럼 가져와 사용 할 수 있다.

현재는 가상 서버에 저장된 contacts 데이터가 없기 때문에 받아온 데이터가 없어

No Contacts 가 나온다.

Loading Data 정리

라우팅 할 때 렌더링 될 컴포넌트에게 필요한 정보를 useDataLoader 를 이용해 전달해줄 수 있다.

useDataLoader 는 가장 가까운 상위에 존재하는 loader 함수의 반환값을 사용한다.
loader 함수는 createBrowserRouter 내에서 정의된 loader 함수이다.


Data Writes + HTML Forms

서버 내에 Contacts 데이터가 없으니 추가해줄 수 있도록 new 버튼을 누르면

서버 측에 데이터를 작성 하여 보낼 수 있도록 해보자

현재의 컴포넌트는 해당 버튼을 누르면 액션을 취할 수 없다고 한다.

코드를 살펴보자

// src/Root.jsx

export function Root() {
	/* { 기존 코드들 .. } */
  return (
    	/* { 기존 코드들 .. } */
          <form method='post'>
            <button type='submit'>New</button>
          </form>
    	/* { 기존 코드들 .. } */

현재 New 버튼은 HTMLform method = 'post' 로 생성된 태그이다 .

기존 HTMLForm 태그의 작동 방식을 생각해보자

<form method = 'get/ post' action = '요청을 보낼 api 주소'>

기존 HTMLform 태그는 action 측에 적힌 api 주소측을 향해 form 내부에 존재하는 태그에 작성된 값들을

서버 측으로 보낸다.

이 때 methodget 일 경우엔 서버 측에 URL 주소에 정보를 저장하여 서버 측에 전송하고

post 일 때엔 서버 측에 내부 정보를 body 에 담아 서버 측에 전송한다.

전통적인 MPA 에서는 서버로 데이터를 전송한 후 서버측에서 새로 렌더링하여 보내주는 document 를 새로 받아 전부 reload 한다.

Form 컴포넌트

// src/Root.jsx
import { Outlet, Link, useLoaderData, Form } from 'react-router-dom';

export function Root() {
	/* { 기존 코드들 .. } */
  return (
    	/* { 기존 코드들 .. } */
          <Form method='post'>
            <button type='submit'>New</button>
          </Form> // form 태그를 react-router-dom 의 Form 태그로 변경
    	/* { 기존 코드들 .. } */

react-router-dom 에서 제공하는 Form 태그는 서버로 제출하는 행위가 일어났을 때

새로운 document 를 받아오지 않고

비동기적으로 서버 측에 데이터를 업로드 하고 , 렌더링에 필요한 정보를 받아 SPA 에서

페이지 리로드 없이 새롭게 업데이트 된 정보를 렌더링 한다.

action 메소드의 역할

이전 loader 함수를 createBrowserRoute 내부에서 정의해줬듯이

Form 태그에서 액션이 일어나면 (서버로부터 요청을 보내는) 서버와 통신하고 , 필요한 정보를 가져오는 함수를

action 메소드에 정의해주자

/* root route 설정 */
const router = createBrowserRouter([
  {
    /* {기존 존재하는 설정들 .. } */
    loader: rootloader,
    action: () => {
    /* 서버와 비동기적으로 요청을 주고 받는 로직들이 해당 부분에 적힘 ..  */
    return /* action 을 통해 추가된 객체 (기존 객체들이 모두 들어가는 것이 아니다 )*/ }
    },
    /* {기존 존재하는 설정들 .. } */
  },
]);

다음과 같은 예시로 말이다.

해당 로직은 src/contact.js 에서 정의해두었으니 해당 함수를 임포트해서 사용해주자 불러오자

/* src/routes/root.jsx */
import { getContacts, createContact } from '../contact';

export async function action() {
  const contact = await createContact();
  return { contact };
}
/* src/index.js*/
import {
  Root,
  loader as rootloader,
  action as rootaction,
} from './routes/root';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction, // action 메소드에 해당 메소드를 설정해줌 
    children: [{ path: 'contacts/:contactId', element: <Contact /> }],
  },
]);

여기서 포인트는 action 에 정의된 함수가 반환하는 값은 기존 loader 함수가 반환하는 것처럼

렌더링에 필요한 모든 정보가 아니라, Form 컴포넌트에 의해 제출된 객체이다.

기존에 정의된 createContact 함수에 대해서 살짝 살펴보자면

export async function createContact() {
  await fakeNetwork(); // 서버와 컨넥트 하고 
  let id = Math.random().toString(36).substring(2, 9); // new 버튼을 눌러 contact 를 생성하고 
  let contact = { id, createdAt: Date.now() }; // contact 객체에 저장 
  let contacts = await getContacts(); // 서버에 저장된 contacts 들을 불러와서 
  contacts.unshift(contact); // contacts 의 자료구조를 변경하고 
  await set(contacts); // 서버에 새롭게 변경된 contacts 를 저장한다. 
  return contact; // 반환하는 값은 서버에 추가한 contact 객체 하나 
}

리액트 라우터는 action 메소드가 반환하는 값 , 즉 loader 함수에서 반환하는

서버와 통신하여 가져오는 데이터의 값이 action 메소드가 종료 된 후 변경되었을 것이라 간주하여

자동으로 useLoaderData 에 의해 반환되는 객체를 변경시킨다.

이러한 과정들은 다음과 같은 과정들이 추상화되어 있다.
1. Form 컴포넌트 내부에서 submit 이벤트가 발생하여 action 메소드가 실행된다.

  1. 서버에게 비동기적으로 정보를 넘긴다. 이 때 기존 form 태그와 다르게 Form 컴포넌트는 페이지 reload 를 하지 않는다.

  2. action 메소드가 종료되고 나면 기본적으로 loader 메소드를 재실행시켜 컴포넌트가 렌더링 하는데 필요한 데이터를 업데이트 시킨다.

    그러니 만약 loader 메소드가 서버와 통신을 하는 경우라면 action 메소드가 실행되면 서버와 통신이 매 번 일어난다.

    다만 기존 form 태그와 다른 점은 form 태그는 항상 모든 페이지를 받아와 reload 하였다면 Form 컴포넌트는 컴포넌트 렌더링에 필요한 데이터만 받아와 렌더링 하기 때문에 코스트가 적게 든다.

  3. Root 컴포넌트가 새롭게 렌더링 될 때는 추가된 정보가 담긴 데이터를 이용해 렌더링 한다.


react router dom 아 고마워 ~!!

정리

react-router-domForm 컴포넌트는 다음과 같은 과정이 추상화 된 컴포넌트이다.

기본적으로 htmlform 태그를 기반으로 하여 만들어졌다.

다만 submit 버튼이 눌리면 서버와 비동기적으로 통신한 후 새로운 도큐먼트로 리다이렉트 시키는 default eventpreventDefault 를 이용해 방지한다.

이를 통해 SPA 에서 form 태그를 사용 할 수 있도록 한다.

Form 태그에서 submit 버튼이 눌렸을 때

서버와 비동기적으로 통신하여 제출하고 , 서버에 변경된 값과 브라우저의 렌더링 상태를 일치 시키기 위해서는

해당 Form 태그가 존재하는 컴포넌트가 정의된

CreateBrowserRoute 에 의해 생성된 Router Layer 배열에서 action 메소드에

서버와 비동기적으로 통신하는 로직과 서버에게 보낸 데이터를 반환하는 함수를 정의해줘야 한다.

react-router-dom 은 해당 action 메소드가 실행된 이후 서버에게 보낸 데이터를 받아

컴포넌트가 렌더링 할 때 사용하는 데이터를 변경시키고 변경된 데이터로 렌더링을 하도록 한다.

서버에게 추가적인 AJAX 요청을 받아오는 것이 아니다.


URL Params in Loaders

현재 New 버튼을 눌러 생성된 랜덤한 Contact 를 클릭하면 임의의 Contact 가 나오고 있다.

그 이유는 다음과 같다.

/* src/index.js*/
/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [{ path: 'contacts/:contactId', element: <Contact /> }],
    // 라우팅 시 <Contact> 컴포넌트를 렌더링 하도록 하는데 
   },
]);

/* src/routes/contacts.js */
export default function Contact() {
  // 현재 Contact 컴포넌트는 만들어둔 더미 contact 데이터를 이용해 렌더링 하기 때문에 
  // 어떤 페이지로 라우팅 되든 더미 데이터를 이용해 렌더링 된다. 
  const contact = {
    first: 'Your',
    last: 'Name',
    avatar: 'https://placekitten.com/200/200',
    twitter: 'your_handle',
    notes: 'Some notes',
    favorite: true,
  };

  return (
    <div id='contact'>
      <div>
        <img
          src={contact.avatar || null}
          alt={contact.first + contact.last}
          key={contact.avatar}
        />
      </div>

그러니 라우팅 되는 라우팅 패스에 따라 적절한 Contact 컴포넌트에서 데이터를 전달 할 수 있도록 설정햊주자

그 전 동적 파라미터에 대해 먼저 알고 가자

path : 'contacts/:contactId' 에서 :contactId동적 파라미터 (dynamic segment) 라고 불린다.

contacts/ .. 경로에서 .. 부분은 사용자의 이벤트에 따라 동적으로 변경 될 수 있으며

변경되는 동적 파라미터에 따라 동적으로 URL 경로도 변경된다.

서버측에서는 변경된 동적 파라미터를 사용함으로서 라우트 핸들러를 통해 적절한 콘텐츠를 렌더링 하도록 한다.

각 동적 파라미터들은 / 를 통해 구분된다.
예를 들어 contacts/:contactId 에서 다른 동적 파라미터인 contactProtocol 을 추가해주고 싶다고 해보자
그럴 때에는 contacts/:contactId/:contactProtocol 로 작성해줄 수 있다.

동적 파라미터를 이용하여 적절한 데이터를 가져오도록 설정하기

react-router-dom 에서는 동적 파라미터들을 params 라는 객체에 저장하여

loader 함수에게 인수로 params 프로퍼티를 전해주자

/* src/index.js */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      {
        path: 'contacts/:contactId', // 동적 파라미터에 맵핑된 값은 
        element: <Contact />,
        loader: (params) => { // params 객체에 들어가있다. ex) params = {contactId : '1'}
          const contactId = params.contactId;
          /* some Logic .. */
        },
      },
    ],
  },
]);

loader Recap

loader 함수는 path 에 적힌 URL 경로로 라우팅 되었을 때
렌더링 될 컴포넌트에게 필요한 정보를 useLoaderData 훅을 통해 접근 할 수 있도록 하는 함수이다.

그러면 :contactId 의 값에 따라 필요한 정보를 서버에서 가져오는 함수를 loader 함수에 지정해주도록 하자

/* src/routes/contacts.jsx */

import { Form, useLoaderData } from 'react-router-dom';
import { getContact } from '../contact';

/* 기존의 다른 코드들 ..*/

export async function loader({ params }) {  // params 프로퍼티를 디스트럭처링 해서 사용하자
  const contactId = params.contactId;
  const contact = await getContact(contactId);

  return { contact };
}

export function Contact() {
  const contact = useLoaderData(); // loader 함수가 반환하는 값을 가져와 사용 하도록 함 
  return (
    /* 기존의 다른 코드들 ..*/

동적 파라미터를 전달받는 loader 함수에서 params 가 아닌 {params} 를 사용하는 이유


loader 함수에게 인수는 단순히 동적 파라미터만 전달하는 것이 아닌, 요청에 대한 값이 담긴 request , 동적 파라미터들을 담고 있는 params , context 인수들을 전달한다.
이에 필요한 것들만 사용하기 위해 디스트럭처링을 활용하여 params 만 사용하도록 하자

/* src/index.js */
import { Contact, loader as contactLoader } from './routes/contacts';
    /* 기존의 다른 코드들 ..*/

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader, 
        /* 
         loader 함수를 설정해준다. 
		 Contact 컴포넌트 내에서 useLoaderData() 를 이용하면
		:contactId 값에 따른 반환값을 컴포넌트 내부에서 사용 할 수 있음
        */
        element: <Contact />,
      },
    ],
  },
]);
    /* 기존의 다른 코드들 ..*/

동적 파라미터인 contactId 의 값을 가져와 서버에게서 쿼리문을 날려 contact 객체를 가져오는 함수를

loader 함수에 지정햊줌으로서 Contact 컴포넌트에서 useLoaderData 를 이용하여

동적으로 라우팅되는 URL 경로에 맞춰 적절한 데이터를 가져와 렌더링 할 수 있다.

new 버튼을 눌러 생성한 contact 객체는 아직 id , Date 만 존재하는 빈 객체이다.

getContact 가 궁금한 사람을 위해

export async function getContact(id) {
  await fakeNetwork(`contact:${id}`);
  let contacts = await localforage.getItem('contacts');
  let contact = contacts.find((contact) => contact.id === id);
  return contact ?? null;
}

해당 코드는 가상의 서버에서 contact 라는 객체를 가져오는 가상의 메소드이다.
데이터들은 localforage 라이브러리를 이용하여 저장하고 해당 자료구조에서 쿼리문에 따른 객체를 가져온다.


중간 회고

내용이 방대하다 보니까 가끔씩 포인트를 놓치는 경우들이 있어 여태까지의 중간 회고를 해보려고 한다.

우선 SPA 에서의 라우팅 방식은 다음과 같은 흐름으로 진행된다.

  1. 클라이언트가 특정 페이지로 이동 할 수 있도록 하는 네비게이터들이 존재한다. (기존 MPA 에서의 a 태그와 같은)

  2. 이 때 이 네비게이터들은 react-router-dom 에서 제공하는 <Link to .. > 컴포넌트를 이용한다.
    2.1 Link 컴포넌트는 해당 페이지의 URL 의 경로를 변경시키기만 한다.

  3. react-router-domcreateBrowserRoute 메소드는 변경되는 URL 경로와 페이지를 구성하는 컴포넌트들을 동기화 할 수 있도록 다양한 프로퍼티와 메소드를 이용한다.

    3.1 createBrowserRouteRouting Layer 를 구성하며 구성하기 위해선 인자로 배열로 구성한 라우팅 레이어를 제공해야 한다.
    3.2 각 배열에는 계층에 맞게 다양한 프로퍼티와 메소드들이 존재한다.
    3.3 path 프로퍼티는 네비게이팅 된 URL 경로를 의미한다.
    3.4 element 프로퍼티는 네비게이팅 된 URL 경로에서 페이지에서 렌더링 될 컴포넌트를 의미한다.
    3.5 errorElement 는 해당 레이어의 자식 레이어들 중 해당 path 를 가진 경로로 클라이언트가 접근했을 때 렌더링 할 컴포넌트를 의미한다.
    3.6 loader 메소드는 element 에 지정된 컴포넌트가 렌더링 될 때 사용할 데이터를 load 하는 메소드이다. 해당 메소드에는 반환값으로 해당 컴포넌트가 렌더링 하는데 필요한 객체를 반환해야 한다.
    3.7 action 메소드는 Form 컴포넌트가 취할 액션에 대한 로직이 담긴 함수를 의미한다. 이 때 action 메소드는 element 가 사용할 데이터의 상태를 변경하기 위하여 추가 된 객체를 반환해야 한다.
    3.8 children 프로퍼티는 해당 라우팅 레이어의 자식 레이어들을 담은 배열이다.

여태까지의 내용을 정리해보자면 react-router-domSPA 에서 변경되는 URL 경로에 맞춰 페이지를 렌더링 할 수 있도록 도와주는 라이브러리이다.

기본 개념은 URL 경로를 window.history 객체를 이용하여 변경하며

변경되는 URL 경로에 맞춰 필요한 정보를 가져오고 , 해당 정보들을 이용해 컴포넌트를 호출해 렌더링 하는 방식이다.

SPA 에서 라우팅을 구현하기 위해 기존 HTML 태그들의 DefaultEvent 를 없앤

커스텀 컴포넌트들 (Link , Form ..etc )을 제공한다.


Updating Data

그런 new 버튼을 눌러 생성된 빈 contact 객체를 편집하는 컴포넌트를 만들어보자

src/routes/contacts.jsx

export function Contact() {
  const contact = useLoaderData();
  return (
  			...
          <Form action='edit'>
            <button type='submit'>Edit</button>
          </Form>
          ...

edit 버튼은 Form 태그로 만들어져있으며 actionedit 으로 적혀있다.

Form 태그는 form 태그를 커스터마이징한 컴포넌트로 action 어트리뷰트에 적힌 상대경로로

리다이렉션시킨다.

그러니 Edit 버튼을 누르면 URL 경로가 contacts/:contactId/edit 으로 변경된다는 것이다.

그러면 contacts/:contactId/edit 경로에서 렌더링 될 컴포넌트를 만들어주자

src/routes/edit.jsx

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

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

  return (
    <Form method='post' id='contact-form'>
      <p>
        <span>Name</span>
        <input
          type='text'
          name='first'
          defaultValue={contact.first}
          aria-label='first name'
          placeholder='fist'
        />
        <input
          type='text'
          name='last'
          defaultValue={contact.last}
          aria-label='last name'
          placeholder='last'
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          type='text'
          name='twitter'
          placeholder='@jack'
          defaultValue={contact.twitter}
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          type='text'
          name='avatar'
          aria-label='Avatar URL'
          defaultValue={contact.avatar}
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea name='notes' rows={6} defaultValue={contact.notes} />
      </label>
      <p>
        <button type='submit'>Save</button>
        <button type='submit'>Cancle</button>
      </p>
    </Form>
  );
}

src/index.js

..
import EditContact from './routes/edit-page';

const router = createBrowserRouter([
  {
    path: '/',
    ...
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },
      { // edit 버튼을 누르면 라우팅 될 컴포넌트를 라우팅 레이어에 추가
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        element: <EditContact />
      },
    ],
  },
]);


Updating Contacts with FormData

.../edit 까지 라우팅 되었을 때 edit 에 렌더링 된 컴포넌트는 하나의 거대한 Form 컴포넌트이다.

src/routes/edit.jsx

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

  return (
    <Form method='post' id='contact-form'>
      /* {기존에 적힌 다른 코드들 .. } */
       <p>
         /* 해당 버튼들을 누르면 액션이 취해진다. */
        <button type='submit'>Save</button>
        <button type='submit'>Cancle</button>
      </p>
    </Form>
  );
}

우리가 .../edit 페이지에서 Save 버튼을 눌렀을 때 원하는 동작은 다음과 같을 것이다.

  1. 우리가 Form 에 적어둔 정보가 서버에 저장되기를 기대 할 것이다.
  2. 저장이 완료된다면 이전 페이지로 리다이렉션 되기를 기대 할 것이다.

그러면 Save , Cancle 버튼이 눌렸을 때 일어날 행위를 action 메소드 내에 정의해주도록 하자

Post/Redirection/Get

어떤 폼을 제출한 후 새로운 페이지로 리다이렉션 시키고 새로운 페이지를 렌더링 하는 패턴을 다음처럼 부른다고 한다.
이러한 과정을 통해 동일한 폼이 중복적으로 제출되는 것을 방지하고 사용자 경험에 좋은 기여를 할 수 있다.

src/routes/edit.jsx

import { Form, useLoaderData, redirect } from 'react-router-dom';
// redirect 메소드를 추가로 import 
import { updateContact } from '../contact';

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

  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  // updateContact 는 임의로 생성해둔 서버 업데이트 로직
  await updateContact(contactId, updates); 

  return redirect(`/contacts/${contactId}`);
}

src/index.js

...
import { EditContact, action as editAction } from './routes/edit';

...
/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },
      {
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        action: editAction, // action 메소드 지정 
        element: <EditContact />,
      },
    ],
  },
]);

서버에 해당 Form 데이터에 있는 정보를 전송하고 우리가 기대하는 페이지로 리다이렉션이 잘 되는 모습을 볼 수 있다.

submit 버튼이 눌렸을 때 action 메소드가 실행되며 받는 인수를 살펴보면 request , params 의 모습들은 다음과 같이 생겼다.

UpdateContact 메소드가 궁금한 사람을 위해

export async function updateContact(id, updates) {
  await fakeNetwork();
  let contacts = await localforage.getItem('contacts'); // 서버에게서 모든 contacts 를 가져오고
  let contact = contacts.find((contact) => contact.id === id); // 수정할 contact 를 필터링 하고
  if (!contact) throw new Error('No contact found for', id);
  Object.assign(contact, updates); // contact 객체를 수정한다.
  await set(contacts); // 변경된 contact 가 있는 contacts 를 서버에 업데이트
  return contact;
}

react-router-domaction 메소드가 반환하는 값을 보고 자동으로 로직을 실행한다.

...
/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction, // rootaction 의 반환값은 { contact }
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },
      {
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        action: editAction, // editAction 의 반환값은 redirection(...)
        element: <EditContact />,
      },
    ],
  },
]);
...

routeraction method 들의 반환값은 서로 다른 타입의 객체들이다.

그런데 위의 rootaction 의 반환값은 Root 컴포넌트에서 이용하는 데이터의 상태를 업데이트 한다고 하고

editAction 의 반환값을 이용해서는 리다이렉션 시킨다고 한다.

react-router-domaction 메소드가 반환하는 객체의 타입에 따라 동적으로 로직을 결정한다.

  1. Object 타입 반환

객체 타입으로 반환된 경우 react-router-dom 은 상태 변경을 일으키거나 다음 컴포넌트 렌더링 시 해당 데이터를 전달해줄 수 있다.

우리의 예시에서는 추가 된 contact 객체를 반환받아, Root 컴포넌트를 렌더링 할 때 사용되는 contacts 배열을 자동으로 업데이트 해주었다.

  1. redirection 반환

redirection 이 반환된 경우에는 인수로 전달한 경로로 라우팅 시킨다.


redirection 함수가 어떤 값을 반환하나 봤더니 Post 요청 후 서버의 response 가 담긴 객체를 반환한다.

공식문서의 숏컷을 보면 좀 더 명확하게 이해가 된다.

react-router-dom 은 서버의 요청을 받은 후 인수에 적힌 상대 경로로 라우팅 하는 것으로 생각된다.

상태 코드나 상태 텍스트에 따라서 어떤 처리를 하는지까지는 아직은 모르겠다. 튜토리얼을 더 진행해보고 loader , action 에 적힌 내용들을 좀 더 봐야겠다.

  1. 아무것도 반환하지 않음

아무것도 반환하지 않을 경우엔 특별한 라우팅 없이 현재 페이지를 유지하도록 한다.


Redirecting new records to the edit page

현재는 New 버튼을 누르면 contact 가 서버에 추가되고

사이드바에서 추가된 No name contact 를 눌러 edit 버튼을 클릭해야 했다.

차라리 자동적으로 new 버튼이 눌린 이후 추가된 contactedit 페이지로 리다이렉팅 시켜보자

src/routes/root.jsx

import { Outlet, Link, useLoaderData, Form, redirect } from 'react-router-dom';
/* {기존의 다른 코드들 .. } */

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


Active Link Styling

react-router-dom 은 현재 라우팅 시킨 정보에 대한 시각적 피드백을 사용하고자 하는 시나리오에 이상적이다.

위 공식 사이트 내의 라우팅을 유발시키는 사이드바를 클릭하면 생기는 일들을 보자

주소가 변경되고 렌더링이 변경될 때

해당 라우팅을 유발시킨 네비게이션 바의 색상이 변하면서

현재 라우팅 된 페이지에 대한 정보를 제공해준다.

이런 기능을 추가하고 싶다면 어떻게 할까 ?

현재 Root 컴포넌트 내에서 다른 페이지로 라우팅 시키는 Link 컴포넌트들은 위와 같다.

src/routes/root.jsx

		/* {Root 컴포넌트 반환문의 일부 .. } */
		<nav>
          <ul>
            {contacts.length ? (
              contacts.map((contact) => (
                <li key={contact.id}>
                  <Link to={`contacts/${contact.id}`}> // Link 컴포넌트
                    {contact.first || contact.last ? (
                      <>
                        {contact.first} {contact.last}
                      </>
                    ) : (
                      <i>No name</i>
                    )}{' '}
                    {contact.favorite && <span></span>}
                  </Link>
                </li>
              ))
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </ul>
  		</nav>

우리가 원하는 것은 Link 컴포넌트가 클릭되어 다른 페이지로 라우팅 되었을 때

현재 어떤 페이지를 보고 있는지 or 현재 어떤 것이 렌더링 되고 있는지가 궁금하다.

NavLink 컴포넌트는 react-router-dom 에서 제공하는 컴포넌트이다.

이는 Link 컴포넌트에서 몇 가지 기능이 추가된 컴포넌트이다.

기본적인 생김새는 다음과 같다.

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

<NavLink
  to="/messages"
  className={({ isActive, isPending }) =>
    isPending ? "pending" : isActive ? "active" : ""
  }
>
  Messages
</NavLink>;

하나씩 살펴보자

NavLink 를 이해하기 위해 다른 앱을 만들어 사용해봤다.

export default function Root() {
  return (
    <div>
      <nav className='side-bar'>
        <ul>
          <NavLink to='/content/1'>Content 1</NavLink>
          <NavLink to='/content/2'>Content 2</NavLink>
          <NavLink to='/content/3'>Content 3</NavLink>
        </ul>
      </nav>
      <Outlet /> // 라우팅 된 엘리먼트가 렌더링 될 영역
    </div>
  );
}

다음처럼 특정한 path 로 라우팅 하는 NavLink 들을 만들어두고

각 링크를 클릭하여 라우팅 시켜보자

라우팅 될 때 마다 라우팅 시킨 NavLink 컴포넌트가 가리키는 a 태그에

class = 'active' 가 붙는 모습을 볼 수 있다.

그럼 a.active 에 대한 css 속성을 넣어주면 라우팅 시키는 컴포넌트를 가리키는 것이 가능할 것이다.

a.active {
  background-color: aquamarine;
  color: red;
}

이렇게 기본적으로 NavLink 는 네비게이팅 시킨 컴포넌트에게는 class 명으로 active 로 ,

네비게이팅 되지 않은 컴포넌트에게는 active 라는 클래스명을 주지 않는다.

className

NavLinkclassName props 는 조건에 따라 클래스명을 반환하는

함수를 지정해줄 수 있다.

NavLink 컴포넌트는 className , style props 들에서 사용하는 함수에게

기본적으로 isActive , isPending , isTransitioning 이라는 boolean 값을 제공한다.

export default function Root() {
  return (
    <div>
      <nav className='side-bar'>
        <ul>
          <NavLink
            to='/content/1'
            className={({ isActive, isPending, isTransitioning }) => {
              if (isActive) return 'custom-active';
              if (isPending) return 'custom-pending';
              if (isTransitioning) return 'custom-transtioning';
            }}
          >
            Content 1
          </NavLink>
          <NavLink
            to='/content/2'
            className={({ isActive, isPending, isTransitioning }) => {
              if (isActive) return 'custom-active';
              if (isPending) return 'custom-pending';
              if (isTransitioning) return 'custom-transtioning';
            }}
          >
            Content 2
          </NavLink>
          <NavLink
            to='/content/3'
            className={({ isActive, isPending, isTransitioning }) => {
              if (isActive) return 'custom-active';
              if (isPending) return 'custom-pending';
              if (isTransitioning) return 'custom-transtioning';
            }}
          >
            Content 3
          </NavLink>
        </ul>
      </nav>
      <Outlet />
    </div>
  );
}
.custom-active {
  background-color: green;
  color: red;
}

.custom-pending {
  background-color: orange;
}

.custom-transtioning {
  background-color: red;
}

이렇게 클래스명을 왔다 갔다 하면서 설정해도 되고 style props 에서 설정해줘도 된다.

isActive

현재 NavLink 가 활성화 되어있는지를 의미한다.

즉 라우팅 되어 변경된 URL 이 해당 링크의 to 어트리뷰트와 같은지를 이야기 한다.

isPending

현재 NavLink와 관련된 탐색이 pending 상태인지 여부를 나타낸다.

비동기 작업이 포함된 경우 가져오고자 하는 값이 setteled 되지 않았을 때를 의미한다.

비동기 작업이 setteld 되어 라우팅이 완료되면 false 가 되고 isActivetrue 가 된다.

isTransitioning

현재 다른 경로 간에 전환중인지 여부를 나타낸다.

사용자가 다른 경로로 이동하기 위해 경로 전환을 시작하면 true 가 된다.

위 예시에서는 isTransitioning 이 되지 않는 이유는 각 to 어트리뷰트의 값이 content/:contentIdcontent 라는 동일한 경로에서 url params 의 값만 변경되기 때문이다.

이 3가지 boolean 값들은 className , style props 에만 전달해줄 수 있는 것이 아니라 내부에 존재하는 다른 컴포넌트들, 즉 children 에게도 전달해줄 수 있다.

         <NavLink
            to='/content/3'
            className={({ isActive, isPending, isTransitioning }) => {
              if (isActive) return 'custom-active';
              if (isPending) return 'custom-pending';
              if (isTransitioning) return 'custom-transtioning';
            }}
          >
  /* children 에게도 인수로 넘겨줘 children 태그를 동적으로 생성 할 수 있음  */
            {({ isActive }) => (
              <span className={isActive ? 'active-text' : 'default-text'}>
                Content 3
              </span>
            )}
          </NavLink>
.active-text {
  color: white;
}

.default-text {
  color: black;
}

추가적인 props 들이 더 있으니 그 부분은 공식 문서 를 통해 확인해보자


src/routes/root.jsx

		/* {Root 컴포넌트 반환문의 일부 .. } */
		<nav>
          <ul>
            {contacts.length ? (
              contacts.map((contact) => (
                <li key={contact.id}>
                  <NavLink to={`contacts/${contact.id}`}> // Link 컴포넌트
                    {contact.first || contact.last ? (
                      <>
                        {contact.first} {contact.last}
                      </>
                    ) : (
                      <i>No name</i>
                    )}{' '}
                    {contact.favorite && <span></span>}
                  </NavLink> // Link -> NavLink
                </li>
              ))
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </ul>
  		</nav>

#sidebar nav a.active {
  background: hsl(224, 98%, 58%);
  color: white;
}

다시 본론으로 돌아와 LinkNavLink 로 바꿔준다.

또한 active 인 선택된 컴포넌트의 색상을 설정해준다.


Global Pending UI

현재 쓰고 있는 서버와의 통신에서 (사실 엄밀히 말하면 통신인척 하는 Promise 객체) 데이터를 가져오기 위해 서버에게 요청을 보내는 동안

딜레이가 존재한다.

한 번 가져온 후에는 딜레이가 걸리지 않는 이유는 캐싱 기법을 흉내냈기 때문이다.

src/contact.js

...
export async function getContacts(query) {
   /* 서버에서 데이터를 가져오는 동안 렌더링이 멈춘다. 
   서버의 상황에 따라 렌더링이 더욱 늦을 수 있다.
   */
  await fakeNetwork(`getContacts:${query}`);
  let contacts = await localforage.getItem('contacts');
  if (!contacts) contacts = [];
  if (query) {
    contacts = matchSorter(contacts, query, { keys: ['first', 'last'] });
  }
  return contacts.sort(sortBy('last', 'createdAt'));
}
...

이는 서버에서 요청을 가져오는 동안 렌더링이 멈추기 때문이다.

좀 더 reactive 했으면 좋겠다는 피드백을 받았다는 가정을 하고 튜토리얼에서는 이야기를 한다.

어떻게 하면 UX 를 더 늘릴 수 있을까 ?

그건 아마도 서버의 요청이 도착하기 이전까지 어떤 화면이 렌더링 되면 좀 더 지루함을 줄일 수 있을 것이다.

설명을 하기 전 먼저 완성본을 보자

서버의 요청이 처리되는 동안 로딩중임을 나타내듯 화면을 렌더링 시키고

서버의 요청이 완료되면 새로운 주소로 라우팅 시킨다.

useNavigation

리액트에서는 서버와의 요청 정보를 담는 Navigation 객체를 제공하고

해당 객체를 useNavigation 을 통해 불러와 사용 할 수 있다.

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

function SomeComponent() {
  const navigation = useNavigation();
  navigation.state; // 서버와의 요청의 응답 상태 
  navigation.location; // 다음으로 라우팅 될 주소
  navigation.formData; //   // POST , DELETE , PATCH 등 body 에 
  //form 을 넣는 경우 해당 form 데이터 
  navigation.json; // body 에 있는 JSON 데이터 
  navigation.text; // body 에 있는 text 데이터 
  navigation.formAction; // form 요청 시 action 으로 설정한 주소 
  navigation.formMethod; // GET 을 제외한 서버와의 데이터 교환 시 사용한 method 
  navigation.formEncType; // 헤더에 사용한 엔터티 타입 
}

네비게이션 객체에는 이와 같은 프로퍼티들이 존재하며 자세한 내용은 useNavigation 6.22.3을 참고하자

Root 컴포넌트에서 김딩가 를 클릭한 경우를 살펴보자

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader, 
    action: rootaction, 
    children: [
      // 0. Root 컴포넌트의 NavLink 컴포넌트로 인해 하위 레이어 시행
      {
        path: 'contacts/:contactId', // 3. 페이지 라우팅 
        loader: contactLoader, // 1.렌더링에 필요한 데이터를 얻기 위해
        					//loader 메소드 시행 (이 동안 시간이 소요됨 )
        element: <Contact />, // 2. loader 의 반환값을 이용해 렌더링 
      },
      {
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        action: editAction,
        element: <EditContact />,
      },
    ],
  },
]);

새로운 페이지로 라우팅이 될 때 URL주소가 먼저 이동하는 것이 아니라

loader 메소드가 실행되어 렌더링에 필요한 데이터를 서버로부터 가져오기 전까지

렌더링은 멈춰있는다.

이후 loader 메소드가 실행이 완료된 후에는 가져온 데이터를 element 에 정의된 컴포넌트에서 불러와

렌더링과 URL 경로 이동이 동시에 일어난다.

Navigation 객체의 stateloader 메소드가 실행 되기 전 , 실행 후 로 변경된다.

  • idle : 네트워크와 통신이 존재하지 않는 상태 (종료되었거나, 시작하지 않았거나)
  • submitting : 네트워크와의 통신이 POST , PATCH , DELETE , PUT 등 서버에게 Form 데이터를 전송한 상태
  • loading : 다음 렌더링 될 컴포넌트를 위해 (위 예시에서는 Contact) loader 메소드가 실행된 상태

해당 객체의 상태 변경을 이용하여 Root 컴포넌트에서 기존에 렌더링 된 컴포넌트의 클래스를 변경하여 로딩중임을 렌더링 하도록 해보자

import { /* {다른 메소드들} */ useNavigation } from 'react-router-dom';
// 0. Naviggation.state = idle
// 1. loader 메소드가 실행되어 Navigation.state = loading 
export function Root() {
  // 2. Root 컴포넌트가 다시 렌더링 된다. 
  const { contacts } = useLoaderData();
  const navigation = useNavigation();
  return (
    <>
		/*{기존 코드들 .. } */
      <div
        id='detail' // 3. 렌더링 될 때 className 이 loading 으로 변하여
        			// 로딩중인 것 처럼 다르게 렌더링 
        className={navigation.state === 'loading' ? 'loading' : ''}
      >
        <Outlet />
      </div>
    </>
  );
}

// 4. loader 메소드가 완료되면 Navigation.state = idle 로 변경되어 
// 다시 렌더링 될 때에는 평소처럼 렌더링 됨 


Deleting Record

이번에는 서버측에 contact 를 삭제해보도록 해보자

src/routes/contacts.jsx

/* {다른 컴포넌트 코드들 .. } */
          <Form
            method='post'
            action='destory'
            onSubmit={(event) => {
              if (!window.confirm('너 진짜로 삭제할거야 ?')) {
                /* window.confirm 은 확인과 취소 두 버튼을 가지며 메시지를 지정 할 수 있는
                 모달 대화 상자를 띄운다.
                 해당 대화 상자에서 거절을 누를 경우 preventDefault;
                */
                event.preventDefault(); // action 에 적힌 곳으로 라우팅 시키지 아니함 
              }
            }}
          >
            <button type='submit'>Delete</button>
          </Form>
/* {다른 컴포넌트 코드들 .. } */

현재 Delete 버튼에 대한 컴포넌트는 Form 컴포넌트로

method = post , action = 'destory' 로 되어 있다.

이는 해당 버튼을 누르면 contacts/:contactId/destory 페이지로 라우팅 된다는 것이다.

그러면 이에 해당하는 내용을 라우팅 레이어에 추가해주자

src/routes/destory.jsx

import { redirect } from 'react-router-dom';
import { deleteContact } from '../contact';

export async function action({ params }) {
  // deleteContact 는 서버에게 contactId 를 가진 contact 를 제거하는 
  // 메소드
  await deleteContact(params.contactId); 

  return redirect('/');
}

src/index.js

...
import { action as deleteAction } from './routes/destory';
...
/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },
      {
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        action: editAction,
        element: <EditContact />,
      },
      {
        path: 'contacts/:contactId/destory', 
        action: deleteAction,
      },
    ],
  },
]);
...

이처럼 해당 Delete 버튼이 클릭되면

사실 Form 컴포넌트에 의해 해당 path 로 라우팅이 될 때를 의미한다.

해당 contactId 를 가진 데이터를 서버에서 제거하고 redirect 시킨다.


Contextual Error

src/routes/destory.jsx

import { redirect } from 'react-router-dom';
import { deleteContact } from '../contact';

export async function action({ params }) {
  await deleteContact(params.contactId); 
  throw new Error('에러가 발생했는뎁슈 '); // 억지로 에러를 발생시켜보자 

  return redirect('/');
}

서버와의 통신 중 예기치 못한 에러가 발생했다고 가정해보자

다음처럼 에러가 발생하면 상위 라우팅 레이어에 존재하는 Root 레이어 계층의 errorElement 가 렌더링 되는 모습을 볼 수 있다.

이처럼 react-router-dom 은 에러가 발생할 경우 본인 계층으로부터 상위 계층까지 errorElement 를 탐색해나가며 가장 가까이 존재하는 errorElement 를 렌더링 한다.

삭제에 실패한 경우 렌더링 할 컴포넌트를 간단하게 작성해주자


/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },
      {
        path: 'contacts/:contactId/edit',
        loader: contactLoader,
        action: editAction,
        element: <EditContact />,
      },
      {
        path: 'contacts/:contactId/destory',
        action: deleteAction,
        // 현재 컨텍스트에서 errorElement 생성 
        errorElement: <h1> 삭제에 실패했슴둥</h1> 
      },
    ],
  },
]);

깨알같지만 에러가 발생하면 발생한 에러 시점 이후의 코드들은 실행이 되지 않아 redirect 가 되지 않는다.


Index Routes

현재 메인 페이지를 나타내는 / 경로에서는 어떠한 path 로도 라우팅 되지 않았기에

sidebar 부분을 제외하면 아무런 컴포넌트가 따로 렌더링 되고 있지 않은 모습을 볼 수 있다.

이에, 다른 경로로 라우팅 되지 않더라도 부모 라우팅 레이어가 렌더링 될 때

같이 렌더링 될 수 있게 해주는 index Route 에 대해 알아보자

나는 현재 react-router-dom v6 에서 처음 접해서 route 객체라는 것이 익숙치 않다.

하지만 공식문서를 보다보면 이전 버전에서는 route 를 따로 레이어를 통해 만드는 것이 아닌 컴포넌트 자체에서 레이어를 만들어준 듯 보인다.

/* 이전 버전의 예제 코드 */
<Route path="teams" element={<Teams />}>
  <Route path=":teamId" element={<Team />} />
  <Route path="new" element={<NewTeamForm />} />
  <Route index element={<LeagueStandings />} />
</Route>

index Routepath props 를 이용해 라우팅 하는 것이 아닌, index props 를 사용해준다.

<Route path="teams" element={<Teams />}>
  <Route path=":teamId" element={<Team />} />
  <Route path="new" element={<NewTeamForm />} />
  <Route index element={<LeagueStandings />} /> // <-- index Route
</Route>

백문이 불여일견이라고 먼저 만들어보고 생각해보자

src/routes/index.jsx

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

src/index.js

... 
import Index from './routes';

/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      // index props 를 true 로 설정
      { index: true, element: <Index /> },  
      ... 
    ],
  },
]);

index route 를 이용하면 부모 라우팅 레이어가 렌더링 되고 있을 때

path 의 변화 없이도 parent component<Outlet /> 자리에 index route 를 생성해줄 수 있다.

index route use case

index route 를 사용하면 상위 구성 요소가 렌더링 될 때 (주로 / 경로에서 렌더링)

하위 구성 요소의 렌더링 여부와 상관 없이 렌더링 되어야 하는

기본 구성 컴포넌트/ 에서만 렌더링 되기를 기대하는 컴포넌트를 분리 해줄 수 있다.

index route 는 오로지 상위 구성 요소가 렌더링 된 경로에서 <Outlet /> 자리에서 렌더링 되기 때문이다.

index route 를 통해 상위 구성 요소의 렌더링을 더욱 깔끔하게 유지 할 수 있다.


Cancle Button

Contact 컴포넌트의 /edit 경로에서는 Save , Cancle 버튼이 있는 모습을 볼 수 있다.

Cancle 버튼의 동작을 구현해보자

필요할 것이라 생각되는 기능은 그저 단순하게 contact/:contactId/edit 경로에서 contact/:contactId 경로로 변경 되기만 하면 될 것이다.

서버와 통신을 하지 않으며 말이다.

물론 적어뒀던 것을 서버에 보낸 후 해당 페이지를 렌더링 할 때 이전에 적어둔 내용을 불러오려면 서버와 통신을 해야하긴 하겠지만 해당 기능은 구현하지 않는다고 둬보자

useNavigate

react-router-domwindow.history API 를 이용하여 구현되었다.

useNavigatewindow.history API 를 조작하는 것 처럼 history stack 을 이용해

경로를 변경하거나 history stack 에 자료를 저장하여 건내주는 등의 일이 가능하다.

더 자세한 내용은 공식문서를 보고 추후 공부해보기로 하고 useNaviage v.6.22.3 가장 기본적인 동작으로 path 를 조작해보자

src/routes/edit.jsx

import { Form, useLoaderData, redirect, useNavigate } from 'react-router-dom';
...
export function EditContact() {
  const contact = useLoaderData();
  const navigate = useNavigate(); // navigate 객체 생성 
  return (
    ...
            <button type='submit'>Save</button>
        <button
          /*
          type 이 submit 이면 action 메소드가 실행되니 
          type 을 바꿔주자 
          */
          type='button' 
          onClick={() => {
        /* 인수로 넘겨주는 인수로 widow.history 의 stack 에 담긴 
        경로로 라우팅 한다. 
        */
            navigate(-1); 
          }}
        >
          Cancle
        </button>
	...
}


Get submission with client side routing

검색 기능이 일을 하도록 만들어보자

src/routes/root.jsx

...
export function Root() {
  // useLoaderData 훅을 이용해 routes 에서 정의된 loader 메소드가
  // 반환하는 값을 컴포넌트 내부에서 불러와 사용
  const { contacts } = 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' // <-- submit 될 때 사용될 key 값 
            />

            <div className='sr-only' aria-live='polite'></div>
          </form>
	...

검색 필드에서 텍스트를 치고 따로 submit 버튼없이 엔터만 눌러도

따로 Link ,NavLink , a 등과 같은 네비게이터 컴포넌트를 사용하지 않아도

/?q = .. 라는 파라미터를 갖는 새로운 경로로 라우팅이 되는 모습을 볼 수 있다.

이것은 다음과 같은 이유로 인해 발생한다.

  1. <form><input .. name = .. ></form> 과 같이 form 태그 하나에 input 태그가 단 1개있을 때에는 엔터만 입력해도 form 태그가 submit 된다.

  2. 이 때 form 태그가 submit 될 때 method 를 따로 지정해주지 않으면 method = 'GET' 의 형태로 submit 된다.

  3. method = GET 형태로 제출된다는 것은 , 서버 측에게 <input name = .. > 으로 지정한 값이, URL parameter 형태로 추가된 URL 에 대한 페이지를 요청하는 것과 같다.

  4. 그러니 위 예시에서는 /?q='하위' 의 페이지를 주세요 ~ 라고 요청한 것과 같다.

src/contact.js

..
export async function getContacts(query) {
  // 서버측에서는 쿼리 문을 필두로 contacts 자료를 찾아 반환한다. 
  await fakeNetwork(`getContacts:${query}`);
  let contacts = await localforage.getItem('contacts');
  if (!contacts) contacts = [];
  if (query) {
    // matchSorter 는 라이브러리로 , 3번째 인수인 객체의 keys 배열의 프로퍼티들을
    // 일렬화 한 후 query 문과 매칭되는 객체들을 반환한다.
    contacts = matchSorter(contacts, query, { keys: ['first', 'last'] });  
  }
  return contacts.sort(sortBy('last', 'createdAt'));
}
...

서버 측에서는 쿼리 문에 따라 자료를 찾아 반환하고 있기 때문에

클라이언트 사이드 단에서 서버 단에 쿼리문을 날리도록 해보자

src/routes/root.jsx

export async function loader({ request }) {
  // 1. 서버에 GET 요청을 한 url 주소를 URL 객체 형태로 만든다.
  const url = new URL(request.url); 
  // 2. URL 객체의 프로토타입 메소드인 serachParams 를 통해 
  // url parameter 들을 Map 객체 형태로 가져온다. 
  const param = url.searchParams.get('q');
  // 3. input filed 에 적혀있던 서버에 전송하여 필요한 contacts 들을 가져온다.
  const contacts = await getContacts(param);

  return { contacts };
}

이와 같은 이유로 엔터키를 누르면 해당 Form 컴포넌트가 제출되기 때문에

서버 측에 GET 요청을 보내게 되고 해당 쿼리 문에 맞는 contacts 객체들을 받아 렌더링 하게 된다.

loader 메소드는 라우팅 되는 경로가 이전과 달라지면 항상 재실행 된다.
Form 컴포넌트가 제출되게 되면 경로가 /?q = .. 로 붙어 변경되기 때문에
loader 메소드는 재실행되게 된다. (/?q = .. 가 붙은 url 을 이용해서 )


Submitting Forms onChange

위 예시에서는 input 에서 엔터키를 누르면

해당 input 태그가 존재하는 Form 컴포넌트가 제출되는 것이라고 했다.

react-router-domForm 컴포넌트의 제출 방식은 기존과 다르다.

특히 위 양식에서는 Form 컴포넌트를 GET 방식으로 제출한다.

Form 컴포넌트를 GET 방식으로 제출한다는 것은

react-router-dom 에서는 input 태그에 적힌 value 값을 input 태그에 적힌 name 어트리뷰트와 key , value 형태로 하여

페이지를 ..path/?name=value 인 곳으로 redirect 시키는 것과 같다.

페이지가 redirect 가 되면 해당 경로에 대한 loader 메소드가 실행되어 데이터를 가져오고 , 해당 데이터를 같은 라우팅 레이어의 element 컴포넌트에서 useLoaderData 를 이용해 가져와 새롭게 렌더링 하는 것이다.

으아 글을 쓰다보니 너무 장황하다. 나중에 튜토리얼을 모두 쓰고 나면 이해한 것을 정리해서 한 번 더 써야겠다.

src/routes/root.jsx

...
          <Form id='search-form' role='search'> 
		  // method : get (default)  , action = '현재path' (default) 
            <input
              id='q'
              aria-label='Search contacts'
              placeholder='Search'
              type='search'
              name='q'
              }}
            />

            <div className='sr-only' aria-live='polite'></div>
          </Form>

현재의 컴포넌트에서 input 태그내에 글을 작성하고 Enter 키를 눌러야만

해당 Form 컴포넌트가 GET 방식으로 제출된다.

다시 말하지만 Form 컴포넌트를 GET 방식으로 제출한다는 것은 action 에 적힌 경로 /?q=value 경로로 라우팅 시키는 것을 의미한다.

여기서 깔쌈하게 Enter 키를 누르는 것이 아니라 입력값이 변하기만 해도 제출이 되게 하고 싶다면 useSubmit 훅을 이용해보자

useSubmit

useSubmitreact-router-dom 에서 제공하는 훅으로

submittingSPA 에서 할 수 있도록 구현해둔 훅이다.

우선 사용 예시를 먼저 살펴보자

import {
  ...
  useSubmit,
} from 'react-router-dom';


export function Root() {
  ...
  const submit = useSubmit(); // submit 메소드를 불러와 사용 
  return(
    	...
  		<Form id='search-form' role='search'> 
  			// 2. input 태그의 값이 바뀔때마다 해당 Form 컴포넌트가 제출된다. 
  			// (action  ,method 에 정의된 형식으로)
            <input
              id='q'
              aria-label='Search contacts'
              placeholder='Search'
              type='search'
              name='q'
              onChange={(event) => {
                submit(event.target.form); // 1. input 태그를 감싸고 있는 form 태그를 submit
              }}
            />

            <div className='sr-only' aria-live='polite'></div>
          </Form>
    ...

위와 같은 형태로 사용해주며녀 input 의 값이 변할 떄 마다 event.target.form 태그가 제출되는 것이 되어

<Form id = .. , role = 'search' > 컴포넌트가 제출된 것과 같은 효과를 갖는다.

Form 컴포넌트가 제출되면 현재path/action 에 적힌 path/?name = value 형태 경로로

리다이렉션 되는거라고 했다.

구우웃

useSubmit 가벼운 딥다이브

useSubmit 은 두 가지 인수를 갖는다.

const submit = useSubmit();

submit(제출할 내용 , {method = 'get' , action = '현재 path' }) // (default)

두 번째 인수는 조건적으로 사용해주면 된다.

만약 두 번째 인수를 따로 정의해주지 않으면 method 는 get , action 은 호출된 페이지의 현재 path 가 기본적으로 사용된다.

다만 제출하고자 하는 자료가 Form 컴포넌트일 경우 (혹은 form 태그 ) 에는

해당 컴포넌트에 작성되어 있는 method , actionoverriding 하여 사용한다.

하지만 Form 컴포넌트를 제출하고 해당 컴포넌트의 어트리뷰트로 method , action 이 지정되어 있더라도

submit 의 두 번째 인수가 지정되어 있다면 , submit 에서 지정된 인수를 사용한다.

          <Form id='search-form' role='search' method='get'> // 제출하는 컴포넌트는 GET 요청 
            <input
              ...
              name='q'
              onChange={(event) => {
                submit(event.target.form, { method: 'post' }); // submit 에선 POST 요청 
              }}
            />
  			...

          </Form>

submit 에서 설정한 methodoverriding 되어 제출된다.

해당 경로에서 Form submit 이 일어나면 action 메소드가 실행되는데 action 메소드는 새로운 값을 추가하는 메소드로 정의해두었다.

그래서 새로운 contact 들이 추가되는 것이다.

위 예시를 차라리 submit(Form 컴포넌트 ,{method : 'post'}) 가 실행된다면

<Form ... method : 'post'> 가 제출되는 것으로 생각해도 된다.

submitForm 컴포넌트만 제출 가능한 것이 아니라 다양한 것들을 제출하는 것이 가능하다.

let searchParams = new URLSearchParams();
searchParams.append("cheese", "gouda");
submit(searchParams);
 // GET 형태로 URLSerachParams 를 제출 , ?cheese=qouda 로 GET 요청이 행해질 것이다.

---

submit("cheese=gouda&toasted=yes");
submit([
  ["cheese", "gouda"],
  ["toasted", "yes"],
]);
// 두 예시는 모두 URL params  형태로 GET 요청을 처리한다. 

---
  
submit(
  { key: "value" },
  {
    method: "post",
    encType: "application/x-www-form-urlencoded",
  }
);
// 혹은 다음처럼 POST 형태로 제출하여 action 메소드를 실행 시킬 수도 있다. 

정리

useSubmit 은 첫 번째 인수에 적힌 객체를 두 번째 인수인 {method = .. , action = ..} 방법에 맞게 적절히 제출시키는 메소드를 불러오는 훅이다.

불러와진 메소드로 (어떤 데이터 , {method = .. , action = ..}) 를 호출했다면 적힌 action 어트리뷰트에 적힌 엔드포인트로 리다이렉션 시키고
(이는 기본적인 Form 컴포넌트의 제출 방식과 동일하다)

method 에 정의된 형태에 맞춰 첫 번쨰 인수로 전달받은 데이터를
이동된 엔드포인트에서 정의된 action method 에 전달한다.

애초에 Form 컴포넌트의 역할 자체는 action props 에 적힌 경로에게 Form 컴포넌트 내부에서 작성된 값을 이용해 어떤 객체를 만들고 해당 객체를 전달하는 역할을 한다.

만약 methodGET 이라면 Form 컴포넌트 내부에 작성된 input 태그들을 이용해 URL params 를 만들고 current path/action path/?key=value 형태로 다이렉션 시키는 것이고

만약 methodPOST 라면 formData 객체 형태로 만들어 /current path/action path 로 이동 시킨 후 생성된 formData 를 리다이렉션된 path 에서 정의된 action method 를 사용하는 것이다.

하지만 useSubmit 을 이용하게 되면 Form 컴포넌트를 생성하지 않고도 {method , action} 을 정의하여 마치 Form 컴포넌트를 제출한 것과 같은 효과를 낼 수 있다.

이 때 useSubmit(Form 컴포넌트 or form 태그) 를 이용할 경우에는 Form 컴포넌트 자체를 전달하는 것이 아닌 , Form 컴포넌트가 생성해낸 객체를 전달하게 되게 설계되었다.


Synchronizing URLs Form State

현재 구현되어 있는 검색 기능에는 몇 가지 문제가 있다.

첫 번째는 검색 이후 페이지를 새로고침 하면 검색 값이 사라진다는 점이다.

두 번째는 검색 이후 뒤로 가기 버튼을 누르면 input 창에는 여전히 값이 남아있다는 점이다.

이런 문제의 발생 원인을 먼저 생각해보자

첫 번째 문제 생각해보기 (새로고침을 하면 input 태그 내 값이 초기화 되는 문제)

내가 을 검색한 상태에서 페이지를 새로고침 한다고 해보자

현재의 URL 경로는 /?q = '김' 이다.

/?q = '김' 형태로 새로고침되면 / 에서 정의된 loader 메소드가 실행됨에 따라

export async function loader({ request }) {
  const url = new URL(request.url);
  // 현재 url 경로의 ?q = .. 를 가져옴
  const param = url.searchParams.get('q'); 
  const contacts = await getContacts(param);

  return { contacts }; // 반환된 `contacts` 들을 sidebar 에 렌더링 함 
}

q 값에 맞는 contacts 들을 가져오고 가져온 값을 이용해 / 경로에서 정의된 element 를 렌더링 한다 .

loader 메소드가 실행되면, 해당 loader 메소드가 존재하는 레이어의 엘리먼트도 항상 re-rendering 된다.

loader 메소드가 가져오는 contacts 객체는 매번 다른 메모리 주소를 가지고 있기 때문이다.

물론 useMemo 와 같은 다른 훅을 이용한다면 다르겠지만 말이다.

이로 인해서 페이지를 새로고침 해도 contacts 를 렌더링 하는 화면은 동기화가 잘 되어 있다.

src/routes/root.jsx

          <Form id='search-form' role='search'>
            <input
              id='q'
              aria-label='Search contacts'
              placeholder='Search'
              type='search'
              name='q'
              // defaultValue = '' (default 설정)
              onChange={(event) => {
                submit(event.target.form);
              }}
            />

            <div className='sr-only' aria-live='polite'></div>
          </Form>

하지만 input 태그 부분은 말이 다르다 .

재렌더링 될 때 컴포넌트에서 정의된 input 태그의 defaultValue'' 이기 때문에 새롭게 렌더링 될 때 마다

이전에 입력해놨던 값을 기억하지 못하고 '' 로 초기화 되어버리는 것이다.

이는 컴포넌트 내부 input 태그의 값이 /?q=.. 의 값과 동기화 되도록 변경해주자

변경되는 URL 경로와 input.value 값을 동기화 시켜주도록 하자

src/routes/root.jsx

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

  // 현재 url 경로의 파라미터를 useLoaderData 에게 내려줌
  return { contacts, param }; 
}

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

  return (
    <>
      <div id='sidebar'>
        <h1>React Router Contacts</h1>
        <div>
          <Form id='search-form' role='search'>
            <input
              ...
              // 경로가 변경됨에 따라 기본값이 params 와 동기화 시키도록 함 
              defaultValue={param} 
              onChange={(event) => {
                submit(event.target.form);
              }}
            />
			...
          </Form>
          ...

두 번째 문제 생각해보기 (뒤로 가기 버튼을 눌러 URL 경로가 변경되어도 input 값이 변경되지 않는 문제)

우선 현재 input 값의 변화가 URL 경로의 변화를 어떻게 가져오는지를 생각해봐야 한다.

src/routes/root.jsx

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

  return (
    <>
      <div id='sidebar'>
        <h1>React Router Contacts</h1>
        <div>
          <Form id='search-form' role='search'>
            <input
              ...
              // 경로가 변경됨에 따라 기본값이 params 와 동기화 시키도록 함 
              defaultValue={param} 
              onChange={(event) => {
                submit(event.target.form);
              }}
            />
			...
          </Form>
          ...

input 태그에 값이 변경될 때 마다 {q : input.value} , {method : 'get'} 형태가 제출되어 입력값에 따라 페이지 경로가 변경된다.

이는 input 내부의 값 변화에는 URL 이 잘 동기화 되어 있음을 의미한다.

하지만 현재 코드만으로는 URL 경로의 변화에는 input 내부의 값이 동기화 될 수 없다.

그 부분은 컴포넌트의 생명주기와 관련있다.

예를 들어 내가 지금 이라고 검색한 순간 렌더링 된 Root 컴포넌트를 A 라고 해보자

여기서 지칭하는 A , B , C .. 들은 생명주기가 끝나고 새롭게 렌더링 된 경우 변경된다고 해보자

A 에서의 input 태그의 어트리뷰트 중 defaultValue = {김} 이 여전히 맞다.

ㄱ -> 기 -> 김 으로 변경되는 동안 input 태그의 defaultValue 또한 계속 변경되어 온 것도 맞다.

하지만 기억해야 할 것은 input 태그의 value 어트리뷰트도 변경된다는 것이다.

src/routes/root.jsx

import { useRef, useEffect } from 'react';
export function Root() {
  // useLoaderData 훅을 이용해 routes 에서 정의된 loader 메소드가
  // 반환하는 값을 컴포넌트 내부에서 불러와 사용
  const { contacts, param } = useLoaderData();
  const navigation = useNavigation();
  const submit = useSubmit();

  const inputRef = useRef();

  useEffect(() => {
    // param 변화에 따라 input tag 의 값이 어떻게 변하는지 보자 
    console.log(`현재의 param : /?q=${param}`);
    console.log(`defaultValue : ${inputRef.current.defaultValue}`);
    console.log(`value : ${inputRef.current.value}`);
  }, [param]);

ㄱ -> 기 -> 김

김 -> 기 -> ㄱ -> ''

위 코드에서 defaultValue 를 아무리 변경해주더라도

현재 컴포넌트의 생명주기는 A 이기 때문에 이전 일 때 입력해뒀던 value 값을

기억하고 있다.

뒤로가기 버튼을 누르면 URL 경로만 변경되는 것이지, 이전 페이지의 액션까지 돌리는 것이 아니기 때문이다.

A 시점의 컴포넌트의 value 값은 일어났던 이벤트에 대한 것을 그대로 기억한다.

생명주기와 관련돼서 더 명확하게 이해하는 방법은 새로고침을 해보는 것이다.

이전과 똑같은 형상이 까지 갔을 때 유지되다가

새로고침을 하니 동기화가 되었다.

그 이유는 새로고침 하는 순간 A 시점의 컴포넌트의 생명주기는 끝나고

새롭게 렌더링 되는 B 시점의 컴포넌트가 생성되기 때문이다.

B 컴포넌트는 새롭게 렌더링 될 때 param 의 값을 가져와 default Value 로 설정하고

value = '' 가 설정되기 때문이다. (새롭게 생성되었기 때문에 이전에 입력해둔 value 값이 없다 !! )

그러니 해결하기 위해서는

렌더링 이후 input.value 값을 param 값으로 변경해주면 된다.

  useEffect(() => {
    inputRef.current.value = param;
  }, [param]);

useEffect 를 이용하여 변경되는 param 값에 맞춰 input.value 값을 동기화 시켜주었다.


Adding Search Spinner

입력 값에 검색을 통해 서버와 통신하여 관련된 contacts 리스트를 가져오도록 하였다.

이 때 만약 서버와의 통신이 원활치 않는 경우 렌더링이 멈춰있기 때문에 UX 상 좋지 않다.

react-router-dom 에서 통신 상태를 나타내주는 useNavigation 훅을 이용해보자

export function Root() {
  const { contacts, param } = useLoaderData();
  const navigation = useNavigation();
  const submit = useSubmit();
  const inputRef = useRef();

  /*
  navigation 객체의 location 은 state 가  loading 일 때 
  요청을 보낸 API 의 endpoint 를 가리킨다. 
  isSearching 은 location 의 state 가 loading 이면서 , api 요청이 
  /?q=.. 를 이용한 것인지 를 묻는 것이다. 
   */
  const isSearching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has('q');
  ...
  
  return (
    ...
              <Form id='search-form' role='search'>
            <input
              id='q'
              aria-label='Search contacts'
              placeholder='Search'
              type='search'
              name='q'
              defaultValue={param}
              ref={inputRef}
              onChange={(event) => {
                submit(event.target.form);
              }}
              className={navigation.state === 'loading' ? 'loading' : ''}
            />
            <div id='search-spinner' aria-hidden hidden={!isSearching}></div>
            <div className='sr-only' aria-live='polite'></div>
          </Form>
    ...

search-spinner 라는 엘리먼트를 추가해줘 검색하고 있지 않을 땐 hidden 으로 가려버리고

검색 중일 때에만 나타나게 했다.

또한 input 태그의 클래스 이름을 변경해줌으로서 CSS 파일에서 loading 일 때에는 돋보기 이미지가 보이지 않도록 하였다.


Managing the History Stack

input 값이 변경됨에 따라 Form 컴포넌트가 GET 요청으로 제출되고, 제출 될 때 마다 URL 경로가 변경되었다.

경로가 변경된다는 것은 window.history stack 에 새로운 url 들이 추가 된다는 것을 의미한다.

history 의 메소드들을 먼저 보자

window.history 의 메소드

window.history stack 에 값을 변경하는 메소드

  • window.history.pushState : 새로운 url 경로를 stack 에 추가
  • window.history.replaceState : 현재의 url 경로를 새로운 url 경로로 변경
    replaceState 를 이용하면 history stack 더 쌓이지 않는다.

window.history stack 의 값을 조회하는 메소드

뒤로가기 버튼 , 앞으로 가기 버튼은 history stackurl 경로를 가리키는 포인터의 위치를 변경하는 것이다.

  • window.history.go(num) : num 만큼 포인터 이동
  • window.history.back : 포인터 1감소
  • window.history.forward : 포인터 1증가

Formreplace props

<Form replace /> propsForm 메소드가 제출 될 때

history stack 에 값을 window.history.pushState 가 아닌 window.history.replaceState 를 이용하여

변경하도록 한다.


출처 : https://reactrouter.com/en/main/components/form#replace

뜬금없이 왜 Form 컴포넌트를 얘기를 하느냐

useSubmit 자체가 Form 컴포넌트를 제출 시키는 것과 같기 때문이다.

src/routes/root.jsx

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

  return (
    <>
      <div id='sidebar'>
        <h1>React Router Contacts</h1>
        <div>
          <Form id='search-form' role='search'>
            <input
              ...
              defaultValue={param} 
              onChange={(event) => {
                submit(event.target.form ,
                        /* {method : 'get' ,
                        action : window.location.pathName,
                        replace : false} <- 기본 값 /*);  
              }}
            />
			...
          </Form>
          ...

useSubmitsubmit 메소드의 두 번쨰 인수의 기본 replacefalse 값으로

조건에 따라 replace : true 로 설정해주자

paramnull 이 아닐 때 replace : true 로 해주면 될 것이다.

/?q= 일 때를 제외하면 모두 paramnull 이 아니다.

		<Form id='search-form' role='search'>
            <input
              id='q'
              ...
              onChange={(event) => {
                const isFirstSearching = param == null; // 검색문일때엔 replace
                submit(event.target.form, { replace: !isFirstSearching });
              }}
              className={navigation.state === 'loading' ? 'loading' : ''}
            />

검색 도중 (url이 /?q=.. 가 없을 때) 의 히스토리 스택이 쌓이지 않는 모습을 볼 수 있다.


Mutations Without Navigation

src/routes/contact.jsx

function Favorite({ contact }) {
  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>
  );
}

src/index.js

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      { index: true, element: <Index /> },
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        element: <Contact />,
      },

위 예시에서 /contact/:contactId 경로에서 해당 별 버튼을 누르면 서버 측으로

해당 contact 객체의 favorite 값을 변경하는 요청을 보낸다고 해보자

기존의 Form 컴포넌트를 이용하여 변경해보자

src/routse/contact.jsx

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

src/index.js

import {
  ..
  action as contactAction,
} from './routes/contacts';
/* root route 설정 */
const router = createBrowserRouter([
  {
    path: '/',
    ...
    children: [
      { index: true, element: <Index /> },
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        action: contactAction, // 새롭ㅂ게 추가 
        element: <Contact />,
      },

이렇게 하게 되면 /contact/:contactId 경로에서 Form 컴포넌트가 제출되면 다음과 같이 실행된다 .

  1. Form action = '/contact/:contactId' (default action value) 제출 이벤트 발생
  2. /contact/:contactId 에 존재하는 action 메소드 실행
  3. action 메소드를 실행하고 반환하는 경로로 페이지 이동 (경로를 반환하지 않을 경우 현재 경로)
  4. 이동한 페이지의 loader 메소드 실행
  5. loader 메소드로 정보를 불러오고 나면 페이지 렌더링

Form 컴포넌트의 action 메소드가 실행된 이후에는 필연적으로 페이지 이동 (navigating)이 일어난다.

다만 이는 우리가 <Form method = 'post'> 일때는 replace = {true} 가 설정되어 있기 때문에 히스토리 스택에 남지 않아 페이지 이동이 일어나지 않는 것 처럼 느껴지는거다.

function Favorite({ contact }) {
  let favorite = contact.favorite;
  const fetcher = useFetcher();
  return (
    <Form method='post' replace={false}> // replace 를 false 로 설정하고 해보겠음
      <button
        name='favorite'
        value={favorite ? 'false' : 'true'}
        aria-label={favorite ? 'Remove from favorites' : 'Add to favorites'}
      >
        {favorite ? '★' : '☆'}
      </button>
    </Form>
  );
}

또한 화면이 잠깐 opacity 속성이 낮아지는 것 또한 페이지 이동 과정 에서 Navigation 객체를 이용해 스타일링을 해줬기 때문이다.

useFetcher

useFetcherfetching 과 관련된 다양한 프로퍼티, 메소드들을 가지고 있는 객체를 반환한다.

이 중 useFetcher 로 불러온 Fetcher 객체의 Form 컴포넌트를 이용하면

페이지 이동 없이 로직을 사용 할 수 있다.

src/routes/contact.jsx

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

...

function Favorite({ contact }) {
  let favorite = contact.favorite;
  const fetcher = useFetcher();
  return (
    // useFetcher 사용 , replace 를 사용하지 않아도 스택에 남는지 보자 
    <fetcher.Form method='post' replace={false}> 
      <button
        name='favorite'
        value={favorite ? 'false' : 'true'}
        aria-label={favorite ? 'Remove from favorites' : 'Add to favorites'}
      >
        {favorite ? '★' : '☆'}
      </button>
    </fetcher.Form>
  );
}

useFetcher 를 이용하면 페이지 이동 과정이 없기 때문에 Navigation 객체를 이용한 페이지 이동 간 스타일링에서 자유로울 수 있다.

페이지 이동 과정 없이도 스타일링을 하고 싶다면 Fetcher.state 등을 이용해서 할 수 있다.


Optimistic UI

다만 위 과정에서 별을 클릭했을 때 네트워크 요청이 모두 끝난 후에 별이 변경되기 때문에

답답한 느낌이 든다.

이러한 과정을 해결해보자

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

  if (fetcher.formData) {
    // fecther.formData 가 존재 할 경우 (요청중일 때)
    // 렌더링 할 때 바뀔 데이터로 미리 렌더링 하도록 설정
    favorite = fetcher.formData.get('favorite') === 'true';
  }

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

서버에게 요청을 보냈을 경우 변경 할 예정인 데이터를 이용해 먼저 렌더링 시켜 버리면

응답 유무와 상관없이 빠르게 렌더링 하는 것이 가능하다.


Not Found Data

만약 /contact/:contactId 로 접근 할 때 존재하지 않는 페이지로 접근하려 할 때

띄울 에러 페이지를 구현해보자

현재 존재하지 않는 페이지로 접근하려 하면 런타임 에러가 발생한다.

export async function getContact(id) {
  await fakeNetwork(`contact:${id}`);
  let contacts = await localforage.getItem('contacts');
  let contact = contacts.find((contact) => contact.id === id);

  return contact ?? null;
}
  
export async function loader({ params }) {
  const contactId = params.contactId;
  const contact = await getContact(contactId);
  return { contact };
}

그 이유는 존재하지 않는 아이디이기 때문에 객체를 가져오지 못하고 그로 인해서 렌더링 과정에서

에러가 발생하는 것이다.

런타임 에러가 발생하지 않도록 에러 핸들링을 해주자


export async function loader({ params }) {
  const contactId = params.contactId;
  const contact = await getContact(contactId);
  if (!contact) {
    // 만약 contact 를 찾을 수 없으면 new Response 객체를 띄운다.
    throw new Response('', {
      status: 404,
      statusText: 'Not Found',
    });
  }

  return { contact };
}
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />, // 해당 컴포넌트가 렌더링 됨
    loader: rootloader,
    action: rootaction,
    children: [
      { index: true, element: <Index /> },
      {
        path: 'contacts/:contactId',
        loader: contactLoader,
        action: contactAction,
        element: <Contact />,
      },


Pathless Routes

Pathless Routespath 가 정의되지 않은 routes 를 의미한다.

const router = createBrowserRouter([
 	...
      {
        path: 'contacts/:contactId', // path 를 정의하고 
        loader: contactLoader,
        action: contactAction,
        element: <Contact />, // 해당 path 에서 렌더링 될 컴포넌틑를 지정
      },

우리가 routes 들을 정의할 때 각 컴포넌트들은 본인이 렌더링 될 path 를 가졌다.

이는 각 컴포넌트가 렌더링 될 조건이 path 값에 따라 결정이 되는 것이였는데

이와 다르게 Pathless routes 들은 patht 값에 따라 렌더링 되는 것이 아닌

특정 여러가지 상태에 따라 렌더링 되도록 한다.

Pathless routes 의 대표적인 예시는 모달창이나 에러 페이지 등이 있다.

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,  // 2. path 와 상관없이 errorElement 가 뜸 
    loader: rootloader,
    action: rootaction,
    children: [
      { index: true, element: <Index /> },
      {
        path: 'contacts/:contactId', // 1. 해당 path 에서 error가 발생하면 
        loader: contactLoader,
        action: contactAction,
        element: <Contact />,
      },

위에서 들었던 예시로 contacts/wrongId 경로에서 error 가 발생하면 path 값과 상관없이

가장 가까운 errorElement 를 찾아 렌더링 하는 모습을 볼 수 있었다.

path 는 여전히 이전에 접근했던 경로임을 볼 수 있다.

이 때 Pathless routes 의 위치를 어떻게 구성해주느냐에 따라 해당 route 가 어떤 형식으로 렌더링 될지를 결정 할 수 있다.

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />, // 3. 해당 컴포넌트의 <Outlet> 영역에서 렌더링이 됨 
    errorElement: <ErrorPage />,
    loader: rootloader,
    action: rootaction,
    children: [
      { index: true, element: <Index /> },
      {
        path: 'contacts/:contactId',
        loader: contactLoader, // 2. 에러가 발생하게 되면 
        action: contactAction,
        element: <Contact />,
        errorElement: <ErrorPage />, // 1. 렌더링 될 errorElement 를 children 에서 지정
      },

이렇게 말이다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글