React Router v7 - (1) Getting Started

장유진·2026년 2월 22일

React Router v7

목록 보기
1/5
post-thumbnail

React Router는 v6에서 v7로 버전이 업그레이드되면서 매우 많은 부분이 변경되었다고 한다. 거의 Remix v2라고 봐도 무방..!! 시작해보자~~

1. Picking a Mode

React Router를 사용하는 방법(mode)에는 세 가지가 있다. 바로 Framework, Data, Declarative이다.

DeclarativeDataFramework 모드로 갈 수록 새로운 기능이 추가되고 사용하기가 더 까다로워지는 형태이다. 따라서 얼마나 많은 기능이 필요한지, 복잡하고 세세한 설정이 필요한지를 고려하여 모드를 잘 선택해야 한다.

1-1. Declarative

Declarative 모드는 URL을 컴포넌트에 매칭하고, 다른 URL로 이동하고, 현재 라우트의 상태를 제공하는 등의 기본적인 라우팅 기능들을 제공한다.

import { BrowserRouter } from "react-router";

ReactDOM.createRoot(root).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
);

1-2. Data

Data 모드는 라우트 관련 설정 코드를 React 밖으로 꺼냄으로써 데이터 로드, 액션, 로딩 중 상태 제어 등의 기능들을 추가적으로 제공한다.

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

let router = createBrowserRouter([
  {
    path: "/",
    Component: Root,
    loader: loadRootData,
  },
]);

ReactDOM.createRoot(root).render(
  <RouterProvider router={router} />,
);

1-3. Framework

Framework 모드는 Data 모드를 Vite 플러그인으로 감싸서 다음과 같은 라우팅 기능을 추가로 제공한다.

  • type-safe href
  • type-safe Route Module API
  • intelligent code splitting
  • SPA, SSR, and static rendering strategies
// routes.ts
import { index, route } from "@react-router/dev/routes";

export default [
  index("./home.tsx"),
  route("products/:pid", "./product.tsx"),
];

// product.tsx
import { Route } from "./+types/product.tsx";

export async function loader({ params }: Route.LoaderArgs) {
  let product = await getProduct(params.pid);
  return { product };
}

export default function Product({
  loaderData,
}: Route.ComponentProps) {
  return <div>{loaderData.product.name}</div>;
}

1-4. 어떤 모드를 선택해야 할까?

모든 모드가 SSR, SPA 등 모든 아키텍쳐와 배포 방법을 지원하므로, SSR을 사용할 것인지가 중요한 것이 아니라 얼마나 많은 것을 직접 제어하고 싶은가를 고려하여 선택해야 한다.

Framework 모드를 사용하면 좋은 경우

  • 아직 경험이 적어서 명확한 취향이나 기준이 없다.
  • Next.js, Solid Start, SvelteKit, Astro, Tanstack Start를 사용하려고 한다.
  • 그냥 React로 무언가를 만들고 싶다.
  • 서버 렌더링을 할 수도 있고 안 할수도 있다.
  • Remix를 사용하다가 넘어왔다. (React Router v7은 Remix v2의 다음 버전이다)
  • Next.js에서 마이그레이션 중이다.

⇒ 즉, 풀스텍 프레임워크처럼 사용하고 싶다면 Framework 모드를 사용하는 것이 좋다.

Data 모드를 사용하면 좋은 경우

  • React router의 데이터 기능(loader, action 등)을 사용하고 싶지만 번들링, 데이터 처리, 서버 추상화 등은 직접 처리하고 싶다.
  • React router v6.4를 사용 중이며 만족하고 있다.

⇒ 즉, 데이터 기능은 사용하되 나머지 구조는 직접 제어하고 싶다면 Data 모드를 사용하는 것이 좋다.

Declarative 모드를 사용하면 좋은 경우

  • React Router를 최대한 단순하게 사용하고 싶다.
  • v6에서 사용하던 BrowserRouter 방식에 만족한다.
  • Data layer를 이미 별도로 사용하고 있다.
  • Create React App을 사용하다가 넘어왔다. (이 경우에는 Framework 모드도 고려해볼만 함)

⇒ 즉, 최대한 단순하게 사용하고 싶다면 Data 모드를 사용하는 것이 좋다.

각 모드 별 사용 가능한 API 목록을 자세히 확인하고 싶다면 여기에서..!

❓Data layer가 뭘까?

