
root.tsxroot 라우트(app/root.tsx)는 유일하게 필수로 사용해야 하는 라우트이다. 모든 라우트의 최상위 부모 라우트이며 <html> 문서를 렌더링하는 역할을 하기 때문이다.
import { Outlet, Scripts } from "react-router";
import "./global-styles.css";
export default function App() {
return (
<html lang="en">
<head>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
따라서 root 라우트는 전체 HTML 문서를 관리하는 곳이기 때문에 document 레벨의 핵심 컴포넌트들을 렌더링하기에 적합한 위치이다. 이 핵심 컴포넌트들은 전체 어플리케이션에서 딱 한 번만 렌더링되어야 하며 페이지들이 제대로 렌더링되기 위해 React Router를 구축하는 데 필요한 것들을 포함한다.
핵심 컴포넌트 목록
LinkMetaScriptsScollRestoration만약 React 19를 사용중이지 않거나 React의 <link>, <title>, <meta> 컴포넌트를 사용하지 않는다면 각 라우트에서 link와 meta export를 사용하기보다는 root 라우트에서 <Link>와 <Meta> 컴포넌트를 사용하는 것이 좋다.
import { Outlet, Links, Meta, Scripts, ScrollRestoration } from "react-router";
export default function App() {
return (
<html lang="en">
<head>
{/* All `meta` exports on all routes will render here */}
<Meta />
{/* All `link` exports on all routes will render here */}
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
Layout exportroot 라우트에서는 추가로 Layout을 사용할 수 있고, 목적은 크게 두 가지다.
HydrateFallback, ErrorBoundary 에 app의 shell이 중복으로 생성되는 것을 피하기HydreateFallback, ErrorBoundary 가 교체될 때 다시 mount되는 것을 피하기 (<link rel="stylesheet">가 제거되었다가 다시 추가되면 FOUC가 발생할 수 있음)❓ app shell 이란?
변하지 않고 항상 유지되는 app의 기본 골격을 의미한다. 즉 root Layout에서 렌더링하는 것들은 모든 페이지에서 동일하게 유지되므로 root Layout 그 자체가 app shell이라고 이해해도 될 것 같다.
Layout은 유일한 prop으로 children을 받고, children은 root 라우트의 default export나 HydreateFallback, ErrorBoundary가 될 수 있다.
export function Layout({ children }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<Meta />
<Links />
</head>
<body>
{/* children will be the root Component, ErrorBoundary, or HydrateFallback */}
{children}
<Scripts />
<ScrollRestoration />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary() {}
Layout 컴포넌트에서 useLoaderData 사용 시 주의할 점useLoaderData 는 데이터가 성공적으로 로드되었을 때 사용되는 것을 가정하므로 ErrorBoundary 내부에서는 사용되어서는 안 된다. 대신 반환 값이 undefined일 수 있는 useRouteLoaderData를 사용해야 한다. Layout 컴포넌트는 성공/실패 케이스 모두에서 사용될 수 있어 동일한 제약을 가지므로 useRouteLoaderData(’root’) 또는 useRouteError()를 사용해야 한다.
routes.tsURL 패턴을 route module과 매칭하기 위해 사용하는 설정 파일이다.
import {
type RouteConfig,
route,
} from "@react-router/dev/routes";
export default [
route("some/path", "./some/file.tsx"),
// pattern ^ ^ module file
] satisfies RouteConfig;
route entry 설정을 위해 다음과 같은 helper 함수들을 사용할 수 있다.
route: 일반 route entry 생성index: index 라우트용 route entry 생성layout: layout 라우트용 route entry 생성prefix: 별도의 부모 라우트를 적용하지 않고 여러 라우트에 prefix 경로를 추가relative: 특정 경로를 기준으로 파일 경로 해석만약 설정 파일보다는 파일 구조와 이름으로 라우트를 설정하기를 원한다면 @react-router/fs-routes를 사용하면 된다.
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig;
react-router.config.tsReact Router 프레임워크의 전반적인 동작 방식을 설정하는 파일이다. SSR 사용 여부, 폴더 구조, 빌드 설정 등을 커스터마이징할 수 있다.
allowedActionOrigins: action 함수를 제출할 수 있는 도메인 주소 목록을 설정한다. micromatch glob 패턴을 지원하여 와일드카드(*, **)를 사용하여 도케인 주소를 작성할 수 있다. 런타임에 이 값을 설정하고 싶다면 서버를 빌드할 때 설정할 수 있다.appDirectory: React Router 프로젝트에서 실제 소스 코드가 들어있는 디렉토리의 경로 (Default: ‘app’)basename: 모든 라우트의 경로 앞에 들어가는 prefix 경로 (Default: ‘/’)buildDirectory: 빌드 결과물이 들어가는 디렉토리의 경로 (Default: 'build')buildEnd: 빌드 완료 후 실행할 함수future: 다음 버전의 기능들을 선택적으로 활성화할 수 있는 플래그prerender: 빌드 타임에 prerender할 URL 배열preset: 특정 플랫폼이나 환경에 앱의 설정을 맞춰주는 패키지 배열routeDiscovery: 라우트들이 어떤 방식으로 로드될지를 설정 (Default: mode: ‘lazy’, manifestPath: ‘/__manifest’)serverBuildFile: 서버 빌드 결과물 파일 이름으로, .js 형식으로 끝나야 함 (Default: ‘index.js’)serverBundles: 서버 빌드 결과물을 단일 파일이 아닌 여러 개의 번들로 나눌 때 사용serverModuleFormat: 서버 빌드 결과물의 포맷 (Default: ‘esm’)ssr: SSR 렌더링 여부 설정 (Default: true)entry.client.tsx브라우저에서의 앱의 진입점으로 hydration 과정을 담당한다. 브라우저에서 가장 먼저 실행되는 코드이며 여기에서 다른 클라이언트용 코드를 초기화할 수 있다.
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
기본적으로 React Router는 이 과정을 내부적으로 처리하므로 파일이 보이지 않지만, 직접 수정하고 싶다면 npx react-router reveal 명령어를 실행해 숨겨진 파일을 보이게 할 수 있다.
entry.server.tsx서버에서의 앱의 진입점으로 HTTP 요청에 대한 응답을 제어하는 역할을 한다. 현재 요청의 context와 url을 사용하여 <ServerRouter>로 현재 페이지의 마크업을 렌더링하고, 이 마크업은 클라이언트 entry 모듈에 의해 hydreate된다.
⚠️ Node를 사용할 경우
entry.server.tsx파일은 필수가 아니고, 기본적으로 구현되어 있는 entry.server.node.tsx 파일을 사용하게 된다. Node가 아닌 다른 JS 런타임(예: Cloudfare)을 사용한다면entry.server.tsx파일을 반드시 사용해야 한다.
기본적으로 React Router는 이 과정을 내부적으로 처리하므로 파일이 보이지 않지만, 직접 수정하고 싶다면 npx react-router reveal 명령어를 실행해 숨겨진 파일을 보이게 할 수 있다.
이 모듈에서의 default export는 HTTP 응답을 생성하는 함수로, HTTP status, header, HTML 등 마크업이 생성되고 클라이언트에 전달되기까지의 모든 과정을 제어한다.
import { PassThrough } from "node:stream";
import type { EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { renderToPipeableStream } from "react-dom/server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<ServerRouter
context={routerContext}
url={request.url}
/>,
{
onShellReady() {
responseHeaders.set("Content-Type", "text/html");
const body = new PassThrough();
const stream =
createReadableStreamFromReadable(body);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
},
);
});
}
streamTimeout스트리밍을 사용하고 있을 경우, 서버가 완료되지 않은 스트림을 기다릴 시간을 streamTimeout으로 설정한다.
⚠️ 서버 응답을 기다리는 timeout 시간과, React 렌더링을 기다리는 timeout을 다르게 설정하는 방법이 권장된다. React 렌더링 timeout 시간을 더 길게 잡아야
streamTimeout으로 인해 거절된 서버 응답 에러 메세지들이 스트림을 타고 전달되기 때문이다.
// Reject all pending promises from handler functions after 10 seconds
export const streamTimeout = 10000;
export default function handleRequest(...) {
return new Promise((resolve, reject) => {
// ...
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{ /* ... */ }
);
// Abort the streaming render pass after 11 seconds to allow the rejected
// boundaries to be flushed
setTimeout(abort, streamTimeout + 1000);
});
}
handleDataRequest데이터 요청의 응답을 변경할 수 있는 함수이다. HTML을 렌더링하는 요청 말고, hydration 이후의 loader와 action 데이터를 반환하는 요청들에 사용할 수 있다.
export function handleDataRequest(
response: Response,
{
request,
params,
context,
}: LoaderFunctionArgs | ActionFunctionArgs,
) {
response.headers.set("X-Custom-Header", "value");
return response;
}
❓ 왜 hydration 이후의 요청들에만 적용 가능할까?
최초 접속 시에는 loader가 별도의 HTTP 요청으로 오지 않고, 서버가 만드는 HTML 내부에 포함되어 내려오기 때문이다.
handleError기본적으로 React Router는 server-side에서 발생한 에러를 콘솔에 출력한다. 만약 콘솔에 로그를 다르게 출력하고 싶거나 외부 서비스에 에러 로그를 전송하고 싶을 경우 handleError 함수를 사용할 수 있다.
export function handleError(
error: unknown,
{
request,
params,
context,
}: LoaderFunctionArgs | ActionFunctionArgs,
) {
if (!request.signal.aborted) {
sendErrorToErrorReportingService(error);
console.error(formatErrorForJsonLogging(error));
}
}
⚠️ React Router의 요청 취소 및 race condition 제어 로직이 많은 요청을 abort시킬 수 있기 때문에 요청이 abort되었을 때 로깅하는 것을 피하는 것이 좋다.
⚠️
renderToPipeableStream이나renderToReadableStream을 사용해 스트리밍으로 응답을 전송할 경우handleError는 첫 HTML shell 렌더링 중에 발생하는 에러까지만 감지할 수 있고, 그 뒤에 스트리밍 렌더링 중 발생하는 에러는 감지할 수 없으므로 개발자가 수동으로 처리해야 한다.
⚠️
handleError는loader나action에서 throw된Response객체를 다루지 않는다.handleError의 목적은 예상하지 못한 코드 상의 버그를 찾아내는 것이고, 401, 404 등의 응답을 throw하는 것은 앱의 정상적인 시나리오 중 하나로 간주되어 코드 내부에서 처리되어야 한다.
.client moduleswindow, document, localStorage 등 브라우저 API를 사용하는 파일들의 이름을 *.client.ts로 짓거나 .client 디렉토리 내부에 위치하게 하면 서버 번들에 포함되지 않도록 할 수 있다.
export const supportsVibrationAPI = "vibrate" in window.navigator;
클라이언트 모듈에서 export된 값들은 서버에서 접근할 경우 undefined가 된다.
import { supportsVibrationAPI } from "./feature-check.client.ts";
console.log(supportsVibrationAPI);
// server: undefined
// client: true | false
.server modules서버에서만 사용할 파일들의 이름을 *.server.ts로 짓거나 .server 디렉토리 내부에 위치하게 하면 클라이언트 번들에 포함되지 않도록 할 수 있다.
// This would expose secrets on the client if not exported from a server-only module
export const JWT_SECRET = process.env.JWT_SECRET;
export function validateToken(token: string) {
// Server-only authentication logic
}
클라이언트에서 서버 모듈로 선언된 파일의 코드를 사용한다면 빌드가 실패한다.
⚠️ Route module에는
.server나.client를 사용해서는 안 된다. Route module은 서버와 클라이언트에서 둘 다 사용되어야 한다.
HydreatedRouter는 ServerRouter에서 보내준 정적 페이지에 라우터를 hydrate하는 역할을 하며, 보통 entry.client.tsx 파일 안에서 사용된다.
props
getContext: 네비게이션이나 데이터 페칭이 발생할 때마다 context 인스턴스를 생성하는 context factory 함수이다. 생성된 context는 clientAction이나 clientLoader 함수에서 사용할 수 있다.onError: middleware, loader, action, 렌더링 등 앱 전체에서 발생하는 에러를 감지하는 error handler 함수이다. ErrorBoundary와 다르게 UI 리렌더링이 발생하지 않고 에러당 한 번 씩만 실행되므로 에러 로깅에 적합하다. 특히 errorInfo 파라미터는 componentDidCatch 함수에서 넘어와서 렌더링 시에만 존재하므로 렌더링 에러를 확인하기 적합하다.ServerRouter는 서버에서 HTML을 생성하는 역할을 하며, 보통 entry.server.tsx 파일 안에서 사용된다.
props
context: manifest, route module 등 렌더링에 필요한 모든 데이터url: 현재 서버가 처리 중인 요청 주소nonce: CSP를 지키기 위한 일회용 암호 값 (optional)