avatar
Ganymedian
#5. React-Router-Dom
Jul 24
·
48 min read

  1. Workthrough for prev. project

  2. Routing in React

  3. React-Router-Dom Basic

  4. Nested Route

  5. Loader

  6. Dynamic Route

  7. Error Handling and Fallback

  8. Action handling

  9. RRD 와 NextJS 의 App Routing

  10. fifth project : RRD Project

Workthrough for prev. project

지난번 숙제는 코드가 선공개 되었었죠.

답안에서 사용된 기술이 조금 앞서 나가있긴 했지만, 앱의 로직과 구현 방향에서 길잡이 역할을 하기에는 충분했다고 생각합니다.

이번 Workthrough 는, 지난 장의 선공개한 코드로 가름하겠습니다.

.

.

.

#5. React-Router-Dom

이번 장에서는 React-Router 를 배우게 됩니다.

JS 진영의 프레임웍 웹 어플리케이션 에는 몇가지 특성화 된 영역 들이 존재하죠.

Static 웹 에서는 Astro 가 강세죠. 그리고, Static 과 웹 앱 이 믹스된 프로젝트에서는 NextJS 가 가장 먼저 떠오릅니다. (Astro 가 최근 공격적으로 영역 확장에 나서고 있기도 하죠.)

React 는 SPA(Single Page App) 에 특성화 되어 있습니다.

.

.

.

Routing in React

SPA 인데 라우팅이 필요하다는 말이 조금 낯설게 들릴 수도 있습니다.

하지만 예를 들어서, User John 의 My Articles 페이지를 열람하는 요청이 발생한다면, React 에서는 어떻게 처리하게 될까요?

비록 물리적으로는 싱글 페이지이고, 페이지 이동은 발생하지 않지만,

http://localhost:5173/user/John/myArticles

이렇게 REST API 로 요청을 식별하는 방법에 많은 사람들이 익숙할 것입니다.

React 프로젝트에서도 물리적 페이지 이동이 가능은 합니다만, state 태스크 처리방식인 React 의 특성상, SPA 로 처리되면서 물리적 페이지 이동은 하지 않는 것이 일반적입니다.

SPA 이면서, URL 은 가상으로 변경해 줌으로써 요청을 식별하고 처리하는 방법.. 을 React 에서도 라우팅이라 부르고 있습니다.

그리고 React 에서 Routing 은, 또 하나의 잘 알려진 라이브러리인, React-Router 를 사용해서 구현하는 게 일반적입니다. 바닐라 React 로도 라우터 구성이 가능하긴 합니다만, 아무도 그렇게 사용하진 않죠. 우리의 프로젝트 에서도 React-Router 를 사용하겠습니다.

React-Router 는 React Native 에서도 같이 사용되고 있는데, Web App 의 Dom 관리 api 가 추가된 React-Router 가 React-Router-Dom 입니다.

이제부터 React-Router-Dom - RRD 의 사용 방법을 배워보겠습니다.

.

.

.

React-Router-Dom Basic

React-Router-Dom 으로 라우팅을 구현하는 방법은 크게 두가지로 나뉩니다.

하나는 BrowserRouter 를 사용하는 컴포넌트 중첩 방식이고, 다른 하나는 createBrowserRouter 를 사용하는 방법입니다.

처음 라우터 코드를 접해보면, BrowserRouter 의 코드가 깔끔하고 직관적입니다. 배우기도 쉽고 구현하기에도 빠릅니다. 많은 사람들이 이 방식을 사용하고 있고, 구글링해서 만나보게 되는 코드들도 거의 이 방식으로 작성된 코드들이죠.

반면에 createBrowserRouter 는 사용하고 있는 사람들이 상대적으로 적습니다.

하지만 우리는 오늘 createBrowserRouter 를 사용해서 라우팅을 구성하고, createBrowserRouter 방식이 어떻게 더 좋은 결과를 가져오게 되는 지를 확인하게 될 것입니다.

BrowserRouter

// BrowserRouter 의 example
function Router() {
 return (
   <BrowserRouter>
     <Routes>
       <Route path="/" element={<Login />} />
       <Route path="/main" element={<Main />} />
     </Routes>
   </BrowserRouter>);
}

createBrowserRouter

// createBrowserRouter 의 example
export const router = createBrowserRouter([
  {
    element: <NavLayout />,
    // errorElement: <h1>404 Not Found</h1>,
    children: [
      { path: "*", element: <Navigate to="/" /> },
      { path: "/", element: <Home /> },
      { path: "/store", element: <Store /> },
      { path: "/about", element: <About /> },
    ],
  }
])

두 가지 방식이 비슷한 듯 어딘가 다릅니다.

.

.

react-router-dom 설치

먼저 프로젝트에 install 부터 합니다.

# npm i react-router-dom

이제 프로젝트에 폴더구조를 만들어 줍시다.

📦src
 ┣ 📂components
 ┣ 📂layout
 ┃ ┣ 📜Footer.tsx
 ┃ ┗ 📜NavBar.tsx
 ┣ 📂lib
 ┃ ┗ 📜utils.ts
 ┣ 📂pages
 ┃ ┣ 📜About.tsx
 ┃ ┣ 📜Home.tsx
 ┃ ┗ 📜Store.tsx
 ┣ 📜App.css
 ┣ 📜App.tsx
 ┣ 📜globals.css
 ┣ 📜index.css
 ┣ 📜main.tsx
 ┣ 📜router.tsx
 ┗ 📜vite-env.d.ts

루트 폴더에 router.jsx 파일을 아래처럼 생성해줍니다.

// router.jsx
import { createBrowserRouter, RouteObject } from "react-router-dom";
import Contact from "./pages/Contact";
import Store from "./pages/Store";
import About from "./pages/About";
import Home from "./pages/Home";

const routes = [
  { path: "/", element: <Home /> },
  { path: "/about", element: <About /> },
  { path: "/store", element: <Store /> },
  { path: "/contact", element: <Contact /> },
]

export const router = createBrowserRouter(routes);