Data layer는 API 호출, 캐싱, 로딩 상태, 에러 처리, 캐싱, 재요청(refetch) 등을 담당하는 로직/구조 전체를 의미한다. React Router의 Data 모드는 loader, action, useLoaderData, useNavigation 등을 제공하여 데이터 페칭, 로딩 상태, 에러 처리를 같이 관리해준다. 그리고 Tanstack Query, Zustand, Axios 같은 것들을 React Router와는 별개인 Data layer라고 할 수 있다.

❓TanStack Query + React Router Data Mode를 같이 쓰면 어떤 문제가 생길까?

기술적으로는 같이 사용할 수 있지만 역할이 겹쳐서 구조가 복잡해질 가능성이 크다.

  • 데이터 페칭 주체가 두 개가 됨
    • React Router → loader, Tanstack Query → useQuery
    • 같은 데이터를 어디에서 가져올기 기준이 모호해진다.
  • 캐싱 전략 충돌
    • React Router는 페이지 이동 시 revalidation, Tanstack Query는 데이터가 stale하면 재요청
    • 재요청 타이밍이 달라지면서 불필요한 중복 요청이 발생하거나, 에상과 다른 갱신이 일어날 수 있다.
  • pending / loading 상태가 이중화됨
    • React Router → navigation.state === "loading", Tanstack Query → useQueryisLoading, isFetching
    • 페이지 전환 로딩과 데이터 갱신 로딩이 분리되면서 UX 기준이 애매해질 수 있다.

굳이 같이 사용하고 싶다면 loader에서 queryClient.prefetchQuery만 사용하고 실제 데이터 페칭은 useQuery를 사용하는 방법을 사용하면 좋다고 한다.

❓Declarative 또는 Data 모드에서 SSR을 구현하려면 어떻게 해야 할까?

Declarative 모드에서 SSR
1. 서버에서 StaticRouter로 렌더링
2. 클라이언트에서 BrowserRouter로 hydration
3. loader가 없기 때문에 데이터는 별도로 처리

Data 모드에서 SSR
1. 서버에서 createStaticHandler 생성
2. 서버에서 StaticRouterProvider로 렌더링
3. 클라이언트에서 createBrowserRouter로 hydration


2. Framework mode

2-1. Installation

create-react-router를 실행하여 프로젝트 템플릿을 생성한다.

npx create-react-router@latest my-react-router-app

템플릿 목록에서 원하는 템플릿을 선택하여 생성할 수도 있다.

npx create-react-router@latest --template remix-run/react-router-templates/<template-name>

❓이미 세팅되어 있는 프로젝트에 Framework 모드를 설치하려면 어떻게 해야 할까?

아마 default 템플릿package.json, react-router.config.ts, tsconfig.json, vite.config.ts를 보고 따라하면 될 것 같다!

2-2. Routing

Route 설정하기

라우트는 app/routes.ts 파일에서 설정한다.

각 라우트에는 URL 매칭을 위한 URL 패턴과 해당 라우트에 연결할 route module의 경로를 설정해야 한다.

// app/routes.ts
import {
  type RouteConfig,
  route,
} from "@react-router/dev/routes";

export default [
  route("some/path", "./some/file.tsx"),
  // pattern ^           ^ module file
] satisfies RouteConfig;

파일 기반 라우팅을 설정하고 싶다면 @react-router/fs-routes를 설치하여 사용한다.

// app/routes.ts
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes() satisfies RouteConfig;

flatRoutesapp/routes.ts에서 export한 후 app/routes 폴더 또는 rootDirectory로 지정한 폴더 하위에 생성한 모든 파일들이 라우트가 된다.

app/routes.ts 파일도 있고 app/routes 폴더도 있는건가?

ㅇㅇ 헷갈리게시리..

Route Modules

Route Module은 각 라우트의 동작을 정의한 파일이다. Page 컴포넌트라고 생각하면 쉬울 듯 하다.

Route Module에서는 loader, action, error boundary 등 다양한 기능을 사용할 수 있다.

// provides type safety/inference
import type { Route } from "./+types/team";

// provides `loaderData` to the component
export async function loader({ params }: Route.LoaderArgs) {
  let team = await fetchTeam(params.teamId);
  return { name: team.name };
}

// renders after the loader is done
export default function Component({
  loaderData,
}: Route.ComponentProps) {
  return <h1>{loaderData.name}</h1>;
}

Nested Routes

