React Router v7 - (5) How-Tos

장유진·2026년 3월 15일

React Router v7

목록 보기
5/5
post-thumbnail

1. Accessibility

React Router에서의 접근성은 일반적인 웹 접근성과 비슷하다. 시멘틱 HTML 태그를 사용하고 Web Content Accessibility Guidelines (WCAG)를 준수하는 것 만으로 대부분의 접근성 문제를 해결할 수 있다.

React Router가 접근성을 기본적으로 적용하는 부분도 있지만 그렇지 않은 경우에는 직접 사용할 수 있는 API를 제공한다.

  • <Link> 컴포넌트: 표준 a 태그를 렌더링하므로 브라우저가 기본적으로 제공하는 접근성 기능을 그대로 가져올 수 있다.
  • <NavLink> 컴포넌트: <Link> 컴포넌트의 접근성 기능을 전부 제공하며, 추가로 현제 페이지의 경로가 링크의 경로와 일치할 경우 aria-current="page"를 자동으로 설정한다.

Routing

  • 전통적인 웹 페이지: 링크를 누르면 새 페이지를 아예 새로고침하며 불러오고, 이 과정은 브라우저가 알아서 처리하므로 접근성에 대해 크게 고민할 필요가 없다.
  • React Router: 브라우저의 기본 동작을 막고 React Router가 라우팅을 직접 제어하므로 브라우저는 페이지가 변경되었다는 것을 감지하지 못할 수 있다.

따라서 React Router 사용 시 고려할 점

  • Focus management: 페이지 이동 시 포커스를 둘 요소 지정
  • Live-region announcements: 페이지 이동 시 스크린 리더 사용자에게 음성으로 페이지가 변경되었음을 알림

2. Client Data

clientLoaderclientAction을 사용하여 SSR에서 클라이언트 데이터를 활용하는 사례들

Skip the Server Hop (서버 거치지 않기)

BFF를 사용할 경우에 React Router 서버를 거치지 않고 직접 백엔드 API와 통신하는 방법이다. 적절한 인증 처리와 CORS 처리가 있어야 가능하다.

  1. 첫 접근으로 document가 로드될 때는 서버에서 loader를 실행
  2. 그 다음 접근부터는 clientLoader 실행
export async function loader({
  request,
}: Route.LoaderArgs) {
  const data = await fetchApiFromServer({ request }); // (1)
  return data;
}

export async function clientLoader({
  request,
}: Route.ClientLoaderArgs) {
  const data = await fetchApiFromClient({ request }); // (2)
  return data;
}

Fullstack State

렌더링 전에 서버 데이터와 클라이언트 데이터를 합칠 수 있다.

  1. document 로드 시 loader에서 서버 데이터를 가져온다.
  2. 아직 데이터가 충분하지 않으므로 SSR 렌더링 중 보여줄 HydreateFallback 컴포넌트를 export한다.
  3. clientLoader.hydrate = true로 설정하여 첫 접속(hydration) 시에도 clientLoader를 실행하게 한다.
  4. clientLoader 내부에서 서버 데이터와 클라이언트 데이터를 병합한다.
export async function loader({
  request,
}: Route.LoaderArgs) {
  const partialData = await getPartialDataFromDb({
    request,
  }); // (1)
  return partialData;
}

export async function clientLoader({
  request,
  serverLoader,
}: Route.ClientLoaderArgs) {
  const [serverData, clientData] = await Promise.all([
    serverLoader(),
    getClientData(request),
  ]);
  return {
    ...serverData, // (4)
    ...clientData, // (4)
  };
}
clientLoader.hydrate = true as const; // (3)

export function HydrateFallback() {
  return <p>Skeleton rendered during SSR</p>; // (2)
}

export default function Component({
  // This will always be the combined set of server + client data
  loaderData,
}: Route.ComponentProps) {
  return <>...</>;
}

Choosing Server or Client Data Loading

각 라우트 별로 데이터를 서버에서 가져올지, 클라이언트에서 가져올지를 설정할 수 있다.

  1. 서버 데이터 사용을 원할 경우 해당 라우트에서 loader를 export한다.
  2. 클라이언트 데이터 사용을 원할 경우 해당 라우트에서 clinetLoaderHydrateFallback을 export한다.

Client-Side Caching

브라우저 메모리나 localStorage 등을 사용해 서버 요청을 최소화할 수 있다.

  1. 첫 document 로드 시 loader에서 데이터를 가져온다.
  2. clientLoader.hydrate = true를 설정하고 clientLoader 내부에서 초기 데이터를 브라우저 캐시에 저장한다.
  3. 다른 페이지로 갔다가 돌아왔을 때 서버로 요청하지 않고 clientLoader에 저장된 캐시를 사용한다.
  4. clientAction이 실행될 경우 캐시를 비활성화한다.
export async function loader({
  request,
}: Route.LoaderArgs) {
  const data = await getDataFromDb({ request }); // (1)
  return data;
}

export async function action({
  request,
}: Route.ActionArgs) {
  await saveDataToDb({ request });
  return { ok: true };
}

let isInitialRequest = true;

export async function clientLoader({
  request,
  serverLoader,
}: Route.ClientLoaderArgs) {
  const cacheKey = generateKey(request);

  if (isInitialRequest) {
    isInitialRequest = false;
    const serverData = await serverLoader();
    cache.set(cacheKey, serverData); // (2)
    return serverData;
  }

  const cachedData = await cache.get(cacheKey);
  if (cachedData) {
    return cachedData; // (3)
  }

  const serverData = await serverLoader();
  cache.set(cacheKey, serverData);
  return serverData;
}
clientLoader.hydrate = true; // (2)

export async function clientAction({
  request,
  serverAction,
}: Route.ClientActionArgs) {
  const cacheKey = generateKey(request);
  cache.delete(cacheKey); // (4)
  const serverData = await serverAction();
  return serverData;
}

3. Data Strategy

⚠️ dataStrategy는 특정 사용 케이스를 위한 low-level API이다. 이 API를 사용하면 React Router의 action/loader를 실행하는 내부적인 동작을 덮어쓰게 되므로, 잘못 사용하면 어플리케이션 코드가 정상적으로 동작하지 않을 수 있으니 주의하여 사용해야 한다.

dataStrategy란?

React Router는 기본적으로 데이터 로드 및 제출 시에 loader/action 함수를 병렬적으로 실행하여 성능을 최적화한다. 대부분의 경우에는 적절한 방법이지만 실제로는 다양한 요구사항이 존재하기 때문에 다른 방식을 사용해야 할 수 있다.

dataStrategy 옵션을 사용하면 action과 loader 함수가 실행되는 방식을 직접 제어할 수 있다. 또한 이를 기반으로 middleare, context, 캐싱 같은 고급 기능을 구현할 수 있는 토대를 마련할 수 있다.

파라미터

  • matches: DataStrategyMatch 인스턴스 배열로, 현재 경로에 해당하는 모든 라우트의 정보를 담은 배열이다.
  • runClientMiddleware: 매칭된 라우트들에 설정된 middleware를 실행해주는 헬퍼 함수
  • fetcherKey: 네비게이션이 아니라 fetcher에 의해 요청되었을 때 해당 fetcher의 key

DataStrategyMatch 란?

일반 라우트 매칭 속성 + 아래의 속성들이 추가된 객체이다.

  • shouldCallHandler: 해당 라우트의 handler가 해당 요청에서 실행되어야 하는지를 판단하는 함수
  • shoudRevalidateArgs: 해당 라우트의 shouldRevalidate에 전달된 인자들
  • resolve: loader나 action을 실행하는 함수