코드는 직관적이어서 굳이 설명을 추가할 필요도 없을 것 같습니다. 요청되는 request 포인트 각각에 대해, 그에 해당하는 위치를 지정해 주었습니다.

.

.

Router 불러오기

Context 를 공부할 때, Context.Provider 내부의 스코프에서 Context 가 유효하다고 배웠던 기억이 있죠.

Router 도 비슷하게 사용됩니다.

React App 의 가장 바깥쪽 스코프이자, 게이트 스크립트 격인, 루트 폴더의 main.jsx 에 RouterProvider 를 추가해줍니다.

// main.jsx
import { RouterProvider } from "react-router-dom";
import { router } from "./router.tsx";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);
// 이제 이 프로젝트에서는 App.tsx 가 사용되지 않습니다.

이제, /about, /store, /contact 각각의 주소를 주소창에 쳐보면 해당 페이지로 이동 되는 것을 확인할 수 있습니다. 라우팅이 정상 작동 하고 있습니다.

.

.

Navbar 추가

번번이 주소를 칠 수는 없으니, 앱의 상단에 메뉴바를 추가해서 네비게이션을 완성해줍시다.

React-Router-Dom 에서 링크는 <Link to="/about"> 이렇게 작성해 줘야 합니다. <a href=''> 로 만들어주면 페이지 이동이 발생하기 때문이지요. <Link to=""> 링크는 React-Router 가 자체적으로 링크를 관리하기 때문에 페이지 이동이 발생하지 않으면서 해당 링크로 라우팅 됩니다.

// /src/layout/NavBar.jsx
import React from "react";
import { Link } from "react-router-dom";

const NavBar = () => {
  return (
    <header className="sticky top-0 flex h-12 items-center justify-between w-[100dvw] bg-gray-200 p-3 text-xl">
      <a href="/" className="text-3xl">
        🐐
      </a>
      <nav className="">
        <ul className="flex flex-row justify-center items-center gap-8 ">
          <li>
            <Link to="/store">Store</Link>
          </li>
          <li>
            <Link to="/contact">Contact</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
};

export default NavBar;

그리고 앱 전체 레이아웃을 담당하는 레이아웃 컴포넌트를 만듭니다.

여기서 Outlet 컴포넌트는, children 과 같다고 생각하시면 될 것 같습니다.

// /src/layout/RootLayout.jsx
import {
  Outlet,
} from "react-router-dom";
import Navbar from "../client/Navbar";
import Footer from "../client/Footer ";

export function RootLayout() {
  return (
    <div className="wrapper grid min-h-[100dvh] grid-rows-[auto-1fr-auto] bg-background ">
      <Navbar />
      <Outlet />
      <Footer />
    </div>
  );
}

.

.

.

Nested Route

이제 만들어진 레이아웃을, 라우터에 추가해 보겠습니다.

// /src/router.tsx
const routes = [
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { path: "/", element: <Home /> },
      { path: "/about", element: <About /> },
      { path: "/store", element: <Store /> },
      { path: "/contact", element: <Contact /> },
    ],
  },
];

export const router = createBrowserRouter(routes);

라우트 코드의 네스티드 구조를 살펴보면,

  1. / 에 대해 element: <RootLayout /> 을 할당해 주었습니다.

    1. 이제, / 페이지에서 <RootLayout /> 가 출력됩니다.

  2. /children 을 추가해 주었습니다.

  3. <RootLayout /><Outlet />children 에서 지정된 element 가 할당됩니다.

  4. 같은 방식의 Nested Route 를 children 이하의 항목들에도 지정해줄 수 있습니다.

어렵지 않죠?

헤더의 네비게이션이 잘 출력되고 있나요? 링크를 클릭해보면, 라우팅은 되지만 페이지 이동은 발생하지 않는 것을 확인할 수 있을 겁니다. 그리고 레이아웃에는 아무 일도 일어나지 않고 있죠. 이제 URL 은 바뀌지만 앱이 관리하는 state 는 계속 유지할 수 있게 된 것입니다.

.

.

useOutletContext

RRD 의 Outlet 컴포넌트는 자체적으로 Context 를 제공합니다.

// Layout 의 Outlet 에 context 를 추가해 줍니다.
export function RootLayout() {
    return (
      <div className="wrapper grid min-h-[100dvh] grid-rows-[auto-1fr-auto] bg-background ">
        <Navbar />
        <Outlet context="This is from Context" />
        <Footer />
      </div>
    );
  }

이제, Outlet 이하 모든 컴포넌트 들에서 Context 의 데이터를 공유할 수 있습니다.

import {useOutletContext} from "react-router-dom"

export default function NewPost() {
  const data = useLoaderData();
  const contextData = useOutletContext();

  return (
    <>
      <h1 className="page-title">New Post</h1>
      <PostForm
        users={users}
        submittingState={submittingState}
        contextData={contextData }
      />
    </>
  );
}

.

.

Loader

Loader & createRouter functions

BrowserRouter 의 코드에서는 endPoint 각각에 컴포넌트를 지정해 주었죠.

createBrowserRouter 에서는 endPoint 각각에, 컴포넌트 이외에 loader() 라는 펑션을 추가로 할당해줄 수 있습니다. 펑션의 이름은 loader() 로 하자는 약속이 있어요. 다른 이름 안됩니다.


createBrowserRouter([
  {
    path: "users",
    element: <UsersList />,
    loader: async () => {
		  const users = await getUsers();
		  return { users };
    },
  },
])

그리고, <UsersList /> 컴포넌트에서는,

// useLoaderData 는 RRD 의 훅으로, loader() 펑션을 실행시키고 그 리턴 밸류를 얻습니다.
const { users } = useLoaderData();

이렇게 useLoaderData() 훅을 사용해서 loader() 펑션을 실행시켜서 필요한 데이터를 확보할 수 있습니다.

이 처리과정을 BrowserRouter 로 구현하려면, 어쩔 수 없이 useEffect 와 추가 코드를 통해 구현해야 하고, 추가 렌더링이 발생하는 문제도 감수해야 할 것입니다.