부모 라우트 아래에 자식 라우트를 중첩하여 정의할 수 있다.

이 경우 부모 라우트의 경로가 자동으로 자식 라우트의 경로에 포함되고, 자식 라우트는 부모 라우트의 <Outlet />을 통해 렌더링된다.

그리고 app/root.tsx 는 root 라우트라고 불리며, routes.ts에 정의된 모든 라우트는 app/root.tsx라는 모듈 내부에 중첩된다. 즉, app/root.tsx는 최상위 부모 라우트 역할을 한다.

import {
  type RouteConfig,
  route,
  index,
} from "@react-router/dev/routes";

export default [
  // parent route
  route("dashboard", "./dashboard.tsx", [
    // child routes
    index("./home.tsx"),
    route("settings", "./settings.tsx"),
  ]),
] satisfies RouteConfig;

위 config의 경우 /dashboard, /dashboard/settings 경로가 생성된다.

Layout Routes

layout을 사용하면 URL 경로에 아무것도 추가하지 않으면서 자식 라우트를 감싸는 부모 라우트를 생성할 수 있다. root 라우트랑 비슷한 역할을 하지만 앱의 어느 레벨에서든 사용할 수 있다는 점이 다르다.

import {
  type RouteConfig,
  route,
  layout,
  index,
  prefix,
} from "@react-router/dev/routes";

export default [
  layout("./marketing/layout.tsx", [
    index("./marketing/home.tsx"),
    route("contact", "./marketing/contact.tsx"),
  ]),
] satisfies RouteConfig;

home.tsx, contact.tsx는 모두 marketing/layout.tsx<Outlet /> 내부에서 렌더링되지만 경로는 아래와 같이 설정된다.

  • home.tsx/
  • contact.tsx/contact

Index Routes

index를 사용하면 일반 route처럼 부모 라우트의 <Outlet /> 내부에서 렌더링 되지만 부모의 경로를 사용하게 된다.

import {
  type RouteConfig,
  route,
  index,
} from "@react-router/dev/routes";

export default [
  route("dashboard", "./dashboard.tsx", [
    index("./dashboard-home.tsx"),
    route("settings", "./dashboard-settings.tsx"),
  ]),
] satisfies RouteConfig;
  • dashboard-home.tsx/dashboard
  • dashboard-settings.tsx/dashboard/settings

Route Prefixes

prefix를 사용하면 부모 라우트를 구현할 필요 없이 자식 라우트들에 경로만 추가할 수 있다. route tree를 수정하는 것이 아니라 prefix 하위에 있는 자식 라우트들의 경로를 변경하는 것이다. 따라서 아래 라우트 설정들은 동일한 결과를 보여준다.

// prefix 사용
prefix("parent", [
  route("child1", "./child1.tsx"),
  route("child2", "./child2.tsx"),
])

// prefix 미사용
[
  route("parent/child1", "./child1.tsx"),
  route("parent/child2", "./child2.tsx"),
]

Dynamic Segments

경로의 특정 segment가 :로 시작하면 해당 segment는 동적 segment가 된다.

라우트가 URL과 매칭될 때 동적 segment의 값은 URL에서 추출되어 다른 router API에서 params 형태로 제공할 수 있게 된다.

// app/routes.ts
route("teams/:teamId", "./team.tsx"),

// app/team.tsx
import type { Route } from "./+types/team";

export async function loader({ params }: Route.LoaderArgs) {
  //                           ^? { teamId: string }
}

export default function Component({
  params,
}: Route.ComponentProps) {
  params.teamId;
  //        ^ string
}

Optional Segments

경로의 segment에 ?를 더해서 optional segment로 만들 수도 있다. 즉, 그 segment는 있어도 되고 없어도 된다. 정적, 동적 segment 둘 다에 적용 가능하다.

// static optional segment
route("users/:userId/edit?", "./user.tsx");
  • /users/123
  • /users/123/edit
// dynamic optional segment
route(":lang?/categories", "./categories.tsx"),
  • /categories
  • /en/categories
  • /ko/categories

Splats

