본 게시글의 내용은 모두 공식문서인 React Router 를 토대로 하고 있습니다.
Overview
Client Side Rendering
전통적인 브라우저는 클라이언트의 요청에 따라 이미 만들어둔 도큐먼트를 웹 서버에 요청 , 받는
서버 사이드 렌더링이 일어났다.
서버단에서 렌더링 해둔 도큐먼트를 클라이언트에게 전달해주는 렌더링 기법
하지만 이는 매 요청마다 새로운 도큐먼트를 받아 파싱해야 하기 때문에 성능 저하 뿐이 아니라 사용자 입장에서도
화면이 깜박거리는 등 낮은 UX
를 선사했다.
네이버 지도에서 좌표를 조금만 이동해도 새로운 페이지를 받는다 생각해보자 생각만해도 열받는다
하지만 서버 단에서 렌더링 된 도큐먼트를 받는 것이 아니라
서버에게는 새롭게 렌더링에 필요한 자료를 요청하고 , 요청 받은 자료를 가지고
클라이언트 단에서 동적으로 렌더링 하는 클라이언트 사이드 렌더링 기법이 생겨났다.
Client Side Routing
클라이언트 사이드 렌더링 기법은 하나의 페이지에서 인터렉션에 따라 렌더링 하는 화면을 다르게 하는 것일뿐
렌더링 되는 화면과 페이지의 주소가 일치하지 않는다는 문제가 있었다.
이런 문제를 해결 할 수 있도록 브라우저 객체인 Window
의 history
객체를 이용하여
렌더링 되는 화면이 변경됨에 따라 브라우저의 주소도 같이 변경해주도록 하였다.
이 부분과 관련되 내용은 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>,
);
기존 리액트 파일에서 추가된 점은 router
를 createBrowserRouter
를 이용해 생성해주고
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 file
인 index.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
useRouteError
는react-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 /> },
],
},
]);
PathConstants
인 router
자료구조에서 <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
에서 라우팅 하는 방식과 차이가 없다.
Change the <a href> to <Link to>
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>
컴포넌트와 그의 props
인 to
는 다음과 같은 의미를 갖는다.
<Link>
컴포넌트는 해당 태그를 클릭하면 to
로 지정된 페이지로 라우팅 하도록 한다.to
에 적힌 path
를 추가한다.path
에 맞춰 적절한 컴포넌트를 렌더링한다.<a href=..>
와 다르게 <Link to=..>
는 페이지 요청 없이 URL
주소만 변경하기 때문에 훨씬 빠르며 SPA
스럽다.
출처 : How to Build a Routing Layer in React and Why You Need It
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
버튼은 HTML
의 form method = 'post'
로 생성된 태그이다 .
기존 HTML
의 Form
태그의 작동 방식을 생각해보자
<form method = 'get/ post' action = '요청을 보낼 api 주소'>
기존 HTML
의 form
태그는 action
측에 적힌 api
주소측을 향해 form
내부에 존재하는 태그에 작성된 값들을
서버 측으로 보낸다.
이 때 method
가 get
일 경우엔 서버 측에 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
메소드가 실행된다.
서버에게 비동기적으로 정보를 넘긴다. 이 때 기존 form
태그와 다르게 Form
컴포넌트는 페이지 reload
를 하지 않는다.
action
메소드가 종료되고 나면 기본적으로 loader
메소드를 재실행시켜 컴포넌트가 렌더링 하는데 필요한 데이터를 업데이트 시킨다.
그러니 만약
loader
메소드가 서버와 통신을 하는 경우라면action
메소드가 실행되면 서버와 통신이 매 번 일어난다.다만 기존
form
태그와 다른 점은form
태그는 항상 모든 페이지를 받아와reload
하였다면Form
컴포넌트는 컴포넌트 렌더링에 필요한 데이터만 받아와 렌더링 하기 때문에 코스트가 적게 든다.
Root
컴포넌트가 새롭게 렌더링 될 때는 추가된 정보가 담긴 데이터를 이용해 렌더링 한다.
react router dom
아 고마워 ~!!
react-router-dom
의 Form
컴포넌트는 다음과 같은 과정이 추상화 된 컴포넌트이다.
기본적으로 html
의 form
태그를 기반으로 하여 만들어졌다.
다만 submit
버튼이 눌리면 서버와 비동기적으로 통신한 후 새로운 도큐먼트로 리다이렉트 시키는 default event
를 preventDefault
를 이용해 방지한다.
이를 통해 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
에서의 라우팅 방식은 다음과 같은 흐름으로 진행된다.
클라이언트가 특정 페이지로 이동 할 수 있도록 하는 네비게이터들이 존재한다. (기존 MPA
에서의 a
태그와 같은)
이 때 이 네비게이터들은 react-router-dom
에서 제공하는 <Link to .. >
컴포넌트를 이용한다.
2.1 Link
컴포넌트는 해당 페이지의 URL
의 경로를 변경시키기만 한다.
react-router-dom
의 createBrowserRoute
메소드는 변경되는 URL
경로와 페이지를 구성하는 컴포넌트들을 동기화 할 수 있도록 다양한 프로퍼티와 메소드를 이용한다.
3.1 createBrowserRoute
은 Routing 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-dom
은 SPA
에서 변경되는 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
태그로 만들어져있으며 action
은 edit
으로 적혀있다.
Form
태그는 form
태그를 커스터마이징한 컴포넌트로 action
어트리뷰트에 적힌 상대경로로
리다이렉션시킨다.
그러니 Edit
버튼을 누르면 URL
경로가 contacts/:contactId/edit
으로 변경된다는 것이다.
그러면 contacts/:contactId/edit
경로에서 렌더링 될 컴포넌트를 만들어주자
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
컴포넌트이다.
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
버튼을 눌렀을 때 원하는 동작은 다음과 같을 것이다.
Form
에 적어둔 정보가 서버에 저장되기를 기대 할 것이다.그러면 Save , Cancle
버튼이 눌렸을 때 일어날 행위를 action
메소드 내에 정의해주도록 하자
Post/Redirection/Get
어떤 폼을 제출한 후 새로운 페이지로 리다이렉션 시키고 새로운 페이지를 렌더링 하는 패턴을 다음처럼 부른다고 한다.
이러한 과정을 통해 동일한 폼이 중복적으로 제출되는 것을 방지하고 사용자 경험에 좋은 기여를 할 수 있다.
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-dom
은 action
메소드가 반환하는 값을 보고 자동으로 로직을 실행한다....
/* 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 />,
},
],
},
]);
...
router
의 action method
들의 반환값은 서로 다른 타입의 객체들이다.
그런데 위의 rootaction
의 반환값은 Root
컴포넌트에서 이용하는 데이터의 상태를 업데이트 한다고 하고
editAction
의 반환값을 이용해서는 리다이렉션 시킨다고 한다.
react-router-dom
은 action
메소드가 반환하는 객체의 타입에 따라 동적으로 로직을 결정한다.
Object
타입 반환객체 타입으로 반환된 경우 react-router-dom
은 상태 변경을 일으키거나 다음 컴포넌트 렌더링 시 해당 데이터를 전달해줄 수 있다.
우리의 예시에서는 추가 된 contact
객체를 반환받아, Root
컴포넌트를 렌더링 할 때 사용되는 contacts
배열을 자동으로 업데이트 해주었다.
redirection
반환 redirection
이 반환된 경우에는 인수로 전달한 경로로 라우팅 시킨다.
redirection
함수가 어떤 값을 반환하나 봤더니 Post
요청 후 서버의 response
가 담긴 객체를 반환한다.
공식문서의 숏컷을 보면 좀 더 명확하게 이해가 된다.
react-router-dom
은 서버의 요청을 받은 후 인수에 적힌 상대 경로로 라우팅 하는 것으로 생각된다.
상태 코드나 상태 텍스트에 따라서 어떤 처리를 하는지까지는 아직은 모르겠다. 튜토리얼을 더 진행해보고
loader , action
에 적힌 내용들을 좀 더 봐야겠다.
아무것도 반환하지 않을 경우엔 특별한 라우팅 없이 현재 페이지를 유지하도록 한다.
Redirecting new records to the edit page
현재는 New
버튼을 누르면 contact
가 서버에 추가되고
사이드바에서 추가된 No name contact
를 눌러 edit
버튼을 클릭해야 했다.
차라리 자동적으로 new
버튼이 눌린 이후 추가된 contact
의 edit
페이지로 리다이렉팅 시켜보자
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
를 활용해보자NavLink
컴포넌트는 react-router-dom
에서 제공하는 컴포넌트이다.
이는 Link
컴포넌트에서 몇 가지 기능이 추가된 컴포넌트이다.
기본적인 생김새는 다음과 같다.
import { NavLink } from "react-router-dom";
<NavLink
to="/messages"
className={({ isActive, isPending }) =>
isPending ? "pending" : isActive ? "active" : ""
}
>
Messages
</NavLink>;
하나씩 살펴보자
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
NavLink
의 className 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
가 되고isActive
가true
가 된다.
isTransitioning
현재 다른 경로 간에 전환중인지 여부를 나타낸다.
사용자가 다른 경로로 이동하기 위해 경로 전환을 시작하면 true
가 된다.
위 예시에서는
isTransitioning
이 되지 않는 이유는 각to
어트리뷰트의 값이content/:contentId
로content
라는 동일한 경로에서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
들이 더 있으니 그 부분은 공식 문서 를 통해 확인해보자
Link -> NavLink
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;
}
다시 본론으로 돌아와 Link
를 NavLink
로 바꿔준다.
또한 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
객체의 state
는 loader
메소드가 실행 되기 전 , 실행 후 로 변경된다.
Navigation.state
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 Route
는 path 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-dom
은 window.history API
를 이용하여 구현되었다.
useNavigate
는 window.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 = ..
라는 파라미터를 갖는 새로운 경로로 라우팅이 되는 모습을 볼 수 있다.
이것은 다음과 같은 이유로 인해 발생한다.
<form><input .. name = .. ></form>
과 같이 form
태그 하나에 input
태그가 단 1개있을 때에는 엔터만 입력해도 form
태그가 submit
된다.
이 때 form
태그가 submit
될 때 method
를 따로 지정해주지 않으면 method = 'GET'
의 형태로 submit
된다.
method = GET
형태로 제출된다는 것은 , 서버 측에게 <input name = .. >
으로 지정한 값이, URL parameter
형태로 추가된 URL
에 대한 페이지를 요청하는 것과 같다.
그러니 위 예시에서는 /?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-dom
의 Form
컴포넌트의 제출 방식은 기존과 다르다.
특히 위 양식에서는 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
useSubmit
은 react-router-dom
에서 제공하는 훅으로
submitting
을 SPA
에서 할 수 있도록 구현해둔 훅이다.
우선 사용 예시를 먼저 살펴보자
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 , action
을 overriding
하여 사용한다.
하지만 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
에서 설정한 method
가 overriding
되어 제출된다.
해당 경로에서
Form submit
이 일어나면action
메소드가 실행되는데action
메소드는 새로운 값을 추가하는 메소드로 정의해두었다.그래서 새로운
contact
들이 추가되는 것이다.
위 예시를 차라리 submit(Form 컴포넌트 ,{method : 'post'})
가 실행된다면
<Form ... method : 'post'>
가 제출되는 것으로 생각해도 된다.
submit
은 Form
컴포넌트만 제출 가능한 것이 아니라 다양한 것들을 제출하는 것이 가능하다.
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
컴포넌트 내부에서 작성된 값을 이용해 어떤 객체를 만들고 해당 객체를 전달하는 역할을 한다.만약
method
가GET
이라면Form
컴포넌트 내부에 작성된input
태그들을 이용해URL params
를 만들고current path/action path/?key=value
형태로 다이렉션 시키는 것이고만약
method
가POST
라면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
창에는 여전히 값이 남아있다는 점이다.
이런 문제의 발생 원인을 먼저 생각해보자
내가 김
을 검색한 상태에서 페이지를 새로고침 한다고 해보자
현재의 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>
...
우선 현재 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 stack
의url
경로를 가리키는 포인터의 위치를 변경하는 것이다.
window.history.go(num)
:num
만큼 포인터 이동window.history.back
: 포인터 1감소window.history.forward
: 포인터 1증가
Form
의 replace props
<Form replace />
props
는 Form
메소드가 제출 될 때
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>
...
useSubmit
의 submit
메소드의 두 번쨰 인수의 기본 replace
는 false
값으로
조건에 따라 replace : true
로 설정해주자
param
이 null
이 아닐 때 replace : true
로 해주면 될 것이다.
/?q=
일 때를 제외하면 모두param
이null
이 아니다.
<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
컴포넌트가 제출되면 다음과 같이 실행된다 .
Form action = '/contact/:contactId' (default action value)
제출 이벤트 발생/contact/:contactId
에 존재하는 action
메소드 실행action
메소드를 실행하고 반환하는 경로로 페이지 이동 (경로를 반환하지 않을 경우 현재 경로)loader
메소드 실행 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
useFetcher
는 fetching
과 관련된 다양한 프로퍼티, 메소드들을 가지고 있는 객체를 반환한다.
이 중 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 Routes
는 path
가 정의되지 않은 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 에서 지정
},
이렇게 말이다.