loader() 는, createBrowserRouter 뿐 아니라, createMemoryRouter, createHashRouter 등에서도 사용할 수 있는데, 다른 것들은 쓰임새가 그리 잦지 않으니, 그런 것이 있다는 정도만 기억해 뒀다가 나중에 필요한 일이 생기면 그 때 찾아보고 사용하시면 될 것 같습니다.

createHashRouter 는 URL 변경이 허용되지 않는 상황에, 슬래시 대신 해시 를 사용해서 라우팅하는 솔루션입니다.

createMemoryRouter 는, URL 을 메모리로 관리하여 URL 변경이 발생하지 않습니다. Test 코드 작성시에 사용합니다.

.

.

Loader 의 코드 관리

loader 는 라우팅 포인트 URL 의 태스크를 획기적으로 돕고 줄여줄 수 있지만, 그러나 만약에 loader 펑션의 코드가 길어진다면, 라우터의 라우팅 목적 로직이 파묻히는 참사가 벌어질 수도 있겠죠.

Javascript 의 오브젝트 해체 문법을 역으로 활용하면, 오브젝트를 묶어서 export 해주는 방법도 가능합니다. 즉, 묶어서 전달한 오브젝트를 해체문법으로 풀어주면 묶어주기 이전의 코드가 된다는 것이죠.

그리고, RRD 를 사용하는 프로젝트에서는, 라우팅 타겟 컴포넌트들이 더이상 다른 컴포넌트에서 import 되지 않고, 라우팅 포인트로만 지정됩니다.

즉, UsersList 컴포넌트를 import 하는 곳은 오직 router 뿐이라는 것이고, 그렇기 때문에, UsersList 는 export 형식을 router 에 특화된 오브젝트로 바꿔서 export 할 수 있어졌습니다.

다시 말해, <UsersList /> 컴포넌트와 loader 를 하나의 변수에 묶어서 export 해줄 수 있다는 것이죠.

// /src/components/UsersList.tsx
function UsersList() {
	// loader() 실행
  const { users } = useLoaderData();
  return (
    <>
      <h1 className="page-title">Users</h1> {users.length}
      <div className="card-grid">
        {typeof users === "undefined" ? (
          <div>Users 데이터가 없습니다.</div>
        ) : (
          users.map((user) => (
            <div className="card" key={user.id}>
              <div className="card-header">{user.name}</div>
              <div className="card-body">
                <div>{user.company.name}</div>
                <div>https://{user.website}</div>
                <div>{user.email}</div>
              </div>
              <div className="card-footer">
                <Link className="btn" to={`/users/${user.id}`}>
                  View
                </Link>
              </div>
            </div>
          ))
        )}
      </div>
    </>
  );
}
// loader 가 이제 해당 컴포넌트에 위치합니다.
const loader = async () => {
  const users = await getUsers();
  return { users };
};
// 컴포넌트와 loader 를 묶어서 export
export const UsersListRoute = {
  element: <UsersList />,
  loader,
};

그리고 이를 router.tsx 에서 받는 모습입니다.

// /src/router.tsx
import { UsersListRoute } from "/src/components/UsersList.tsx";

const routes = [
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { path: "/", element: <Home /> },
      {
        path: "/users",
        children: [
          { index: true, ...UsersListRoute },
          { path: ":userId", ...UserRoute },
        ],
      },
    ],
  },
];

UsersListRoute 는 import 되었고 곧바로 해체 문법으로 라우터 코드에 넣어 줬습니다.

이 코드는 결국,

  { index: true, element: <UsersList />, loader },
  // 이런 코드일 것입니다.

loader 의 코드가 해당 목적 컴포넌트에 위치한다는 것은, 코드 간소화 이상의 의미가 있습니다.

이것은 loader() 펑션의 리턴 데이터가 사용될 컴포넌트와, 코드와 리소스를 함께 공유한다는 것이고, 코드관리와 메모리 관리에서의 잇점도 추가된다는 의미이기도 합니다.

index: true 는, 해당 path 의 디폴트 페이지라는 의미입니다. 하위 path 가 있을 때 index: true 를 추가해주면, 이 element 항목과 loader() 펑션이 <Outlet /> 에 전달됩니다.

path: ":userId" 는 다이내믹 라우팅의 표현식으로, 가변 path 에 대한 라우팅 설정입니다.

.

.

.

Dynamic Route

App 에서, Dynamic Route 가 사용되는 상황이라면 거의 예외없이 BackEnd 와 Data 를 주고받는 상황일 것입니다.

그리고, BackEnd 와의 통신이 발생한다면, loading , error , 등의 통신상태를 확보하고 사용자에게 현재 상태를 표시해주는 처리가 필요하겠죠. 누구나 언제든 느린 통신상황에 처할 수 있기 때문에, 이것은 개발자에게 꼭 필요한 애티튜드 이기도 합니다.

RRD createBrowseRoute App 에서 BackEnd 와의 통신은 loader() 의 코드가, useLoaderData() 로 트리거 되면서 시작됩니다. 그리고 그 통신상태 는, RRD 의 내부 메커니즘에 보관되고, RRD 의 또다른 훅인, useNavigation() 훅이 리턴해 줍니다.

const { state } = useNavigation()  
// state === "loading" ? do something..

그리고, loading , error , 등의 통신상태는, Layout 컴포넌트에서도 불러올 수 있습니다. 그리고, 이 사실은 매우 효율적인 통신상태 관리가 가능하다는 것을 의미합니다.

.

.

RootLayout ver.2

앞서 작성했던 RootLayout.tsx 를 loading, error state 를 포함한 컴포넌트로 업그레이드 해보겠습니다.

// /src/layout/RootLayout.jsx
import {
  Outlet, useNavigate 
} from "react-router-dom";
import RootFrame from "./RootFrame";
import Navbar from "../client/Navbar";
import Footer from "../client/Footer ";
import Loading from "./Loading ";

export function RootLayout() {
	const { state } = useNavigation();
  return (
    <RootFrame>
      <Navbar />
      { state === "loading" ? <Loading /> : <Outlet /> }
      <Footer />
    </RootFrame>
  );
}
// useNavigation 훅과, useNavigate 훅은 역할과 목적이 다른 훅입니다.
// useNavigation.state 는 세가지, idle | loading | submitting 상태를 리턴합니다.