splat segment는 /*로 끝나는 경로를 말하며 catchall 또는 star segment로도 알려져 있다.

경로가 /*로 끝나게 되면 /를 포함하여 / 이후에 오는 모든 문자를 매칭한다.

따라서 파일 경로를 처리하거나, 404 페이지를 처리하는 데 사용할 수 있다.

route("files/*", "./files.tsx"),
  • files
  • /files/readme.tsx
  • /files/images/photo.jpg

slat segment는 params에서 사용할 수 있지만 *는 유효한 변수 이름이 아니기 때문에 다른 이름으로 재할당해야 한다. 일반적으로 splat이라는 이름을 많이 사용한다.

const { "*": splat } = params;

Component Routes

컴포넌트 내부에서도 <Routes><Route>를 사용해 URL과 매칭되는 요소를 렌더링할 수 있다. 즉, 앱 전체 라우트 설정이 아니라 특정 컴포넌트 내부에서 라우트를 정의하는 방식이다.

⚠️ Component Routes는 loader, action, code splitting 등 route module의 기능들을 지원하지 않고 오직 URL에 따라 컴포넌트를 렌더링하는 기능만 제공한다.

따라서 Tab UI, Modal 내부 라우팅 등 로컬 UI 상태 관리 목적의 라우팅에 적합하다.

// Tab UI 예시
import { Routes, Route, Link } from "react-router";

export function ProfileTabs() {
  return (
    <div>
      {/* Tab buttons */}
      <nav>
        <Link to="">Posts</Link>
        {" | "}
        <Link to="likes">Likes</Link>
        {" | "}
        <Link to="settings">Settings</Link>
      </nav>

      {/* Tab content */}
      <Routes>
        <Route index element={<PostsTab />} />
        <Route path="likes" element={<LikesTab />} />
        <Route path="settings" element={<SettingsTab />} />
      </Routes>
    </div>
  );
}

2-3. Route Module

app/routes.ts 파일에서 참조되어 각 라우트의 동작을 구현하는 파일을 Route Module이라고 한다.

Route Module은 React Router 프레임워크의 기반이며, 다음과 같은 기능들을 제공한다.

  • automatic code-splitting
  • data loading
  • actions
  • revalidations
  • error boundaries
  • 기타 등등

❓route module api들은 정해진 이름으로 route module 파일에서 export하기만 하면 되는건가?

그렇다! 정해진 이름의 함수나 상수를 export하기만 하면 React Router가 알아서 인식한다.

Component (default로 export)

Route Module에서는 라우트가 매칭되었을 때 렌더링할 컴포넌트를 default로 export한다.

props

  • loaderData: loader의 리턴값
  • actionData: action의 리턴값
  • params: 파라미터 관련 정보
  • matches: 현재 라우트 트리에서 매칭된 모든 라우트 정보를 담은 배열, 부모 라우트부터 현재 라우트까지의 정보를 담은 배열

useLoaderData 또는 useParams 대신 이 props를 사용할 수 있다.

middleware

middleware는 document와 데이터 요청 전후에 서버에서 순차적으로 실행되는 함수이다. 이를 통해 다음과 같은 공통 로직을 한 곳에서 처리할 수 있다.

  • 로깅
  • 인증
  • 응답 후처리

middlewarenext 함수를 호출하여 체이닝 할 수 있으며, 다음 middleware 또는 최종 loader/action을 실행한다.

async function authMiddleware({ request, context }) {
  const session = await getSession(request);
  const userId = session.get("userId");

  if (!userId) {
    throw redirect("/login");
  }

  const user = await getUserById(userId);
  context.set(userContext, user);
}

export const middleware = [authMiddleware];

❓ document 요청일때와 데이터 요청일 때의 차이는?

document 요청은 GET /route로 요청이 온 것을 의미하며, 사용자가 어플리케이션에 처음 접근하거나 새로고침했을 경우 발생한다. 이 때 서버는 단순히 데이터만 보내는 것이 아니라 전체 HTML 페이지를 그려서 응답해야 하기 때문에 middleware는 항상 실행된다.
data 요청은 GET /route.data로 요청이 온 것을 의미하며, 사용자가 페이지를 이동(client-side navigation)할 때 발생한다. 이 때 middleware는 서버에서 실행할 loader / action이 있을 때만 실행된다.

❓ 작성한 middleware가 request 전에 실행되는지 후에 실행되는지 어떻게 구분할까?

실행 시점을 구분하는 기준은 next() 함수를 호출하는 위치이다. await next() 호출 전의 코드는 request 전에 실행되고, await next() 호출 후의 코드는 request가 끝나고 response 객체가 생성된 후에 실행된다.