사용 방법

matches로 라우트들을 흝어보고, shoudCallHandler로 실행 여부를 체크한 뒤, resolve를 원하는 타이밍에 실행한다!

let router = createBrowserRouter(routes, {
  async dataStrategy({
    matches,
    request,
    runClientMiddleware,
  }) {
    const matchesToLoad = matches.filter((m) =>
      m.shouldCallHandler(),
    );

    const results: Record<string, DataStrategyResult> = {};
    Promise.all(
      matchesToLoad.map(async (match) => {
        console.log(`Processing ${match.route.id}`);
        // The resolve function calls through to the route handler
        results[match.route.id] = await match.resolve();
      }),
    );
    return results;
  },
});

⚠️ dataStrategy 함수는 Record<string, DataStrategyResult>를 반환해야 한다.

라우트에서 middleware를 사용할 경우 runClientMiddleware 함수를 사용해야 한다.

await runClientMiddleware(() =>
  Promise.all(
    matchesToLoad.map(async (match) => {
      results[match.route.id] = await match.resolve();
    }),
  ),
);

handler 실행을 더 섬세하게 컨트롤하고 싶을 경우 match.resolve()에 callback 함수를 전달한다.

await Promise.all(
  matchesToLoad.map((match, i) =>
    match.resolve((handler) => {
      let customContext = getCustomContext();
      // Call the handler and p[ass a custom parameter as the handler's second argument
      return handler(customContext);
    }),
  ),
);

revalidation 동작을 변경하고 싶은 경우 defaultShouldRevalidateshouldCallHandler에 전달한다.

const matchesToLoad = matches.filter((match) => {
  let defaultShouldRevalidate = customShouldRevalidate(
    match.shouldRevalidateArgs,
  );
  return m.shouldCallHandler(defaultShouldRevalidate);
});

4. Error Boundaries

에러 발생 시 route module은 에러를 자동으로 감지하여 가장 가까운 ErrorBoundary를 렌더링한다.

root error boundary

기본적으로 root route에서는 ErrorBoundary를 export해야 한다. 이 root ErrorBoundary는 아래의 세 가지 경우의 에러 상황을 처리한다.

  • 상태 코드가 있는 데이터를 던졌을 때
  • 자바스크립트 코드 오류로 인해 stack trace가 포함된 실제 에러가 발생했을 때
  • 그 외 예상치 못한 값이 throw 되었을 때

Framework 모드에서는 root ErrorBoundary에 Route.ErrorBoundaryProps 타입의 prop이 전달되지만 Data 모드에서는 prop이 전달되지 않기 때문에 useRouteError 훅을 사용해 에러에 접근해야 한다.

❓ stack trace란?

에러 발생 시점까지의 함수 호출 경로를 기록한 리스트로, 버그가 어디서, 왜 발생했는지 찾는 디버깅의 핵심 도구이다. 보안상 실제 서비스 유저에게 노출되어서는 안 된다.

Write a bug

ErrorBoundary는 코드의 실수를 잡는 목적으로 사용되어야 하므로 의도적으로 에러 화면으로 보내기 위해 일부러 에러를 던지는 것은 권장되지 않는다. ErrorBoundary는 단순히 컴포넌트 내부의 에러만 잡는 것이 아니라, 모든 route module API에 적용된다.

단, 예외적으로 404 에러의 경우에는 적절한 status 코드와 함께 의도적으로 에러를 던져도 괜찮다.

Error Sanitization (에러 정화)

Framework 모드의 운영 환경에서는 서버에서 발생한 에러들은 브라우저로 보내지기 전에 필터링되어 stact trace가 아닌 일반적인 메세지만 전달되게 된다. 서버의 파일 경로, DB 구조, 내부 로직 등의 민감한 정보가 노출되는 것을 막기 위해서이다.

  • throw Error 방식 : throw new Error("DB 연결 실패: 비밀번호 1234") → “An unexpected error occurred”로 필터링됨
  • throw data 방식: throw data({ message: "사용자를 찾을 수 없습니다" }, { status: 404 }) → 개발자가 의도적으로 사용자에게 전달하려는 정보로 간주되어 필터링되지 않음

5. Error Reporting

Server Errors

  1. react-router reveal entry.server를 실행하여 entry.server.tsx 파일 노출
  2. entry.server.tsx에서 handleError 함수 export
import { type HandleErrorFunction } from "react-router";

export const handleError: HandleErrorFunction = (
  error,
  { request },
) => {
  // React Router may abort some interrupted requests, don't log those
  if (!request.signal.aborted) {
    myReportError(error);

    // make sure to still log the error so you can see it
    console.error(error);
  }
};

Client Errors

  1. react-router reveal entry.client를 실행하여 entry.client.tsx 파일 노출
  2. entry.client.tsxHydratedRouter 또는 RouterProvider 컴포넌트에 onError 함수 전달
import { type ClientOnErrorFunction } from "react-router";

const onError: ClientOnErrorFunction = (
  error,
  { location, params, unstable_pattern, errorInfo },
) => {
  myReportError(error, location, errorInfo);

  // make sure to still log the error so you can see it
  console.error(error, errorInfo);
};

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter onError={onError} />
    </StrictMode>,
  );
});

Data Mode

Data 모드에서는 RouterProvideronError 함수 전달

import { type ClientOnErrorFunction } from "react-router";

const onError: ClientOnErrorFunction = (
  error,
  { location, params, unstable_pattern, errorInfo },
) => {
  myReportError(error, location, errorInfo);

  // make sure to still log the error so you can see it
  console.error(error, errorInfo);
};

function App() {
  return <RouterProvider onError={onError} />;
}

6. Using Fetchers

Fetcher는 네비게이션 없이 복잡한 사용자 데이터 인터렉션을 구현할 때 사용한다.

Calling Actions

action으로 데이터 제출하고, revalidation 실행하기

import { useLoaderData } from "react-router";

export async function clientLoader({ request }) {
  let title = localStorage.getItem("title") || "No Title";
  return { title };
}

export default function Component() {
  let data = useLoaderData();
  return (
    <div>
      <h1>{data.title}</h1>
    </div>
  );
}
  1. fetcher가 호출할 action 추가
export async function clientAction({ request }) {
  await new Promise((res) => setTimeout(res, 1000));
  let data = await request.formData();
  localStorage.setItem("title", data.get("title"));
  return { ok: true };
}
  1. fetcher 생성
export default function Component() {
  let data = useLoaderData();
  let fetcher = useFetcher();
  return (
    <div>
      <h1>{data.title}</h1>

      <fetcher.Form method="post">
        <input type="text" name="title" />
      </fetcher.Form>
    </div>
  );
}
  1. 폼 제출
  2. pending UI 구현