loading 스테이트에서는 <Outlet /> 단계에서 렌더링을 바꿔줬습니다. loading 을 일일이 개별 컴포넌트 단에서 처리하기 보다는, 레이아웃 단에서 처리하는 것이 훨씬 직관적이고 시원해 보입니다. 거 로딩 참 시원하오

submitting state 는 개별 컴포넌트 단의 개별 요소에서 참조하고 구현하는 것이 일반적입니다. Form 의 제출시에 화면 전체가 submitting 상태로 전환되는 변화는 사용자 경험에서 일반적이지 않죠. 버튼이나 폼 제출 요소에서만 국한적으로 disabled 상태를 만들어 주거나 모달을 활성화 하는 것으로 충분합니다.

.

.

Loader part.2

앞선 챕터에서 loader() 를 소개할 때는 대략적인 작동방식만을 짚고 넘어왔죠.

RRD 에서 loader() 는, 주로 BackEnd 와의 통신을 하고 그 결과 데이터를 리턴하는 역할을 합니다. 그런데, 아래 코드에서는 뭔가 다른 부분이 보입니다.

// 
const loader = async ({
  request: { signal },
}) => {
  return fetch("<https://jsonplaceholder.typicode.com/users>", { signal })
};

유심히 살펴봐야 할 점이 두가지 보이죠.

첫째로, Loader 는, Json.parse 를 알아서 해준다는 점입니다.

두번째로, 자체적인 AbortController 로 signal 이라는 이름의 객체를 사용한다는 점이에요. request 객체는, RRD 의 loader 펑션이 호출되면서 자동으로 생성되는 객체입니다. 이 객체에는 abortController 역할을 담당하는 abortSignal 이 포함되어 있습니다.

그러나 우리의 프로젝트에서는 직접 작성한, axiosRequest 를 사용할 것이기 때문에 loader 의 내장 기능은 사용하지 않을 것입니다.

.

.

Dynamic Route

RRD 의 Dynamic Route 에서 파라메터가 표시되는 형식은 위에서 본 것 처럼,

path: ":userId"

가 됩니다.