async function loggingMiddleware({ request }, next) {
  console.log(`Request: ${request.method} ${request.url}`);
  let response = await next();
  console.log(
    `Response: ${response.status} ${request.method} ${request.url}`,
  );
  return response;
}

export const middleware: Route.MiddlewareFunction[] = [
  loggingMiddleware,
];

참고: https://reactrouter.com/how-to/middleware#when-middleware-runs

clientMiddleware

clientMiddleware는 클라이언트 navigation 전후에 브라우저에서 실행되는 middleware이다. middleware와 다른 점은 서버가 아니라 브라우저에서 실행되기 때문에 Response를 리턴하지 않는다는 점이다.

async function loggingMiddleware(
  { request, context },
  next,
) {
  console.log(
    `${new Date().toISOString()} ${request.method} ${request.url}`,
  );
  const start = performance.now();
  await next(); // 👈 No Response returned
  const duration = performance.now() - start;
  console.log(
    `${new Date().toISOString()} (${duration}ms)`,
  );
  // ✅ No need to return anything
}

export const clientMiddleware = [loggingMiddleware];

loader

loader는 component가 렌더링되기 전에 데이터를 제공하는 함수이다. loader는 SSR 또는 pre-rendering 시에만 실행된다.

export async function loader() {
  return { message: "Hello, world!" };
}

export default function MyRoute({ loaderData }) {
  return <h1>{loaderData.message}</h1>;
}

clientLoader

clientLoaderloader를 보완하거나 대체하여 component에 데이터를 제공하는 함수이다.

export async function clientLoader({ serverLoader }) {
  // call the server loader
  const serverData = await serverLoader();
  // And/or fetch data on the client
  const data = getDataFromClient();
  // Return the data to expose through useLoaderData()
  return data;
}
clientLoader.hydrate = true as const;

브라우저에서 클라이언트 navigation 시 실행되며 clientLoader에 hydrate 옵션을 설정하면 SSR 페이지의 hydration 과정에서도 clientLoader가 실행된다.

action

action은 서버에서 데이터 변경(mutation)을 처리하는 함수이다.

<Form>, useFetcher, useSubmit에서 호출될 경우 자동으로 모든 loader를 다시 실행해 UI를 최신 상태로 유지한다.

// route("/list", "./list.tsx")
import { Form } from "react-router";
import { TodoList } from "~/components/TodoList";

// this data will be loaded after the action completes...
export async function loader() {
  const items = await fakeDb.getItems();
  return { items };
}

// ...so that the list here is updated automatically
export default function Items({ loaderData }) {
  return (
    <div>
      <List items={loaderData.items} />
      <Form method="post" navigate={false} action="/list">
        <input type="text" name="title" />
        <button type="submit">Create Todo</button>
      </Form>
    </div>
  );
}

export async function action({ request }) {
  const data = await request.formData();
  const todo = await fakeDb.addItem({
    title: data.get("title"),
  });
  return { ok: true };
}

clientAction

action과 비슷하지만 브라우저에서만 실행되는 함수이다.

export async function clientAction({ serverAction }) {
  fakeInvalidateClientSideCache();
  // can still call the server action if needed
  const data = await serverAction();
  return data;
}

ErrorBoundary

다른 route module API에서 에러가 발생하면 ErrorBoundary가 route component 대신 렌더링된다.

HydrateFallback

첫 페이지 로드 시에는 route component가 client loader가 완료된 후 렌더링된다. HydrateFallback은 로딩 중 대체 UI를 렌더링한다.

headers

해당 route module이 서버에서 렌더링될 때 어떤 HTTP 헤더를 내려줄 지 정의한다.

documnet의 head에 포함될 <link> 요소들을 정의한다.

export function links() {
  return [
    {
      rel: "icon",
      href: "/favicon.png",
      type: "image/png",
    },
    {
      rel: "stylesheet",
      href: "https://example.com/some/styles.css",
    },
    {
      rel: "preload",
      href: "/images/banner.jpg",
      as: "image",
    },
  ];
}

meta

<Meta> 컴포넌트 안에 들어갈 meta tag를 정의한다.

자식에 정의된 meta는 부모에 정의된 meta를 덮어쓴다. 배열이 병합되는 것이 아니라 아예 대체된다는 점을 주의해야 한다.

export function meta() {
  return [
    { title: "Very cool app" },
    {
      property: "og:title",
      content: "Very cool app",
    },
    {
      name: "description",
      content: "This app is the best",
    },
  ];
}