<fetcher.Form method="post">
  <input type="text" name="title" />
  {fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
  1. optimistic UI 구현
let title = fetcher.formData?.get("title") || data.title;
  1. fetcher로 제출한 데이터 검증
export async function clientAction({ request }) {
  await new Promise((res) => setTimeout(res, 1000));
  let data = await request.formData();

  let title = data.get("title") as string;
  if (title.trim() === "") {
    return { ok: false, error: "Title cannot be empty" };
  }

  localStorage.setItem("title", title);
  return { ok: true, error: null };
}

{fetcher.data?.error && (
  <p style={{ color: "red" }}>
    {fetcher.data.error}
  </p>
)}

Loading Data

// { path: '/search-users', filename: './search-users.tsx' }
const users = [
  { id: 1, name: "Ryan" },
  { id: 2, name: "Michael" },
  // ...
];

export async function loader({ request }) {
  await new Promise((res) => setTimeout(res, 300));
  let url = new URL(request.url);
  let query = url.searchParams.get("q");
  return users.filter((user) =>
    user.name.toLowerCase().includes(query.toLowerCase()),
  );
}
  1. fetcher 생성
export function UserSearchCombobox() {
  let fetcher = useFetcher();
  return (
    <div>
      <fetcher.Form method="get" action="/search-users">
        <input type="text" name="q" />
      </fetcher.Form>
    </div>
  );
}
  1. 타입 적용
import type { loader } from "./search-users";

let fetcher = useFetcher<typeof loader>();
  1. 데이터 렌더링
{fetcher.data && (
  <ul>
    {fetcher.data.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
)}
  1. pending UI 구현
  {fetcher.data && (
    <ul
      style={{
        opacity: fetcher.state === "idle" ? 1 : 0.25,
      }}
    >
      {fetcher.data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )}
  1. 사용자 입력값에 따라 검색
<fetcher.Form method="get" action="/search-users">
  <input
    type="text"
    name="q"
    onChange={(event) => {
      fetcher.submit(event.currentTarget.form);
    }}
  />
</fetcher.Form>

7. File Route Conventions (파일 기반 라우팅)

세팅

  1. @react-router/fs-routes 설치
  2. app/routes.ts에 적용
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes() satisfies RouteConfig;
  1. app/routes 폴더의 모든 파일들이 라우트가 됨. 라우트에서 제외하기 위해서는 ignoredRouteFiles 옵션 지정
export default flatRoutes({
  ignoredRouteFiles: ["home.tsx"],
}) satisfies RouteConfig;
  1. app/routes 폴더 말고 다른 폴더를 사용하고 싶다면 rootDirectory 옵션 지정
export default flatRoutes({
  rootDirectory: "file-routes",
}) satisfies RouteConfig

기본 사용 방법

rootDirectory로 지정한 폴더의 파일들의 이름이 각 라우트의 URL의 pathname을 구성한다.

단, _index.tsx 파일은 예외적으로 pathname이 없고 index 라우트가 된다.

  • app/routes/_index.tsx/
  • app/routes/about.tsx/about

점찍기

파일명에 점(.)을 찍으면 URL에서 /가 된다.

  • apps/routes/concerts.trending.tsx/concerts/trending

Dynamic Segments

segment 앞에 $를 붙이면 동적 segment가 되어 모든 문자열에 매칭될 수 있다.

  • app/routes/concerts.$city.tsx/concerts/salt-lake-city, /concerts/san-diego

동적 segment 값은 URL에서 추출되어 다양한 API에 전달된다.

  • $city.tsxparams.city
export async function loader({ params }) {
  return fakeDb.getAllConcertsForCity(params.city);
}

Nested Routes

파일명의 . 앞부분이 다른 파일명과 일치한다면 해당 파일의 자식 라우트가 된다.

  • app/routes/concerts.tsx → 부모 라우트(Layout)
  • app/routes/concerts._index.tsx → 자식 라우트
  • app/routes/concerts.trending.tsx → 자식 라우트
  • app/routes/concerts.$city.tsx → 자식 라우트

Nested URLs without Layout Nesting

Layout 없이 경로만 중첩시키고 싶다면 부모 segment 뒤에 _를 붙이면 된다.

  • app/routes/concerts.trending.tsx → 부모 라우트: app/routes/concerts.tsx
  • app/routes/concerts_.mine.tsx → 부모 라우트: app/root.tsx

Nested Layouts without Nested URLs (Pathless Routes)

Layout은 적용하지만 경로는 추가하지 않고 싶다면 부모 segment 앞에 _를 붙이면 된다.

  • app/routes/_auth.login.tsx → 부모 라우트: app/routes/_auth.tsx, 경로: /login

Optional Segments

괄호로 segment를 감싸면 optional segment가 된다.

  • app/routes/($lang)._index.tsx/, /american-flag-speedo
  • app/routes/($lang).categories.tsx/categories, /en/categories
  • app/routes/($lang).$productId.tsx/en/american-flag-speedo

⚠️ /american-flag-speedo($lang).$productId.tsx가 아니라 ($lang)._index.tsx에 매칭되는 이유는 React Router가 경로를 탐욕적으로 매칭하기 때문이다. 따라서 의도하는 대로 동작하게 하기 위해서는 loader에서 params.langko, en등의 언어 코드가 맞는지 검증하고 아니라면 올바른 경로로 리다이렉트하는 방법을 사용해야 한다.

Splat Routes

$를 사용하면 여러 개의 경로 segment와 매칭할 수 있다.

  • app/routes/$.tsx/beef/and/cheese, /other-things
  • app/routes/files.$.tsx/files, /files/talks/react-conf_old.pdf

splat route에 매칭된 경로는 params["*"]로 접근할 수 있다.

Catch-all Route

rootDirectory 바로 아래에 $.tsx 파일을 생성하면 다른 모든 라우트들과 매칭되지 않는 요청을 처리할 수 있다. 404 처리에 유용하다.

// app/routes/$.tsx
export async function loader() {
  return data({}, 404);
}

Escaping Special Characters

지금까지 살펴본 file route convention에서 사용하는 예약어들을 경로에 그대로 포함하고 싶다면 해당 단어를 []로 감싸면 된다.

  • app/routes/sitemap[.]xml.tsx/sitemap.xml
  • app/routes/weird-url.[_index].tsx/weird-url/_index

Folders for Organization

기본적으로는 파일 이름이 곧 경로가 되는 방식이지만, 해당 라우트에서만 사용하는 컴포넌트나 유틸 함수 등을 한 곳에서 관리하기 위해서 라우트 폴더 기능을 제공한다. 라우트 이름으로 된 폴더를 생성하고, 그 안에 생성한 route.tsx 파일이 route module 파일이 된다. 그리고 route.tsx가 아닌 나머지 파일들은 라우트가 되지 않기 때문에 자유롭게 활용할 수 있다.

기존 방식

  • apps/routes/_landing.about.tsx

라우트 폴더 방식

  • apps/routes/_landing.about/
    • route.tsx: 라우트 파일
    • employee-profile-card.tsx: 이 페이지에서만 사용하는 컴포넌트
    • get-employee-data.server.ts: 이 페이지에서만 사용하는 서버 로직
    • team-photo.jpg: 이 페이지에서만 사용하는 이미지

8. File Uploads

기본 파일 업로드 구현

@remix-run/form-data-parser 설치 후 라우트 모듈에서 parseFormDatauploadHandler 사용하기

npm i @remix-run/form-data-parser
import {
  type FileUpload,
  parseFormData,
} from "@remix-run/form-data-parser";
import type { Route } from "./+types/user-profile";

export async function action({
  request,
}: Route.ActionArgs) {
  const uploadHandler = async (fileUpload: FileUpload) => {
    if (fileUpload.fieldName === "avatar") {
      // process the upload and return a File
    }
  };

  const formData = await parseFormData(
    request,
    uploadHandler,
  );
  // 'avatar' has already been processed at this point
  const file = formData.get("avatar");
}

export default function Component() {
  return (
    <form method="post" encType="multipart/form-data">
      <input type="file" name="avatar" />
      <button>Submit</button>
    </form>
  );
}

Local Storage를 활용한 파일 업로드 구현

LocalFileStorage 객체 생성 후 uploadHandler 구현

loader에서 local storage에 저장된 파일을 불러와서 사용할 수 있음

npm i @remix-run/file-storage
// 라우트 모듈과 다른 파일!
import { LocalFileStorage } from "@remix-run/file-storage/local";

export const fileStorage = new LocalFileStorage(
  "./uploads/avatars",
);

export function getStorageKey(userId: string) {
  return `user-${userId}-avatar`;
}
// 파일을 업로드할 페이지의 라우트 모듈의 action
async function uploadHandler(fileUpload: FileUpload) {
  if (
    fileUpload.fieldName === "avatar" &&
    fileUpload.type.startsWith("image/")
  ) {
    let storageKey = getStorageKey(params.id);

    // FileUpload objects are not meant to stick around for very long (they are
    // streaming data from the request.body); store them as soon as possible.
    await fileStorage.set(storageKey, fileUpload);

    // Return a File for the FormData object. This is a LazyFile that knows how
    // to access the file's content if needed (using e.g. file.stream()) but
    // waits until it is requested to actually read anything.
    return fileStorage.get(storageKey);
  }
}
// 파일을 다운로드할 페이지의 라우트 모듈의 loader
import {
  fileStorage,
  getStorageKey,
} from "~/avatar-storage.server";
import type { Route } from "./+types/avatar";

export async function loader({ params }: Route.LoaderArgs) {
  const storageKey = getStorageKey(params.id);
  const file = await fileStorage.get(storageKey);

  if (!file) {
    throw new Response("User avatar not found", {
      status: 404,
    });
  }

  return new Response(file.stream(), {
    headers: {
      "Content-Type": file.type,
      "Content-Disposition": `attachment; filename=${file.name}`,
    },
  });
}

9. Form Validation

action 내부에서 if문으로 분기처리하여 validation

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));

  const errors = {};

  if (!email.includes("@")) {
    errors.email = "Invalid email address";
  }

  if (password.length < 12) {
    errors.password =
      "Password should be at least 12 characters";
  }

  if (Object.keys(errors).length > 0) {
    return data({ errors }, { status: 400 });
  }

  // Redirect to dashboard if validation is successful
  return redirect("/dashboard");
}