// /src/router.js
// 이하 라우팅 코드는 아래 각각의 url 라우팅을 생성합니다.
// /posts
// /posts/new
// /posts/myid789
	{
    path: "/posts",
    children: [
      { index: true, ...PostsListRoute },
      { path: "new", ...NewPostRoute },
      { path: ":userId", ...PostRoute },

get 방식 URL 로 표현하면 ?userId=.... 가 되겠죠.

RRD 의 Dynamic Routing 에서, 파라메터 식별은 : 로 시작하고, 이후에 나오는 문자열이 파라메터의 이름이 됩니다. 그런데 혹시, 여기서 “new” 가 :userId 로 해석될 여지는 없느냐는 호기심도 들 수 있는데, router 의 path 에 지정된 pathName 은 파라메터 보다 path 가 우선합니다. “new” 라는 사용자 ID 가 있다 하더라도 미아 신세가 됩니다. 그건 “new” 라는 id 를 허용한 개발자의 잘못이에요.

그리고 URL 호출은

// add new post
# <http://localhost:5173/posts/new>
// userId:"any_string"
# <http://localhost:5173/posts/any_string>

으로 하고,

// Component 에서 parameter 를 획득하는 방법
// RRD 에서 제공하는 useParams() 로 파라메터를 획득할 수 있습니다.
export default function NewPost() {
  const data = useLoaderData();
  const { userId } = useParams();  // userId 에는 "any_string" 이 담깁니다.
  const location = useLocation();

이렇게 사용됩니다.

그리고 loader 에서는,


const loader = async ({ params }) => {
  const config = { method: "GET" };
  const postData: PostType = await axiosRequest({
    endPoint: "/posts/" + params.userId ,
    config,
  });

이렇게 사용됩니다.

.

.

다중 파라메터

:city 에서 관리하는 :bench 의 관리대장을 업데이트 하는 페이지가 있다고 가정해 보겠습니다.

URL 호출은

# <http://localhost:5173/city/:cityId/:benchId>
// 또는,
# <http://localhost:5173/city/:cityId/bench/:benchId/manage>

대략 이와 비슷하게 되겠죠.

그리고 router 는,

	 	
	...
	{
    path: "/city",
    children: [
      { index: true, ...CityListRoute },
      { children: [
	      { path: ":cityId", ...CityRoute },
	      { children: [
		      { path: ":benchId", ...BenchRoute },
		    ]}
	    ]},
	  ]
 },

이런 모습이 되겠죠.

그리고 컴포넌트에서는,

// Component 에서 parameter 를 획득하는 방법
// RRD 에서 제공하는 useParams() 로 파라메터를 획득할 수 있습니다.
export default function NewPost() {
  const data = useLoaderData();
  const { cityId, benchId } = useParams(); 
  const location = useLocation();

이렇게 사용하게 됩니다.

.

.

Wild Card & 404 Page

라우터에서 설정되지 않은 URL 을 요청하면 404 페이지를 띄워주고 싶을 것입니다.

‘존재하지 않는’ 페이지 보다는 ‘path 이하의 모든’ 페이지에서 404 페이지를 디폴트 값으로 하고, 서비스가 준비된 페이지들에 대해서 라우팅 포인트를 지정해주는 것이 효과적입니다.

또한, 허용되지 않은 접근에 대해서는 Redirection 을 해주는 것도 필요하겠죠.

/lib 으로 접근하는 모든 사용자를 / 로 리다이렉션 해주는 룰셋도 추가해 봅니다.

// 
export const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    children: [
		  { path: "*", element: <NotFound404 /> },
      {
        errorElement: <ErrorPage />,
        children: [
          { path: "/", element: <Home /> },
          { path: "/lib", element: <Navigate to="/" /> },
          {
            path: "/posts",
            children: [
              { index: true, ...PostsListRoute },
              { path: "new", ...NewPostRoute },
              { path: ":postId", ...PostRoute },
              { path: ":postId/edit", ...EditPostRoute },
              { path: ":postId/delete", ...DeletePostRoute },
            ],
          },
        ...

.

.

ScrollRestoration

RRD 의 디폴트 작동방식에서는, 페이지 이동시에 스크롤 위치가 항상 #Top 으로 고정되어 있습니다.

사용자가 현재 페이지의 관심있는 위치까지 스크롤 했다가 사이드 메뉴 등으로 페이지를 이동 한 후, 뒤로가기로 되돌아 왔을 때, 관심있던 위치의 스크롤 정보가 복원되는 경험을 원할 수 있습니다.

이럴 때,

// /src/layout/RootLayout.tsx
import { Outlet, ScrollRestoration, useNavigation } from "react-router-dom";

const RootLayout = () => {
  const { state } = useNavigation();
  // const state = "loading";
  return (
    <RootFrame>
      <NavBar />
      <ScrollRestoration />
      {state === "loading" ? <LoadingMain /> : <Outlet />}
      <Footer />
    </RootFrame>
  );
};

export default RootLayout;

이렇게 RootLayout 에 <ScrollRestoration /> 을 추가해주면 스크롤 위치가 기억되고 복원됩니다. 반드시 root 또는 전체 레이아웃 레벨에서 삽입해 주어야 작동합니다.

아마도 Scroll 포지션이 전역상태로 관리되고 네비게이션 이벤트에서 해당 state 를 근거로 복원시키는 원리겠죠? 도메인 이동시에도 스크롤 위치가 복원된다고 하니 신기하긴 합니다.

참고로, BrowserRouter 에서는 ScrollRestoration 을 사용할 수 없습니다.

.

.

RRD 로 간단한 Dialog 띄우기

프로젝트내 다른 URL 을 Dialog 로 띄워줘야 할 필요도 생기죠.

RRD 로 프로젝트내 다른 URL 을 Dialog 로 띄워줄 수 있습니다.

// 먼저 팝업으로 띄워줄 url 의 라우팅을, 일반 라우팅과 다를 바 없이 똑같이 설정해줍니다.
const routes: RouteObject[] = [
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { path: "/", element: <Home /> },
      {
        path: "/popup",
        element: <Popup />,
        children: [{ path: "/popup/dialog", element: <DialogPopup /> }],
      },
      { path: "/about", element: <About /> },
      { path: "/store", element: <Store /> },
    ],
  },
];

팝업(Dialog) 을 띄워줄 <Popup /> 컴포넌트에서 팝업 이벤트 처리 코드를 추가해주고,

const Popup: React.FC = () => {
  const navigate = useNavigate();

  // 팝업 이벤트에 url 이동이 발생합니다.
  const openDialog = () => {
    navigate("dialog");
  };

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-4">This is Dialog component</h1>
      <button
        onClick={openDialog}
        className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
      >
        Open DialogPopup
      </button>
      <Outlet />
    </div>
  );
};

팝업되는 DialogPopup 컴포넌트입니다. 일반적인 컴포넌트와 다를 바 없고, 단지 팝업창의 형태만 갖고 있습니다.

팝업창이 close 될 때에 history(-1) 됩니다.

const DialogPopup = () => {
  const navigate = useNavigate();

  const closeDialog = () => {
    navigate(-1);
  };

  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
      <div className="bg-white p-6 rounded-lg shadow-xl max-w-md w-full">
        <h2 className="text-2xl font-bold mb-4">
          This is DialogPopup component in a dialog
        </h2>
        <p className="mb-4">
          This is the content of the DialogPopup component.
        </p>
        <button
          onClick={closeDialog}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Close
        </button>
      </div>
    </div>
  );
};

이 팝업이 노출되는 원리는,

  1. 팝업 버튼 클릭 이벤트에, url 이동이 발생합니다.

  2. 현재 url 은, /popup/dialog 로 이동합니다.

  3. 하지만, 출력되는 <DialogPopup /> 컴포넌트는 router 의 설정에 때라, <Popup /> 컴포넌트의 레이아웃에 종속적입니다.

  4. <Popup /> 컴포넌트에는, <Outlet /> 이 포함되어 있습니다. 즉, children 격인 라우팅 포인트가 항상 노출되도록 설정되어 있습니다.

  5. 항상 노출되도록 설정된 children 격의 설정은, 오직, /popup/dialog 에 대해서만 작동하도록 설정되어 있습니다.

  6. 이 때문에, /popup/dialog 에서는 <Dialog /> 안에 <DialogPopup /> 를 포함한 레이아웃이 출력됩니다.

다른 트리 구조의 URL 에 대해서는 설정해줄 수 없고, HTML 표준인 Dialog 를 사용할 수도 없지만, 긴히 필요한 경우가 있을 수 있겠죠.

.

.

.

Error Handling and Fallback

react-error-boundary

우리의 프로젝트에서는 아직까지 ErrorBoundary 를 다루지는 않았습니다만, 대부분의 React 프로젝트에서는, 요청된 태스크를 처리하는 도중에 Error 가 발생했을 때, 처리 요청을 fallback 으로 보내는 코드를 사용합니다.

import { ErrorBoundary } from 'react-error-boundary';

export const Layout = () => {
  return (
    <ErrorBoundary fallback={<h1>Error</h1>}>
      <App />
    </ErrorBoundary>);
};
// ErrorBoundary 내에서 발생하는 모든 에러는 fallback 으로 지정된 react 컴포넌트로 전달됩니다.

잘 알려진 ErrorBoundary 라이브러리인, react-error-boundary 는

# npm i react-error-boundary

이렇게 프로젝트에 설치할 수 있습니다.

.

.

errorElement in RRD

그러나 RRD 에서는 Error 를 캐치하고 Fallback 으로 보내는 전 과정을 RRD 가 관리하기 때문에, ErrorBoundary 가 필요 없습니다.

위의 router 코드에서도 잠깐 나왔지만,

// 
export const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      {
        errorElement: <ErrorPage />,
        children: [
          { path: "/", element: <Home /> },
          {
            path: "/posts",
            children: [
              { index: true, ...PostsListRoute },
              { path: "new", ...NewPostRoute },
              { path: ":postId", ...PostRoute },
              { path: ":postId/edit", ...EditPostRoute },
              { path: ":postId/delete", ...DeletePostRoute },
            ],
          },

RRD 에서는 라우팅 코드에 errorElement 를 추가해서 에러 상황에 errorElement 로 핸들을 넘겨주도록 지정해줍니다.

위의 라우터가 사용된 프로젝트에서, 예를 들어 NewPostRoute 에서 fetch error 가 발생할 경우, 즉, <NewPost /> 컴포넌트의 loader() 에서 사용되는 axiosRequest() 펑션에서 fetch error 가 발생하는 경우, axiosRequest 의 try-catch 에서 에러가 캐치되고 throw 되면, 이 에러는 가장 가까운 errorElement 로 전파됩니다. 또, errorElement 는 하위 path 에서도 추가해줄 수도 있는데, 이렇게 되면, 에러가 발생했을 때, 가장 가까운 parent errorElement 로 핸들이 넘겨집니다. 디렉토리가 중첩되는 구조에서, 각 디렉토리에 특화된 에러 메시지와 처리를 별도로 지정해줄 수 있겠죠.

.

.

axiosRequest

RRD 의 loader 펑션 내부에서는, 아쉽게도 useAxiosFetch() 커스텀 훅을 사용할 수 없습니다. 커스텀 훅은 오직 컴포넌트 내부 코드에서만 호출해서 사용할 수 있다는 조건이 있기 때문이죠. loader() 는 일반 펑션이기 때문에, 커스텀 훅이 아닌, 일반 펑션을 만들어서 사용해야 합니다.

프로젝트에서 사용되는, axiosInstance.js 에 axiosRequest 펑션을 추가해줍니다.

// /src/util/axiosInstance.js
import axios from "axios";

// Axios 인스턴스 생성 및 설정
export const axiosBase = axios.create({
  baseURL: "<https://jsonplaceholder.typicode.com>",
  headers: {
    "Content-type": "application/json",
  },
});

type AxiosRequestPropType = {
  endPoint: string;
  config?: object;
};
export const axiosRequest = async ({
  endPoint,
  config = {},
}: AxiosRequestPropType) => {
  const source = axios.CancelToken.source();
  try {
    const response = await axiosBase.request({
      url: endPoint,
      ...config,
      cancelToken: source.token,
    });
    // Abort 펑션인 cancelToken 을 함께 반환합니다.
    return { data: response.data, cancelToken: source };
  } catch (e) {
    if (axios.isAxiosError(e)) {
      throw new Error(e.message);
    } else {
      throw new Error("An unexpected error occurred");
    }
  }
};

이제, axiosRequest() 펑션을, loader() 에서 이렇게 호출해서 사용할 수 있습니다.

// Post.tsx > loader()
// axiosRequest 에서 에러 처리가 되고 있으므로, loader 에서의 에러 처리는 굳이 필요 없으나,
// cancelToken 처리를 위해 try-catch 로 처리합니다.
const loader = async ({
  params,
}: LoaderFunctionArgs): Promise<UserLoaderDataType | null> => {
  const config = { method: "GET" };
  const cancelTokens: CancelTokenSource[] = [];

  try {
    const userResult = await axiosRequest({
      endPoint: `/users/${params.userId}`,
      config,
    });
    if (userResult?.cancelToken) cancelTokens.push(userResult.cancelToken);
    const userData: UserType = userResult?.data;

    const postsResult = await axiosRequest({
      endPoint: `/users/${params.userId}/posts`,
      config,
    });
    if (postsResult?.cancelToken) cancelTokens.push(postsResult.cancelToken);
    const postsData: PostType[] = postsResult?.data;

    return { user: userData, posts: postsData };
  } catch (e) {
    if (axios.isCancel(e)) {
      console.log("Request canceled", e.message);
      return null;
    }
    throw e;
  } finally {
    // 모든 요청 취소 (이미 완료된 요청에는 영향 없음)
    cancelTokens.forEach((cancelToken) => {
      cancelToken.cancel("Request canceled by cleanup");
    });
  }
};

RRD 의 loader() 펑션은, Error 가 발생하면 가장 가까운 ErrorElement 로 에러 핸들을 넘겨줍니다. try-catch 로 fetch 도중 발생할 수 있는 에러의 처리는, 이미 axiosRequest() 에서 해주고 있으므로, fetch 에러는 자동으로 가까운 ErrorElement 로 전달됩니다.

위의 코드에서 axiosRequest() 에서 캐치된 에러는, 정상적으로 error 객체가 throw 되지만, 이를 수신하고 처리해야 하는 loader() 에서는 사실 에러를 처리하고 핸들을 넘겨주는 코드가 필요 없습니다. RRD 에서는, loader() 함수 내에서 발생한 모든 에러가 자동으로 캐치되고 가장 가까운 errorElement 로 전달됩니다.

따라서 위의 코드에서 발생하는 에러가 처리되는 과정은 다음과 같습니다:

  1. axiosRequest 함수에서 에러가 발생하고 캐치됩니다.

  2. axiosRequest 함수는 에러를 throw합니다.

  3. loader 함수에서는 이 에러를 명시적으로 처리하지 않습니다.

  4. 그러나 React Router는 이 에러를 자동으로 캐치하고, 가장 가까운 errorElement로 전달합니다.

하지만, 일부 에러에 대해서는 상위로 전파하거나 예외로 처리하고 싶어질 수도 있겠죠.

// 추가 예외처리 예제 코드
const loader = async ({
  params,
}: LoaderFunctionArgs): Promise<PostLoaderDataType> => {
  try {
    const config = { method: "GET" };
    const postData: PostType = await axiosRequest({
      endPoint: "/posts/" + params.postId,
      config,
    });
    const commentData: CommentType[] = await axiosRequest({
      endPoint: `/posts/${params.postId}/comments`,
      config,
    });
    const userData: UserType = await axiosRequest({
      endPoint: `/users/${postData.userId}`,
      config,
    });
    return { post: postData, comments: commentData, user: userData };
  } catch (error) {
    // 여기서 특정 에러에 대한 커스텀 처리를 할 수 있습니다.
    // 예를 들어, 404 에러에 대해 다르게 처리하고 싶다면:
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      throw new Response("Not Found", { status: 404 });
    }
    // 그 외의 에러는 그대로 throw하여 errorElement로 전달
    throw error;
  }
};

.

.

.

Action handling

<Form />컴포넌트는, RHF 와 ShadCN 에서도 각자의 기능을 담당하는 Form 으로 제공되고 있었죠.

RRD 에서도 자체적인 Form 컴포넌트가 제공됩니다. 그리고 RRD 의 <Form /> 컴포넌트는,

  1. onSubmit() 에 url 액션이 발생하지만, 페이지 이동을 발생시키지 않습니다. 즉, GET 액션시에, server/action?query=query_val 처럼 url action 이 발생하지만, RRD 가 페이지 이동을 가로채고, 이에 대해 유효한 라우팅을 만들어 줍니다. (url 변경됨)

  2. RRD Form 의 디폴트 get action URL 은 현재 라우팅 포인트의 loader() 가 됩니다. 따라서, onSubmit() 에, loader() 가 실행됩니다.

  3. get 이외의, post/ put/ delete 등의 액션은 action() 펑션이 타겟 action URL 이 됩니다.

이런 특성을 갖습니다.

.

.

Action

HTTP 리퀘스트에는, GET, POST, PUT, DELETE 등이 있죠. loader() 는 이 중 GET 요청만을 처리할 수 있습니다. 나머지 요청들은 action() 펑션으로 처리하고, 구문과 형식은 loader() 와 같습니다.

loader() 가, 페이지의 로딩에 필요한 데이터를 획득하는 역할이라면,

action() 은, 페이지가 전송하는 <form> 의 처리를 담당합니다.

// /src/pages/AddTodo.tsx
// action
const action = async (req) => {
  const formData = await req.formData();
  const title = formData.get("title");
  const completed = formData.get("completed");
  
  if(title==="") return "Title required.";

  const config = { method: "POST", body: JSON.stringify(title, completed) };
  const todo = await axiosRequest({
    endPoint: "/new",
    config,
  });
  
  return redirect("/")
};

// export
const AddTodoRoute = {
  element: <AddTodo />,
  loader,
  action,
};

export default AddTodoRoute;

loader() 의 리턴 밸류를, useLoaderData 로 획득했던 것 처럼, action() 의 리턴 밸류는 useActionData 로 획득할 수 있습니다. fetch 도중 발생하는 에러 메시지 등의 처리에 필요하겠죠.


const Todos = () => {
  const todos = useLoaderData() as TodosLoaderDataType[];
  const actionResult = ussActionData();

하지만, RRD 의 action 처리 방식은 위의 코드에서 처럼 많이 번거롭습니다. 그리고, RHF 의 밸리데이션을 사용하기에도 어려워집니다. RRD 와 RHF 이 서로 폼 핸들링 주도권을 놓고 충돌하게 돼요.

import { Form, useSubmit, useActionData } from 'react-router-dom';
import { useForm } from 'react-hook-form';

function MyForm() {
  const submit = useSubmit();
  const actionData = useActionData();
  const { register, handleSubmit, formState: { errors: clientErrors } } = useForm();

  const onSubmit = (data) => {
    // RHF의 유효성 검사를 통과하면 RRD Form을 제출
    submit(data, { method: 'post' });
  };

  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username', { required: true })} />
      {clientErrors.username && <span>This field is required</span>}
      {actionData?.errors?.username && <span>{actionData.errors.username}</span>}
      
      <input {...register('email', { required: true, pattern: /^\\S+@\\S+$/i })} />
      {clientErrors.email && <span>Please enter a valid email</span>}
      {actionData?.errors?.email && <span>{actionData.errors.email}</span>}
      
      <button type="submit">Submit</button>
    </Form>
  );
}

// RRD action 함수
export async function action({ request }) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  
  // 서버 측 유효성 검사 로직
  const errors = {};
  if (!data.username) errors.username = "Username is required";
  if (!data.email || !/^\\S+@\\S+$/i.test(data.email)) errors.email = "Valid email is required";

  if (Object.keys(errors).length > 0) {
    return { errors };
  }

  // 유효성 검사 통과 시 데이터 처리
  // ...

  return { success: true };
}

프로그램의 진행 주도권이 RHF 과 RDD 사이를 복잡하게 탁구공처럼 왔다갔다 하고 있습니다.

그리고 RRD 에서도 자체적인 폼 밸리데이션을 위한 훅을 제공하고 있긴 합니다만, Zod 가 연동되는 RHF 쪽이 보다 직관적입니다.

취향에 따라 호불호가 갈릴 수 있겠으나, 저는 과감히 RRD action 을 버리고 RHF 의 폼 처리에 모든 핸들을 맡기고, 작업이 종료되는 시점에 추가로 navigate(url) 로 페이지를 이동시켜 주는 방식을 선호하고 있습니다.


function EditPost() {
  const formRef = useRef<HTMLFormElement>(null);
  const navigate = useNavigate();
  const location = useLocation();
  const state = location.state as string;
  const submittingState = state === "submitting";

  const { users, post }: loaderInterface = useLoaderData() as loaderInterface;

  const {
    register,
    watch,
    handleSubmit,
    formState: { errors },
  } = useForm<PostType>({
    resolver: zodResolver(PostSchema),
  });

  const onSubmitForm = async (data: PostType): Promise<void> => {
    const formR = formRef.current as HTMLFormElement;
    const formData = new FormData(formR);
    let formDataObject = Object.fromEntries(formData);
    if (post?.id !== undefined) {
      formDataObject = { ...formDataObject, id: post.id.toString() };
      console.log("formDataObject", formDataObject);
      // backend api function
      await updatePost(formDataObject, {});
      // navigate 로 url 이동
      navigate(`/posts/${post.id}`);
    }
  };

  return (
    <>
      <h1 className="page-title">Edit Post</h1>
      <form
        onSubmit={handleSubmit(onSubmitForm)}
        method="post"
        className="form"
        ref={formRef}
      >
        {/* <input type="hidden" {...register("id")} defaultValue={post?.id} /> */}
        <div className="form-row">
          <FormGroup errorMessage={errors?.title?.message}>
            <label htmlFor="title">Edit in 784... Post {post?.id}</label>
            <input
              type="text"
              {...register("title")}
              defaultValue={post?.title}
            />
          </FormGroup>

          <FormGroup errorMessage={errors?.userId?.message}>
            <label htmlFor="userId">Author</label>
            <select {...register("userId")} defaultValue={post?.userId}>
              <option value={0} key={0}>
                Nobody
              </option>
              {users &&
                users.map((user) => {
                  // console.log("user", user);
                  return (
                    <option value={Number(user.id)} key={user.id}>
                      {user.name}
                    </option>
                  );
                })}
            </select>
          </FormGroup>
        </div>
        <div className="form-row">
          <FormGroup errorMessage={errors?.body?.message}>
            <label htmlFor="body">Body</label>
            <textarea
              id="body"
              {...register("body")}
              defaultValue={post?.body}
            ></textarea>
          </FormGroup>
        </div>
        <div className="form-row form-btn-row">
          <Link className="btn btn-outline" to="/posts">
            Cancel
          </Link>
          <button type="submit" className="btn">
            Save
          </button>
        </div>
      </form>
    </>
  );
}

const loader = async ({
  params,
  request: { signal },
}): Promise<loaderInterface | undefined> => {
  const post = await getPost(params.postId, { signal });
  const users = await getUsers({ signal });
  return { users, post };
};

export const EditPostRoute = {
  element: <EditPost784 />,
  loader,
};

RRD Form action 세줄 요약

  1. RRD 의 action 은, loader 와 같은 방식으로 라우터에 action 펑션을 전달해줄 수 있다.

  2. 근데 번거로우니까, RRD action 을 버리고, RHF 으로 처리하면 작업이 쉬워진다.

  3. 하지만 form validation 이 필요하지 않은 간단한 입력 상황에는 RRD action 이 유리할 수 있다.

.

,

,

RRD 와 NextJS 의 App Routing

이미 NextJS 가 널리 사용되고 있고, NextJS 의 강력한 라우팅 기능이 다른 모든 라우팅을 압도하는데, 굳이 RRD 를 배워야 하는가 라는 회의적인 시각을 가질 수도 있습니다.

그러나, NextJS 는 SPA 에 적합한 프레임웍이 아니죠. SPA 영역에서 프로젝트의 규모가 커지면, RRD 를 필요로 하게 될 것입니다. 게다가 React-Router 는 React Native 에서도 사용되고 있기 때문에, RRD 를 학습하고 익숙해지기에 시간을 할애하는 일에 인색해질 필요는 없을 것 같습니다.

React 생태계에서, React-Router 의 지위는 쉽게 흔들리지는 않을 것 같다는 생각을 해봅니다.

.

.

.

fifth project : RRD Project

이번 프로젝트는 RRD 훈련입니다.

  1. Jsonplaceholder 의 목업용 더미 데이터를 사용해서,

    1. /users, /users/:userId

    2. /posts, /posts/:postId

    3. /todos

    각각의 라우팅을 설정하고, 페이지를 구성하십시오.

    각 endPoint 에 해당하는 jsonplaceholder API 의 주소는

    https://jsonplaceholder.typicode.com/users

    https://jsonplaceholder.typicode.com/users/4

    https://jsonplaceholder.typicode.com/posts

    https://jsonplaceholder.typicode.com/posts/16

    https://jsonplaceholder.typicode.com/todos

    입니다.

  2. 업데이트가 허용되지 않는 외부 API 를 사용했기 때문에, Form 기능을 충분히 활용하지 못합니다. 그러니, /todos 를 조금 바꿔봅시다.

    1. jsonplaceholder 에서 초기 todos 를 받아오지만, CRUD 액션은 localStorage 에서 진행합니다.

    2. localStorage 에 todos 를 먼저 질의하고, localStorage 의 totos 확보에 실패했을 때 jsonplaceholder 에 질의해서 todos 를 받아옵니다.

    3. AddTodo 컴포넌트를 생성하고, RHF + Zod 폼 밸리데이션과 액션을 구성합니다. 폼 밸리데이션을 추가하기엔 너무 가벼운 태스크입니다. 밸리데이션 추가는 취소

이번 프로젝트 역시, 완성된 코드를 선공개합니다.

그런데 이번에도, StackBlitz 의 인터페이스에서 에러가 발생합니다. RRD 가 제대로 지원이 안되는 것 같군요. 그래서 이번 코드는 StackBlitz 가 아닌 깃헙 레포지토리로 공개합니다. 라이브 데모는 버슬에 따로 올려두었습니다.

https://github.com/KangWoosung/react-router-dom-exercise

https://react-router-dom-exercise.vercel.app/

완성된 코드는 가이드 역할로 참고만 하시고, 이번 과제도 꼭 직접 구현해 보세요. 끈기가 곧 실력 아니겠슴까.

.

.

.

마치면서…

벌써 다음이 마지막 장이 되는군요.

이제 React 개발에서 중요한 포인트 들은 거의 다 다뤄본 셈입니다. 오늘까지 다룬 내용 만으로도 완성도 높은 앱을 구현할 수 있는 준비는 어느정도 갖춰진 셈이지요.

다음 장에서는 Suspense 가 중심 주제가 되는데요..

코어 리액트의 나머지 20% 중 10% 정도에 해당하는 중요한 주제이기도 합니다. 앱의 빌드 시점에도 작용하게 될 중요한 리액트 아키텍쳐라고 할 수 있습니다.

오늘도 긴 글 따라오시느라 고생하셨습니다.

다음 장은, 휴가 일정과 겹쳐져서 그동안 유지해온 템포 보다는 조금 늦어질 수도 있어요.

뜨거운 여름날씨에, 의미 있는 시간이 되었길 바라봅니다.

fin.







....