⚠️ React 19부터는 React에서 기본으로 제공되는 빌트인 <meta> 태그를 사용하는 것이 권장된다.

shouldRevalidate

navigation이나 form 제출 이후에 해당 route의 loader를 다시 실행할지를 결정한다.

Framework mode에서 SSR을 사용할 때는 기본 동작이 항상 true이고, Data mode 또는 SPA를 사용할 때는 필요한 경우에만 loader를 다시 실행한다. 즉, route params가 변경되거나, URL의 search params가 변경되거나, action이 성공적으로 실행된 이후에만 loader가 다시 호출된다.

route loader의 재실행 여부 최적화가 필요할 경우 shouldRevalidate를 사용하여 직접 로직을 구현하여 적용할 수 있다.

2-4. Rendering Strategies

React Router에는 세 가지 렌더링 전략이 있다.

  • Client Side Rendering
  • Server Side Rendering
  • Static Pre-rendering

Client Side Rendering

import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

Server Side Rendering

import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
} satisfies Config;

SSR을 구현하려면 이를 지원하는 배포 환경이 필요하다. 즉, 정적 호스팅만으로는 어렵고 서버 런타임(Node 등)이 필요하다. SSR은 전역으로 설정되지만 각 route 단위로 pre-render 설정이 가능하고, loader를 사용하지 않고 clientLoader만 사용하는 방법으로 SSR을 피할 수 있다.

Static Pre-rendering

import type { Config } from "@react-router/dev/config";

export default {
  // return a list of URLs to prerender at build time
  async prerender() {
    return ["/", "/about", "/contact"];
  },
} satisfies Config;

Pre-rendering이란 빌드 시점에 정적 HTML과 client navigation data를 생성하는 것이다. client navigation data는 loader를 빌드 시점에 생성하여 JSON 파일로 저장해둔 것을 말한다.

2-5. Data Loading

데이터는 loaderclientLoader로부터 route component로 전달된다.

데이터는 loader에서 자동으로 serialize되고 컴포넌트에서 deserialize된다.

loaderData의 타입은 loader의 반환 타입을 기반으로 자동으로 추론된다.

❓serialize (직렬화)란?

데이터를 전송하거나 저장할 수 있는 형태로 변환하는 것

const data = { name: "Lee", age: 20 };
JSON.stringify(data);

❓deserialize (역직렬화)란?

직렬화된 데이터를 다시 원래 형태로 복원하는 것

const str = '{"name":"Lee","age":20}';
JSON.parse(str);

Client Data Loading

데이터 fetch를 위해 clientLoader를 사용한다.

Server Data Loading

데이터 fetch를 위해 loader를 사용한다. loader는 첫 페이지 로드와 client navigation 둘 다에서 실행된다. loader는 클라이언트 번들에서는 제거되므로 서버에서만 사용할 수 있는 API들을 걱정 없이 사용해도 된다.

Static Data Loading

데이터 fetch를 위해 loader를 사용한다. loader는 production 빌드 타임에 실행된다. react-router.config.ts 에서 pre-render될 거라고 지정되지 않은 route들은 SSR로 실행된다.

2-6. Actions

데이터 변경(mutation)은 route action을 통해 실행된다. action이 완료되면 현재 라우트의 모든 loader를 재실행하여 UI를 동기화한다. route action 중 action은 서버에서만 실행되고 clientAction은 브라우저에서만 실행된다.

Client Actions

export async function clientAction({
  request,
}: Route.ClientActionArgs) {
  let formData = await request.formData();
  let title = formData.get("title");
  let project = await someApi.updateProject({ title });
  return project;
}

Server Actions

export async function action({
  request,
}: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get("title");
  let project = await fakeDb.updateProject({ title });
  return project;
}

Calling Actions

action들은 <Form>을 통해 선언적으로 실행되거나 useSubmit, <fetcher.Form>, fetcher.submit을 통해 명령적으로 실행된다. 실행할 때는 해당 route의 경로를 참조하고 method를 post로 실행해야 한다.

// <Form> 사용
function SomeComponent() {
  return (
    <Form action="/projects/123" method="post">
      <input type="text" name="title" />
      <button type="submit">Submit</button>
    </Form>
  );
}

// useSubmit 사용
function handleAdd() {
  const submit = useSubmit();
  const formData = new FormData();
  formData.append("title", "New Todo");

  submit(formData, {
    method: "post",
    action: "/todos",
  });
}