10. HTTP Headers

라우트 모듈에서 headers를 export하거나 entry.server.tsx에서 헤더를 정의할 수 있다.

라우트 모듈에서 헤더 정의하기

  1. headers 에서 직접 정의
import { Route } from "./+types/some-route";

export function headers(_: Route.HeadersArgs) {
  return {
    "Content-Security-Policy": "default-src 'self'",
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Cache-Control": "max-age=3600, s-maxage=86400",
  };
}
  1. loader 정의
export async function loader({ params }: LoaderArgs) {
  let [page, ms] = await fakeTimeCall(
    await getPage(params.id),
  );

  return data(page, {
    headers: {
      "Server-Timing": `page;dur=${ms};desc="Page query"`,
    },
  });
}
  1. headers에서 actionloader의 헤더 사용
function hasAnyHeaders(headers: Headers): boolean {
  return [...headers].length > 0;
}

export function headers({
  actionHeaders,
  loaderHeaders,
}: HeadersArgs) {
  return hasAnyHeaders(actionHeaders)
    ? actionHeaders
    : loaderHeaders;
}
  1. 부모 라우트의 헤더 사용
    부모 라우트와 자식 라우트 모두에서 헤더를 설정할 경우, 자식 라우트에서는 자식 라우트에서 정의한 헤더만 사용하게 된다. 따라서 부모 라우트의 헤더까지 모두 사용하기를 원할 경우 headers에서 합쳐야 한다.
// append - override X
export function headers({ parentHeaders }: HeadersArgs) {
  parentHeaders.append(
    "Permissions-Policy: geolocation=()",
  );
  return parentHeaders;
}

// set - override O
export function headers({ parentHeaders }: HeadersArgs) {
  parentHeaders.set(
    "Cache-Control",
    "max-age=3600, s-maxage=86400",
  );
  return parentHeaders;
}

entry.server.tsx에서 헤더 정의하기

handleRequest 함수를 정의한다.