// <fetcher.Form> 사용
function Task() {
  let fetcher = useFetcher();
  let busy = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action="/update-task/123">
      <input type="text" name="title" />
      <button type="submit">
        {busy ? "Saving..." : "Save"}
      </button>
    </fetcher.Form>
  );
}

// fethcer.submit 사용
fetcher.submit(
  { title: "New Title" },
  { action: "/update-task/123", method: "post" },
);

2-7. Navigating

<Link><NavLink><Form>redirectuseNavigate 를 사용하여 다른 라우트로 이동할 수 있다.

active / pending 상태가 필요한 네비게이션 링크

<NavLink to="/" end>
  • active: 현재 URL과 일치할 때
  • pending: 이동 중일 때
  • transitioning: view 전환 중일 때

route의 상태에 따라 a 태그에 class가 추가되어 CSS로 스타일링 할 수 있다.

a.active {
  color: red;
}

a.pending {
  animate: pulse 1s infinite;
}

a.transitioning {
  /* css transition is running */
}

className, style, children에 callback props를 받아서 활용할 수 있다.

<NavLink
  to="/messages"
  className={({ isActive, isPending, isTransitioning }) =>
    [
      isPending ? "pending" : "",
      isActive ? "active" : "",
      isTransitioning ? "transitioning" : "",
    ].join(" ")
  }
  style={({ isActive, isPending, isTransitioning }) => {
  return {
    fontWeight: isActive ? "bold" : "",
    color: isPending ? "red" : "black",
    viewTransitionName: isTransitioning ? "slide" : "",
  };
}}
>
  {({ isActive, isPending, isTransitioning }) => (
    <span className={isActive ? "active" : ""}>Tasks</span>
  )}
</NavLink>

pendingtransitioning의 차이는?

  • pending: loder, action이 실행되고 있어서 데이터 로딩 중인 상태
  • transitioning: document.startViewTransition() 기반 UI 전환이 실행되는 상태.

상태에 따른 스타일링이 필요하지 않은 일반 텍스트 링크

<Link to="/login">Login again</Link>

Form

사용자가 폼에 입력한 정보를 URLSearchParams로 넘기며 라우트를 이동

<Form action="/search">
  <input type="text" name="q" />
</Form>

예제 코드에서 사용자가 journey를 입력 후 제출한다면 /search?q=journey 로 이동하게 된다.

⚠️ post method를 사용하면 URLSearchParams로 데이터를 넘기는 것이 아니라 FormData 형식으로 데이터를 action에 제출하게 된다.

redirect

loader나 action 안에서 다른 라우트로 이동할 때 사용한다.

// loader
export async function loader({ request }) {
  let user = await getUser(request);
  if (!user) {
    return redirect("/login");
  }
  return { userName: user.name };
}

// action
export async function action({ request }) {
  let formData = await request.formData();
  let project = await createProject(formData);
  return redirect(`/projects/${project.id}`);
}

useNavigate

사용자 인터렉션과 관계없이 페이지 이동을 할 수 있게 하는 훅이다.

비활성 로그아웃, 타이머 UI, 특정 조건 만족 시 자동 이동 등의 경우에 사용할 수 있고 일반적인 이동에는 다른 Navigation API 사용을 권장한다.

import { useNavigate } from "react-router";

export function useLogoutAfterInactivity() {
  let navigate = useNavigate();

  useFakeInactivityHook(() => {
    navigate("/logout");
  });
}

2-8. Pending UI

새로운 라우트로 이동하거나 action에 데이터를 제출할 때, UI는 pending 또는 optimistic한 상태로 즉각적으로 변경되어야 한다.

Global Pending Navigation

useNavigation을 통해 라우트 이동 시의 pending 상태를 얻을 수 있고 이를 통해 앱 전체에 적용되는 pending UI를 구현할 수 있다.

import { useNavigation } from "react-router";

export default function Root() {
  const navigation = useNavigation();
  const isNavigating = Boolean(navigation.location);

  return (
    <html>
      <body>
        {isNavigating && <GlobalSpinner />}
        <Outlet />
      </body>
    </html>
  );
}

Local Pending Navigation

NavLinkisPending callback prop으로 각 링크 별 pending UI를 적용할 수 있다.

<NavLink to="/home">
  {({ isPending }) => (
    <span>Home {isPending && <Spinner />}</span>
  )}
</NavLink>

Pending Form Submission

폼 제출 시 useFetcher를 사용하여 즉각적으로 pending UI를 노출할 수 있다.

import { useFetcher } from "react-router";

function NewProjectForm() {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post">
      <input type="text" name="title" />
      <button type="submit">
        {fetcher.state !== "idle"
          ? "Submitting..."
          : "Submit"}
      </button>
    </fetcher.Form>
  );
}

fetcher를 사용하지 않은 폼 제출인 경우 useNavigation을 활용할 수 있다.

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

function NewProjectForm() {
  const navigation = useNavigation();

  return (
    <Form method="post" action="/projects/new">
      <input type="text" name="title" />
      <button type="submit">
        {navigation.formAction === "/projects/new"
          ? "Submitting..."
          : "Submit"}
      </button>
    </Form>
  );
}

Optimistic UI

제출 후의 UI가 정해져 있다면 낙관적 업데이트를 하는 것도 좋은 방법이다.

function Task({ task }) {
  const fetcher = useFetcher();

  let isComplete = task.status === "complete";
  if (fetcher.formData) {
    isComplete =
      fetcher.formData.get("status") === "complete";
  }

  return (
    <div>
      <div>{task.title}</div>
      <fetcher.Form method="post">
        <button
          name="status"
          value={isComplete ? "incomplete" : "complete"}
        >
          {isComplete ? "Mark Incomplete" : "Mark Complete"}
        </button>
      </fetcher.Form>
    </div>
  );
}
  1. 현재 상태: task.status
  2. 폼 제출 후: fetcher.formData가 생겨 UI를 바로 업데이트
  3. action 완료 후: route loader revalidation 후 최신 task 상태를 받아옴

2-9. Testing

useLoaderData, <Link> 등을 사용하는 컴포넌트는 반드시 React Router의 context 내부에서 렌더링해야 한다. createRoutesStub 함수는 가짜 context를 생성하여 컴포넌트를 독립적으로 테스트할 수 있도록 해준다.

import { createRoutesStub } from "react-router";
import {
  render,
  screen,
  waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";

test("LoginForm renders error messages", async () => {
  const USER_MESSAGE = "Username is required";
  const PASSWORD_MESSAGE = "Password is required";

  const Stub = createRoutesStub([
    {
      path: "/login",
      Component: LoginForm,
      action() {
        return {
          errors: {
            username: USER_MESSAGE,
            password: PASSWORD_MESSAGE,
          },
        };
      },
    },
  ]);

  // render the app stub at "/login"
  render(<Stub initialEntries={["/login"]} />);

  // simulate interactions
  userEvent.click(screen.getByText("Login"));
  await waitFor(() => screen.findByText(USER_MESSAGE));
  await waitFor(() => screen.findByText(PASSWORD_MESSAGE));
});

Using with Framework Mode Types

createRoutesStubuseLoaderData, useActionData, useMatches 등을 사용하여 router context에 의존하는 일반 컴포넌트의 unit 테스트를 위한 도구이다.

createRoutesStub 는 route component 그 자체를 테스트하기 위해 사용할 수 없다. Framework Mode에서 route component의 타입은 실제 route tree 구조, loader, action, matches 구조를 기반으로 자동으로 생성되기 때문에 타입 충돌이 발생한다.

loader와 action의 타입은 실제 loader와 action과 동일하게 하면 어떻게든 맞출 수는 있지만, matches는 route tree를 기반으로 한 것이기 때문에 절대 맞출 수 없다.

import LoginRoute from "./login";

test("LoginRoute renders error messages", async () => {
  const Stub = createRoutesStub([
    {
      path: "/login",
      Component: LoginRoute,
      // ^ ❌ Types of property 'matches' are incompatible.
      action() {
        /*...*/
      },
    },
  ]);

  // ...
});

위와 같은 테스트 코드를 작성했다고 했을 때 실제 앱에서는 [Root, Layout, Login] 이런 식으로 matches가 들어오지만 테스트 stub에서는 [Login]으로 들어오기 때문에 matches와 관련된 타입 에러가 발생한다.

따라서 route component를 테스트할 때는 E2E 테스트를 하는 것을 권장하고, 굳이 unit 테스트를 해야겠다면 @ts-expect-error 코멘트를 사용해야 한다.

profile
프론트엔드 개발자

0개의 댓글