export default async function handleRequest(
  request,
  responseStatusCode,
  responseHeaders,
  routerContext,
  loadContext,
) {
  // set, append global headers
  responseHeaders.set(
    "X-App-Version",
    routerContext.manifest.version,
  );

  return new Response(await getStream(), {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

11. Instrumentation

⚠️ instrumentation 기능은 아직 실험 단계이다!

instrumentation이란?

instrumentation은 어플리케이션의 내부 동작을 관찰할 수 있게 해주는 API이다. 기존의 비즈니스 로직이나 라우터 핸들러 코드를 변경하지 않고도 로깅, 에러 리포트, 성능 측정 등을 수행할 수 있다. instrumentation의 중요한 원칙은 read-only라는 점이다. 런타임에 어떤 동작이 발생하는지 관찰만 할 수 있고, 런타임의 동작을 변경하는 것은 불가능하다.

구현 방법

  • handler level (server): 서버로 들어오는 모든 요청을 최상위 레벨에서 감시한다.
export const unstable_instrumentations = [
  {
    handler(handler) {
      handler.instrument({
        async request(handleRequest, { request, context }) {
          // Runs around ALL requests to your app
          await handleRequest();
        },
      });
    },
  },
];
  • router level (client): navigation이나 fetcher 호출 같은 클라이언트 측 라우터 동작을 감시한다.
export const unstable_instrumentations = [
  {
    router(router) {
      router.instrument({
        async navigate(callNavigate, { to, currentUrl }) {
          // Runs around navigation operations
          await callNavigate();
        },
        async fetch(
          callFetch,
          { href, currentUrl, fetcherKey },
        ) {
          // Runs around fetcher operations
          await callFetch();
        },
      });
    },
  },
];

// Framework Mode (entry.client.tsx)
<HydratedRouter
  unstable_instrumentations={unstable_instrumentations}
/>;

// Data Mode
const router = createBrowserRouter(routes, {
  unstable_instrumentations,
});
  • route level (server + client): 각 라우트의 동작을 감시한다.
const unstable_instrumentations = [
  {
    route(route) {
      route.instrument({
        async loader(
          callLoader,
          { params, request, context, unstable_pattern },
        ) {
          // Runs around loader execution
          await callLoader();
        },
        async action(
          callAction,
          { params, request, context, unstable_pattern },
        ) {
          // Runs around action execution
          await callAction();
        },
        async middleware(
          callMiddleware,
          { params, request, context, unstable_pattern },
        ) {
          // Runs around middleware execution
          await callMiddleware();
        },
        async lazy(callLazy) {
          // Runs around lazy route loading
          await callLazy();
        },
      });
    },
  },
];

Error Handling

instrumentation 코드가 런타임 동작에 영향을 끼치지 않기 위해 에러는 내부적으로 처리되고 밖으로 전달되지 않는다.

  1. lodaer, action, request handler, navigation 등의 핸들러 함수에서 에러가 발생할 경우 instrumentation의 callHandler 함수에서는 에러가 발생하지 않는다. 대신 callHandler 함수는 { type: "success", error: undefined } | { type: "error", error: unknown } 형태의 결과를 반환한다.
  2. instrumentation 함수 내부에서 에러가 발생하더라도, react router가 자체적으로 에러를 흡수하여 실제 앱의 동작이나 다른 instrumentation 함수의 실행에는 영향을 주지 않는다.

Composition

여러 개의 instrumentation 함수를 배열을 사용해서 병합할 수 있다. 앞의 instrumentation을 뒤의 instrumentation이 래핑하게 된다.

export const unstable_instrumentations = [
  loggingInstrumentation,
  performanceInstrumentation,
  errorReportingInstrumentation,
];

Conditional Instrumentation

  • 외부에서 분기처리하기
export const unstable_instrumentations =
  process.env.NODE_ENV === "production"
    ? [productionInstrumentation]
    : [developmentInstrumentation];
  • 내부에서 분기처리하기
export const unstable_instrumentations = [
  {
    route(route) {
      // Only instrument specific routes
      if (!route.id?.startsWith("routes/admin")) return;

      // Or, only instrument if a query parameter is present
      let sp = new URL(request.url).searchParams;
      if (!sp.has("DEBUG")) return;

      route.instrument({
        async loader() {
          /* ... */
        },
      });
    },
  },
];

12. Middleware

미들웨어는 response 생성 전후에 코드를 실행하는 기능으로, 인증, 로깅, 에러 핸들링, 데이터 전처리 등의 작업을 수행할 수 있게 해준다.

미들웨어는 체이닝 실행이 가능하며 response 생성 전에는 부모 라우트에서 자식 라우트로 내려가면서 실행되고 response 생성 후에는 자식 라우트에서 부모 라우트로 올라가면서 실행된다.

- Root middleware start
  - Parent middleware start
    - Child middleware start
      - Run loaders, generate HTML Response
    - Child middleware end
  - Parent middleware end
- Root middleware end

middleware 설정하기

  1. react-router.config.ts에서 middleware flag 활성화

⚠️ Framework 모드에서는 getLoadContext 함수와 loader/action의 context 파라미터에 마이너한 변경이 생겼기 때문에 future.v8_middleware 플래그를 활성화해야한다.

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

export default {
  future: {
    v8_middleware: true,
  },
} satisfies Config;
  1. createContext 함수를 사용하여 미들웨어 체인에 전달할 데이터를 담은 context 생성
import { createContext } from "react-router";
import type { User } from "~/types";

export const userContext = createContext<User | null>(null);
  1. 라우트 모듈에서 middleware export
// Server-side Authentication Middleware
async function authMiddleware({ request, context }) {
  const user = await getUserFromSession(request);
  if (!user) {
    throw redirect("/login");
  }
  context.set(userContext, user);
}

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

// Client-side timing middleware
async function timingMiddleware({ context }, next) {
  const start = performance.now();
  await next();
  const duration = performance.now() - start;
  console.log(`Navigation took ${duration}ms`);
}

export const clientMiddleware: Route.ClientMiddlewareFunction[] =
  [timingMiddleware];

Server 미들웨어와 Client 미들웨어의 차이

Server Middleware

  • HTML 문서 요청과 페이지 이동 또는 fetcher 실행 시 발생하는 .data 요청 시 실행됨
  • next 함수가 Response를 반환함
async function serverMiddleware({ request }, next) {
  console.log(request.method, request.url);
  let response = await next();
  console.log(response.status, request.method, request.url);
  return response;
}

Client Middleware

  • 페이지 이동 또는 featcher 실행 시 실행됨
  • next 함수가 dataStrategy의 결과값을 반환함
async function clientMiddleware({ request }, next) {
  console.log(request.method, request.url);
  await next();
}

server 미들웨어의 실행 시점

  • HTML 문서 요청 시에는 loader 유무에 상관없이 서버 미들웨어 실행
  • client-side 네비게이션 시에는 action/loder로 인해 생성된 .data 요청이 있을 경우에만 서버 미들웨어 실행

❗ 데이터는 필요하지 않지만 인증 등의 이유로 서버 미들웨어를 꼭 거쳐야 하는 경우에는 내용이 없는 loader를 추가하여 서버 요청을 강제할 수 있다.

function authMiddleware({ request }, next) {
  if (!isLoggedIn(request)) {
    throw redirect("/login");
  }
}

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

// By adding a `loader`, we force the `authMiddleware` to run on every
// client-side navigation involving this route.
export async function loader() {
  return null;
}

client 미들웨어의 실행 시점

  • loader의 유무에 상관없이 client-side 네비게이션 시 무조건 실행

middleware context

  • 기존 방식: context.user = user 처럼 자유롭게 속성을 추가하는 방식으로, 오타가 나거나 데이터 타입을 보장받기 어려움
  • 새로운 Context API 방식: createContext로 컨텍스트를 생성하고 context.setcontext.get 메서드를 사용하여 타입 안정성 향상
// ✅ Type-safe
import { createContext } from "react-router";
const userContext = createContext<User>();

// Later in middleware/`loader`s
context.set(userContext, user); // Must be `User` type
const user = context.get(userContext); // Returns `User` type

// ❌ Old way (no type safety)
context.user = user; // Could be anything

Node.js 환경(및 Bun, Deno, Cloudflare 등)에서 제공하는 AsyncLocalStorage를 미들웨어와 함께 사용하여 context를 인자로 넘기지 않고도 비동기 흐름 전반에서 context 데이터에 접근할 수 있음

// 미들웨어에서 실행 흐름(run)을 감싸면
export const middleware = [
  async ({ request }, next) => {
    return USER_STORAGE.run(user, () => next());
  },
];

// 하위 어디서든(로더든 서버 컴포넌트든) 호출 가능
export async function loader() {
  const user = USER_STORAGE.getStore(); 
}

next() 함수

실행 순서 제어

  • next() 호출 전: 핸들러가 실행되기 전에 수행할 작업 (예: 인증 체크, 컨텍스트 설정).
  • next() 호출 후: 핸들러(로더/액션)가 실행된 후 결과물(Response)을 가지고 수행할 작업 (예: 로그 기록, 커스텀 헤더 추가).

미들웨어의 위치에 따른 next 함수 동작

  • leaf가 아닌 미들웨어에서 next() 실행: 다음 미들웨어 실행
  • leaf 미들웨어에서 next() 실행: route 핸들러를 실행하고 request의 Response를 생성하여 반환한다.

next 함수 생략하기

  • next 함수 호출 후 실행할 코드가 없다면 next() 호출을 명시적으로 작성하지 않아도 React Router가 알아서 next 함수를 실행해 준다.

Error handling

  • middleware에서 에러가 던져져도 next 함수는 중단되지 않고 에러 정보가 담긴 Response 객체를 반환한다.
  • next() 호출 전에 에러가 발생하면 loader가 아직 호출되지 않은 상태이므로 loader가 있는 가장 가까운 상위 라우트의 ErrorBoundary를 찾는다.
  • next() 호출 후에 에러가 발생하면 이미 loader 실행이 완료된 상태이므로 일반적인 loader 에러처럼 해당 라우트의 ErrorBoundary를 찾는다.

13. Navigation Blocking

사용자가 폼 입력 도중 페이지를 나가는 것을 방지하기 위해 useBlocker를 사용할 수 있다.

  1. form을 사용한 route 생성하기
import { useFetcher } from "react-router";
import type { Route } from "./+types/contact";

export async function action({
  request,
}: Route.ActionArgs) {
  let formData = await request.formData();
  let email = formData.get("email");
  let message = formData.get("message");
  console.log(email, message);
  return { ok: true };
}

export default function Contact() {
  let fetcher = useFetcher();

  return (
    <fetcher.Form method="post">
      <p>
        <label>
          Email: <input name="email" type="email" />
        </label>
      </p>
      <p>
        <textarea name="message" />
      </p>
      <p>
        <button type="submit">
          {fetcher.state === "idle" ? "Send" : "Sending..."}
        </button>
      </p>
    </fetcher.Form>
  );
}
  1. dirty state를 생성하고 onChange에서 관리하기
export default function Contact() {
  let [isDirty, setIsDirty] = useState(false);
  let fetcher = useFetcher();

  return (
    <fetcher.Form
      method="post"
      onChange={(event) => {
        let email = event.currentTarget.email.value;
        let message = event.currentTarget.message.value;
        setIsDirty(Boolean(email || message));
      }}
    >
      {/* existing code */}
    </fetcher.Form>
  );
}
  1. dirty 상태일 때 navigation blocking
let blocker = useBlocker(
  useCallback(() => isDirty, [isDirty]),
);
  1. blocked 상태일 때 confirm UI 보여주기
{blocker.state === "blocked" && (
  <div>
    <p>Wait! You didn't send the message yet:</p>
    <p>
      <button
        type="button"
        onClick={() => blocker.proceed()}
      >
        Leave
      </button>{" "}
      <button
        type="button"
        onClick={() => blocker.reset()}
      >
        Stay here
      </button>
    </p>
  </div>
)}
  1. 사용자가 폼 제출 시 blocker와 form 초기화하기
useEffect(() => {
  if (fetcher.data?.ok) {
    // clear the form in the effect
    formRef.current?.reset();
    if (blocker.state === "blocked") {
      blocker.reset();
    }
  }
}, [fetcher.data]);

14. Pre-rendering

pre-rendering(사전 렌더링)이란?

런타임이 아닌 빌드 타임에 미리 HTML을 생성해두는 것

pre-rendering 기본 설정

react-router.config.tsprerender: true 설정

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

export default {
  prerender: true,
} satisfies Config;

dynamic path로 설정된 라우트 pre-rendering 설정

prerender: true/blog/:slug 같은 동적 경로로 설정된 라우트에는 적용되지 않으므로 동적 경로에 적용하기 위해서는 pre-rendering을 수행할 경로의 목록을 지정한다.

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

let slugs = getPostSlugs();

export default {
  prerender: [
    "/",
    "/blog",
    ...slugs.map((s) => `/blog/${s}`),
  ],
} satisfies Config;

함수형 pre-rendering 설정

prerender 옵션에 경로 목록을 반환하는 함수를 전달하여 더 복잡한 설정을 처리할 수 있다.. prerender 함수의 인자로 전달되는 getStaticPaths를 사용하면 정적 경로들을 자동으로 가져올 수 있다.

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

export default {
  async prerender({ getStaticPaths }) {
    let slugs = await getPostSlugsFromCMS();
    return [
      ...getStaticPaths(), // "/" and "/blog"
      ...slugs.map((s) => `/blog/${s}`),
    ];
  },
} satisfies Config;

Concurrency

⚠️ prerender의 concurrency 기능은 아직 실험 단계이다!

원래 페이지들은 한 번에 하나씩 순차적으로 pre-rendering 되지만, concurrency 옵션을 사용하면 병렬적으로 처리하여 빌드 시간을 단축할 수 있다.

concurrency를 활성화하려면 prerender 설정을 객체로 확장하여, paths에 사전 렌더링할 경로 목록을 작성하고 unstable_concurrency에 동시 실행할 작업의 개수를 명시한다.

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

let slugs = getPostSlugs();

export default {
  prerender: {
    paths: [
      "/",
      "/blog",
      ...slugs.map((s) => `/blog/${s}`),
    ],
    unstable_concurrency: 4,
  },
} satisfies Config;

ssr:true 설정과 함께 pre-rendering하기

서버 렌더링되는 라우트와 사전 렌더링되는 라우트가 사용하는 API에는 차이가 없다. 사전 렌더링의 경우에는 서버로 요청이 오는 대신 빌드타임에 new Request()가 생성되고 서버에서와 같이 수행된다.

react-router build로 사전 렌더링된 결과물은 build/client 폴더에 저장된다.

  • [url].html: 첫 접근 시 사용하는 HTML 파일
  • [url].data: 클라이언트 네비게이션 시 사용하는 파일로, loader를 실행한 결과를 JSON 형식으로 저장한 것

ssr:false 설정과 함께 pre-rendering하기

SPA fallback 파일

  • 모든 페이지를 미리 만들어두지 않는 ssr:false 환경에서 사전 렌더링 되지 않은 경로로 접속했을 때 보여줄 fallback HTML 파일
  • 루트(/) 경로의 사전 렌더링 여부에 따라 Fallback 파일의 이름을 다르게 생성함
    • build/client/index.html: / 경로가 사전 렌더링 되지 않았을 경우
    • build/client/__spa-fallback.html: / 경로가 사전 렌더링 되었을 경우

Invalid exports

  • ssr:false일 경우: headers, action은 모든 라우트에서 사용 금지
  • ssr:false이면서 prerender 설정을 하지 않았을 경우: 루트 라우트에서만 loader 사용 허용
  • ssr:false이면서 prerender 설정을 했을 경우: prerender에 설정된 라우트에서만 loader 사용 허용
    • 단, prerender 라우트가 자식 라우트를 가지고 있을 경우 모든 자식 라우트도 사전 렌더링하거나 부모 라우트에서 clientLoader를 사용해야 함

15. Presets

presets는 외부 도구나 호스팅 환경과의 연동을 지원하기 위한 옵션으로, 아래 두 가지 작업이 가능하다.

  1. Configure: 사용자를 대신해 react router의 config를 채워주기 - reactRouterConfig
  2. Validate: 최종적으로 완성된 설정이 잘 동작하는지 검증하기 - reactRouterConfigResolved
// preset.ts

import type {
  Preset,
  ServerBundlesFunction,
} from "@react-router/dev/config";

const serverBundles: ServerBundlesFunction = ({
  branch,
}) => {
  const isAuthenticatedRoute = branch.some((route) =>
    route.id.split("/").includes("_authenticated"),
  );

  return isAuthenticatedRoute
    ? "authenticated"
    : "unauthenticated";
};

export function myCoolPreset(): Preset {
  return {
    name: "my-cool-preset",
    reactRouterConfig: () => ({ serverBundles }),
    reactRouterConfigResolved: ({ reactRouterConfig }) => {
      if (
        reactRouterConfig.serverBundles !== serverBundles
      ) {
        throw new Error("`serverBundles` was overridden!");
      }
    },
  };
}
// react-router.config.ts

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

export default {
  // ...
  presets: [myCoolPreset()],
} satisfies Config;

16. React Server Components (RSC)

React Router의 Framework 모드와 Data 모드에서는 React 19부터 도입된 React Server Component와 React Server Function 기능을 지원한다.

아래의 템플릿들을 사용하면 React Router에서의 RSC API들과 @vitejs/plugin-rsc 플러그인이 설정된 상태에서 시작할 수 있다.

  • npx create-react-router@latest --template remix-run/react-router-templates/unstable_rsc-framework-mode
  • npx create-react-router@latest --template remix-run/react-router-templates/unstable_rsc-data-mode-vite

RSC Framework Mode

  • RSC용 vite plugin인 unstable_reactRouterRSC, @vitejs/plugin-rsc을 사용한다.
  • 서버 빌드 파일(build/server/index.js)이 default request handler 함수를 export하게 되고 이를 createRequestListener 함수를 사용하여 표준 Node.js request listener로 변환할 수 있다.
  • loader/action에서 React element를 반환할 수 있고, 이 element들은 서버에서만 렌더링된다. 클라이언트용 기능을 사용하려면 “use client” 지시어를 사용한 client 모듈을 반환해야 한다.
  • route module에서 default export 대신 ServerComponent를 export하여 서버 컴포넌트를 렌더링할 수 있다.
  • “use server”“use client” 지시어와의 혼동을 피하기 위해 RSC Framework Mode에서는 파일명을 .server.ts, .client.ts로 짓지 않고 @vitejs/plugin-rsc에서 제공하는 "server-only” 또는 "client-only” import를 사용한다. RSC 모드로 마이그레이션 시 기존 파일명 규칙을 계속 사용하고 싶다면 vite-env-only 플러그인의 denyImports 설정을 사용해야 한다.
  • v3.1.1 이상의 @mdx-js/rollup 플러그인을 사용하면 MDX Route를 사용할 수 있다.
  • 커스텀 entry 파일을 생성하여 사용할 수 있다.
    • app/entry.rsc.ts (or .tsx) - Custom RSC server entry
    • app/entry.ssr.ts (or .tsx) - Custom SSR server entry
    • app/entry.client.tsx - Custom client entry
  • 아래의 옵션들은 RSC Framework Mode에서 지원되지 않는다.
    • buildEnd
    • prerender
    • presets
    • routeDiscovery
    • serverBundles
    • ssr: false (SPA Mode)
    • future.v8_splitRouteModules
    • future.unstable_subResourceIntegrity

RSC Data Mode

  • matchRSCServerRequest를 사용하여 inline으로 Route를 설정하거나 lazy() 옵션을 사용하여 Route를 설정할 수 있다.
// inline
matchRSCServerRequest({
  // ...other options
  routes: [{ path: "/", Component: Root }],
});

// route module (app/routes.ts)
import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";

export function routes() {
  return [
    {
      id: "root",
      path: "",
      lazy: () => import("./root/route"),
      children: [
        {
          id: "home",
          index: true,
          lazy: () => import("./home/route"),
        },
        {
          id: "about",
          path: "about",
          lazy: () => import("./about/route"),
        },
      ],
    },
  ] satisfies RSCRouteConfig;
  • 기본적으로 각 route의 default export가 서버 컴포넌트를 렌더링하게 된다.
  • “use server” 지시어를 사용하여 서버 함수를 정의할 수 있다.
  • “use client” 지시어를 사용하여 clientLoader, clientAction, shouldRevalidate를 사용할 수 있다.
  • 커스텀 프레임워크를 구축하여 사용할 경우 번들러를 RSC 친화적으로 설정할 수 있다.
    • entry.rsc.tsx: 요청에 맞는 라우트를 매칭하고, 결과물로 RSC Payload 생성
    • entry.ssr.tsx: 생성된 RSC Payload를 받아서 실제 사용자가 볼 수 있는 HTML로 변환
    • entry.browser.tsx: 서버에서 받은 HTML을 받아 Hydrate하고 이후의 상호작용을 처리

17. Resource Routes

일반적인 라우트와 달리 이미지, pdf, JSON 등의 데이터나 파일 자체를 응답으로 보내는 라우트 (서버 렌더링에서만 가능)

정의 방법

route module에서 default component를 export하지 않고 loader 또는 action만 export하기

import type { Route } from "./+types/pdf-report";

export async function loader({ params }: Route.LoaderArgs) {
  const report = await getReport(params.id);
  const pdf = await generateReportPDF(report);
  return new Response(pdf, {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}

주요 특징

  • link 방식: <a> 태그 또는 <Link reloadDocument>를 사용해야 함. 단순히 <Link>를 사용하면 React Router가 클라이언트 사이드 라우팅으로 처리하려고 하여 오류가 발생할 수 있음
  • HTTP 메서드 처리
    • GET: loader로 처리
    • POST, PUT, PATCH, DELETE: action으로 처리
  • 반환 타입
    • 외부 서비스에서 호출하는 API: 표준 Response 객체
    • 내부 UI의 fetcher<Form> 제출: data() 함수

Error Handling

  • 500 에러: throw new Error()와 같이 일반 에러를 던지면 서버의 handleError가 호출되고 500 응답 전송
  • 4xx/5xx 응답: throw new Response(...)return data(..., { status: 401 })처럼 응답 객체를 반환하면 이는 성공적인 응답 생성으로 간주되어 서버 에러 로그를 남기지 않고 해당 상태 코드를 그대로 전달
  • Error Boundary: 리소스 라우트가 UI 내의 fetcherForm을 통해 호출되었을 때만 에러가 발생하면 UI 상의 가장 가까운 ErrorBoundary가 활성화됨

18. Route Module Type Safety

React Router는 각 라우트 별로 URL 파라미터, Loader 데이터 등에 대한 타입을 자동으로 생성하여 타입 안정성을 강화함

설정 방법

  1. React Router는 앱의 루트에 있는 .react-router 디렉토리에 타입을 생성하므로 .react-router 디렉토리를 .gitignore에 추가
  2. tsconfig.json 설정
    1. include“.react-router/types/**/*” 추가
    2. compilerOptions.rootDirs[".", "./.react-router/types"] 추가 (상대 경로로 타입을 임포트할 수 있도록 현재 디렉토리(.)와 생성된 타입 디렉토리를 연결)
  3. CI 등에서 타입 체크를 수행할 때는 타입을 먼저 생성해야 함: react-router typegen && tsc
  4. AppLoadContext 타입 지정: 앱 전반의 컨텍스트 타입을 정의하려면 react-router 모듈을 확장하여 AppLoadContext 인터페이스를 정의
  5. 생성된 타입 자동 import (optional): tsconfing.jsoncompilerOptions.verbatimModuleSyntaxtrue로 설정하면 import { Route } from "./+types/my-route";import type { Route } from "./+types/my-route";로 자동 변환됨

동작 원리

React Router의 Vite 플러그인은 라우트 설정 파일(routes.ts)을 수정할 때마다 자동으로 타입을 생성하므로 react-router dev를 실행하면 항상 최신화된 라우트 타입을 사용할 수 있다.

이 설정을 완료하면 각 라우트 모듈에서 다음과 같이 타입을 가져와 사용할 수 있게 되어 잘못된 파라미터 접근이나 데이터 타입을 컴파일 시점에 방지할 수 있다.

import type { Route } from "./+types/my-route";

export function loader({ params }: Route.LoaderArgs) {
  // params에 대한 타입 추론이 가능해짐
}

19. Security

앱에 CSP(Content-Security-Policy)를 적용할 때 unsafe-inline 지시문을 피하기 위해 inline <script>nonce 속성을 추가해야 할 경우, React Router에서는 nonce 속성 추가를 위해 다음과 같은 API들을 제공한다.

❓CSP(Content-Security-Policy)란?

브라우저가 신뢰할 수 있는 출처의 리소스만 실행하도록 강제하여 XSS 등 악성 코드 주입 공격을 막는 웹 보안 정책

❓CSP의 nonce 속성이란?

원래 CSP는 unsafe-inline을 써서 모든 인라인 스크립트를 허용하거나, 아예 금지해야 한다. 하지만 프레임워크가 생성하는 필수 인라인 스크립트가 있을 때, 서버에서 매번 새로운 임의의 값인 nonce를 생성해 스크립트 태그에 넣어주고, CSP 헤더에도 같은 값을 보낸다. 이 때 브라우저는 nonce 값이 일치하는 스크립트를 안전하다고 판단하여 실행한다. 즉, nonce를 일회용 암호라고 생각하면 될 듯 하다.

20. Server Bundles

일반적으로 React Router는 서버 코드를 하나의 번들로 빌드하지만, react-router.config.ts에서 serverBundles 옵션을 사용하여 여러 개의 번들로 나누어 빌드할 수 있다.

설정 방법

serverBundles 함수에서 각 라우트가 속할 번들의 ID를 반환

  • branch: 루트 라우트에서 해당 라우트까지 도달하기까지의 라우트 배열 목록
  • branch의 각 라우트가 가진 속성
    • id: 라우트의 공유한 ID
    • path: 라우트 경로
    • file: 라우트 파일의 절대경로
    • index: index 라우트 여부
import type { Config } from "@react-router/dev/config";

export default {
  // ...
  serverBundles: ({ branch }) => {
    const isAuthenticatedRoute = branch.some((route) =>
      route.id.split("/").includes("_authenticated"),
    );

    return isAuthenticatedRoute
      ? "authenticated"
      : "unauthenticated";
  },
} satisfies Config;

Build manifest

빌드가 완료되면 React Router는 buildManifest 객체를 전달하여 buildEnd 훅을 실행한다. 이 함수를 통해 빌드가 정확하게 수행되었는지를 검사할 수 있다.

buildManifest 구조

  • serverBundles: 각 번들 ID와 해당 파일 경로 매핑
  • routeIdToServerBundleId: 특정 라우트 ID가 어떤 서버 번들에 속해 있는지에 대한 정보
  • routes: 전체 라우트 메타데이터

❓Build Manifest 파일이란?

빌드 프로세스 완료 후 생성되는 애플리케이션 자산(Assets)의 메타데이터를 담은 JSON 파일이다. 소스 코드의 라우트 ID와 실제 배포용 번들 파일 경로, 의존성 관계, 서버 번들 매핑 정보 등을 기록하여 런타임 시 서버가 요청에 맞는 리소스를 정확히 식별하고 서빙할 수 있도록 돕는 참조 데이터이다.

21. Singe Page App (SPA)

React Router에서의 SPA 모드

  • 일반적인 SPA: <div id="root"></div> 만 있는 진짜 거의 빈 HTML 사용
  • React Router의 SPA: 빌드타임에 root 라우트를 index.html로 빌드하여 더 많은 기능 사용 가능

설정하기

  1. react-router.config.ts에서 ssr: false로 설정
  2. root 라우트에서 HydreateFallback을 export하여 로딩 UI 제공
  3. root 라우트에서 loader를 export (optional)
  4. 일반 라우트에서 clientLoader, clientAction 사용 가능
  5. react-router build 실행 후 생성된 build/client 디렉토리를 static host에 배포 가능. 모든 경로에 대한 요청을 index.html로 연결해주는 기능이 없다면 설정 필요함

22. Status Codes

loader 또는 action에서 data() 함수를 반환하여 status code를 설정할 수 있으며, data() 함수를 사용하지 않을 경우에는 status code는 기본적으로 200으로 설정된다.

// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { data } from "react-router";
import { fakeDb } from "../db";

export async function action({
  request,
}: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get("title");
  if (!title) {
    return data(
      { message: "Invalid title" },
      { status: 400 },
    );
  }

  if (!projectExists(title)) {
    let project = await fakeDb.createProject({ title });
    return data(project, { status: 201 });
  } else {
    let project = await fakeDb.updateProject({ title });
    // the default status code is 200, no need for `data`
    return project;
  }
}

23. Streaming with Suspense

구현 방법

  1. loader에서 await 하지 않은 promise 반환하기
import type { Route } from "./+types/my-route";

export async function loader({}: Route.LoaderArgs) {
  // note this is NOT awaited
  let nonCriticalData = new Promise((res) =>
    setTimeout(() => res("non-critical"), 5000),
  );

  let criticalData = await new Promise((res) =>
    setTimeout(() => res("critical"), 300),
  );

  return { nonCriticalData, criticalData };
}
  1. loaderDataAwait을 사용하여 렌더링하기
export default function MyComponent({
  loaderData,
}: Route.ComponentProps) {
  let { criticalData, nonCriticalData } = loaderData;

  return (
    <div>
      <h1>Streaming example</h1>
      <h2>Critical data value: {criticalData}</h2>

      <React.Suspense fallback={<div>Loading...</div>}>
        <Await resolve={nonCriticalData}>
          {(value) => <h3>Non critical value: {value}</h3>}
        </Await>
      </React.Suspense>
    </div>
  );
}

❗ React 19 이후부터는 Await 대신 React.use를 사용할 수 있다.

<React.Suspense fallback={<div>Loading...</div>}>
  <NonCriticalUI p={nonCriticalData} />
</React.Suspense>

function NonCriticalUI({ p }: { p: Promise<string> }) {
  let value = React.use(p);
  return <h3>Non critical value {value}</h3>;
}

timeout 시간 설정

기본적으로 4950ms로 설정되어 있고, 이를 변경하기 위해서는 entry.server.tsx에서 streamTimeout을 export해야 한다.

export const streamTimeout = 10_000;

10_000처럼 써도 되나?

브라우저나 서버가 코드를 실행할 때는 이 언더바(_)를 완전히 무시하고 일반 숫자(10000)로 인식하기 때문에 성능이나 동작에는 영향을 주지 않는다고 한다!

24. Using handle

handle이란?

route module에서 export할 수 있는 사용자 적의 객체로, 임의의 데이터나 함수를 담을 수 있다.

handleuseMatches를 사용하여 동적 UI 구현하기

  1. handle 정의
// app/routes/parent.tsx
export const handle = {
  breadcrumb: () => <Link to="/parent">Some Route</Link>,
};

// app/routes/child.tsx
export const handle = {
  breadcrumb: () => (
    <Link to="/parent/child">Child Route</Link>
  ),
};
  1. useMatches 훅에서 handle정보를 가져와 렌더링하기
// app/root.tsx
export function Layout({ children }) {
  const matches = useMatches();

  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <ol>
            {matches
              .filter(
                (match) =>
                  match.handle && match.handle.breadcrumb,
              )
              .map((match, index) => (
                <li key={index}>
                  {match.handle.breadcrumb(match)}
                </li>
              ))}
          </ol>
        </header>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

25. View Transitions

View Transition API란?

브라우저가 이전 페이지의 스냅샷과 새 페이지의 스냅샷을 찍어 두 상태 사이를 부드러운 페이드(Fade)나 이동 애니메이션으로 연결해주는 브라우저 기본 API로, 기존의 복잡한 CSS 애니메이션 라이브러리 없이도 앱과 같은 매끄러운 전환 효과를 낼 수 있다.

React Router에서의 사용법

  1. Link에서 사용: <Link to="/about" viewTransition>About</Link>
  2. useNavigate에서 사용: navigate("/about", { viewTransition: true })
  3. render props 사용
<NavLink to={`/image/${idx}`} viewTransition>
  {({ isTransitioning }) => (
    <>
      <p
        style={{
          viewTransitionName: isTransitioning
            ? "image-title"
            : "none",
        }}
      >
        Image Number {idx}
      </p>
      <img
        src={src}
        style={{
          viewTransitionName: isTransitioning
            ? "image-expand"
            : "none",
        }}
      />
    </>
  )}
</NavLink>
  1. useViewTransitionState 훅 사용
function NavImage(props: { src: string; idx: number }) {
  const href = `/image/${props.idx}`;
  // Hook provides transition state for specific route
  const isTransitioning = useViewTransitionState(href);

  return (
    <Link to={href} viewTransition>
      <p
        style={{
          viewTransitionName: isTransitioning
            ? "image-title"
            : "none",
        }}
      >
        Image Number {props.idx}
      </p>
      <img
        src={props.src}
        style={{
          viewTransitionName: isTransitioning
            ? "image-expand"
            : "none",
        }}
      />
    </Link>
  );
}
profile
프론트엔드 개발자

0개의 댓글