
React Router에서의 접근성은 일반적인 웹 접근성과 비슷하다. 시멘틱 HTML 태그를 사용하고 Web Content Accessibility Guidelines (WCAG)를 준수하는 것 만으로 대부분의 접근성 문제를 해결할 수 있다.
React Router가 접근성을 기본적으로 적용하는 부분도 있지만 그렇지 않은 경우에는 직접 사용할 수 있는 API를 제공한다.
<Link> 컴포넌트: 표준 a 태그를 렌더링하므로 브라우저가 기본적으로 제공하는 접근성 기능을 그대로 가져올 수 있다.<NavLink> 컴포넌트: <Link> 컴포넌트의 접근성 기능을 전부 제공하며, 추가로 현제 페이지의 경로가 링크의 경로와 일치할 경우 aria-current="page"를 자동으로 설정한다.따라서 React Router 사용 시 고려할 점
clientLoader와 clientAction을 사용하여 SSR에서 클라이언트 데이터를 활용하는 사례들
BFF를 사용할 경우에 React Router 서버를 거치지 않고 직접 백엔드 API와 통신하는 방법이다. 적절한 인증 처리와 CORS 처리가 있어야 가능하다.
loader를 실행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;
}
렌더링 전에 서버 데이터와 클라이언트 데이터를 합칠 수 있다.
loader에서 서버 데이터를 가져온다.HydreateFallback 컴포넌트를 export한다.clientLoader.hydrate = true로 설정하여 첫 접속(hydration) 시에도 clientLoader를 실행하게 한다.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 <>...</>;
}
각 라우트 별로 데이터를 서버에서 가져올지, 클라이언트에서 가져올지를 설정할 수 있다.
loader를 export한다.clinetLoader와 HydrateFallback을 export한다.브라우저 메모리나 localStorage 등을 사용해 서버 요청을 최소화할 수 있다.
loader에서 데이터를 가져온다.clientLoader.hydrate = true를 설정하고 clientLoader 내부에서 초기 데이터를 브라우저 캐시에 저장한다.clientLoader에 저장된 캐시를 사용한다.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;
}
⚠️
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 동작을 변경하고 싶은 경우 defaultShouldRevalidate를 shouldCallHandler에 전달한다.
const matchesToLoad = matches.filter((match) => {
let defaultShouldRevalidate = customShouldRevalidate(
match.shouldRevalidateArgs,
);
return m.shouldCallHandler(defaultShouldRevalidate);
});
에러 발생 시 route module은 에러를 자동으로 감지하여 가장 가까운 ErrorBoundary를 렌더링한다.
기본적으로 root route에서는 ErrorBoundary를 export해야 한다. 이 root ErrorBoundary는 아래의 세 가지 경우의 에러 상황을 처리한다.
Framework 모드에서는 root ErrorBoundary에 Route.ErrorBoundaryProps 타입의 prop이 전달되지만 Data 모드에서는 prop이 전달되지 않기 때문에 useRouteError 훅을 사용해 에러에 접근해야 한다.
❓ stack trace란?
에러 발생 시점까지의 함수 호출 경로를 기록한 리스트로, 버그가 어디서, 왜 발생했는지 찾는 디버깅의 핵심 도구이다. 보안상 실제 서비스 유저에게 노출되어서는 안 된다.
ErrorBoundary는 코드의 실수를 잡는 목적으로 사용되어야 하므로 의도적으로 에러 화면으로 보내기 위해 일부러 에러를 던지는 것은 권장되지 않는다. ErrorBoundary는 단순히 컴포넌트 내부의 에러만 잡는 것이 아니라, 모든 route module API에 적용된다.
단, 예외적으로 404 에러의 경우에는 적절한 status 코드와 함께 의도적으로 에러를 던져도 괜찮다.
Framework 모드의 운영 환경에서는 서버에서 발생한 에러들은 브라우저로 보내지기 전에 필터링되어 stact trace가 아닌 일반적인 메세지만 전달되게 된다. 서버의 파일 경로, DB 구조, 내부 로직 등의 민감한 정보가 노출되는 것을 막기 위해서이다.
throw Error 방식 : throw new Error("DB 연결 실패: 비밀번호 1234") → “An unexpected error occurred”로 필터링됨throw data 방식: throw data({ message: "사용자를 찾을 수 없습니다" }, { status: 404 }) → 개발자가 의도적으로 사용자에게 전달하려는 정보로 간주되어 필터링되지 않음react-router reveal entry.server를 실행하여 entry.server.tsx 파일 노출entry.server.tsx에서 handleError 함수 exportimport { 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);
}
};
react-router reveal entry.client를 실행하여 entry.client.tsx 파일 노출entry.client.tsx의 HydratedRouter 또는 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 모드에서는 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);
};
function App() {
return <RouterProvider onError={onError} />;
}
Fetcher는 네비게이션 없이 복잡한 사용자 데이터 인터렉션을 구현할 때 사용한다.
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>
);
}
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 };
}
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>
);
}
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
let title = fetcher.formData?.get("title") || data.title;
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>
)}
// { 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()),
);
}
export function UserSearchCombobox() {
let fetcher = useFetcher();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input type="text" name="q" />
</fetcher.Form>
</div>
);
}
import type { loader } from "./search-users";
let fetcher = useFetcher<typeof loader>();
{fetcher.data && (
<ul>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
{fetcher.data && (
<ul
style={{
opacity: fetcher.state === "idle" ? 1 : 0.25,
}}
>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(event) => {
fetcher.submit(event.currentTarget.form);
}}
/>
</fetcher.Form>
@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;
app/routes 폴더의 모든 파일들이 라우트가 됨. 라우트에서 제외하기 위해서는 ignoredRouteFiles 옵션 지정export default flatRoutes({
ignoredRouteFiles: ["home.tsx"],
}) satisfies RouteConfig;
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/trendingsegment 앞에 $를 붙이면 동적 segment가 되어 모든 문자열에 매칭될 수 있다.
app/routes/concerts.$city.tsx → /concerts/salt-lake-city, /concerts/san-diego 등동적 segment 값은 URL에서 추출되어 다양한 API에 전달된다.
$city.tsx → params.cityexport async function loader({ params }) {
return fakeDb.getAllConcertsForCity(params.city);
}
파일명의 . 앞부분이 다른 파일명과 일치한다면 해당 파일의 자식 라우트가 된다.
app/routes/concerts.tsx → 부모 라우트(Layout)app/routes/concerts._index.tsx → 자식 라우트app/routes/concerts.trending.tsx → 자식 라우트app/routes/concerts.$city.tsx → 자식 라우트Layout 없이 경로만 중첩시키고 싶다면 부모 segment 뒤에 _를 붙이면 된다.
app/routes/concerts.trending.tsx → 부모 라우트: app/routes/concerts.tsxapp/routes/concerts_.mine.tsx → 부모 라우트: app/root.tsxLayout은 적용하지만 경로는 추가하지 않고 싶다면 부모 segment 앞에 _를 붙이면 된다.
app/routes/_auth.login.tsx → 부모 라우트: app/routes/_auth.tsx, 경로: /login괄호로 segment를 감싸면 optional segment가 된다.
app/routes/($lang)._index.tsx → /, /american-flag-speedoapp/routes/($lang).categories.tsx → /categories, /en/categoriesapp/routes/($lang).$productId.tsx → /en/american-flag-speedo⚠️
/american-flag-speedo가($lang).$productId.tsx가 아니라($lang)._index.tsx에 매칭되는 이유는 React Router가 경로를 탐욕적으로 매칭하기 때문이다. 따라서 의도하는 대로 동작하게 하기 위해서는 loader에서params.lang이ko, en등의언어 코드가 맞는지 검증하고 아니라면 올바른 경로로 리다이렉트하는 방법을 사용해야 한다.
$를 사용하면 여러 개의 경로 segment와 매칭할 수 있다.
app/routes/$.tsx → /beef/and/cheese, /other-thingsapp/routes/files.$.tsx → /files, /files/talks/react-conf_old.pdfsplat route에 매칭된 경로는 params["*"]로 접근할 수 있다.
rootDirectory 바로 아래에 $.tsx 파일을 생성하면 다른 모든 라우트들과 매칭되지 않는 요청을 처리할 수 있다. 404 처리에 유용하다.
// app/routes/$.tsx
export async function loader() {
return data({}, 404);
}
지금까지 살펴본 file route convention에서 사용하는 예약어들을 경로에 그대로 포함하고 싶다면 해당 단어를 []로 감싸면 된다.
app/routes/sitemap[.]xml.tsx → /sitemap.xmlapp/routes/weird-url.[_index].tsx → /weird-url/_index기본적으로는 파일 이름이 곧 경로가 되는 방식이지만, 해당 라우트에서만 사용하는 컴포넌트나 유틸 함수 등을 한 곳에서 관리하기 위해서 라우트 폴더 기능을 제공한다. 라우트 이름으로 된 폴더를 생성하고, 그 안에 생성한 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: 이 페이지에서만 사용하는 이미지@remix-run/form-data-parser 설치 후 라우트 모듈에서 parseFormData와 uploadHandler 사용하기
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>
);
}
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}`,
},
});
}
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");
}
라우트 모듈에서 headers를 export하거나 entry.server.tsx에서 헤더를 정의할 수 있다.
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",
};
}
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"`,
},
});
}
headers에서 action과 loader의 헤더 사용function hasAnyHeaders(headers: Headers): boolean {
return [...headers].length > 0;
}
export function headers({
actionHeaders,
loaderHeaders,
}: HeadersArgs) {
return hasAnyHeaders(actionHeaders)
? actionHeaders
: loaderHeaders;
}
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;
}
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,
});
}
⚠️ instrumentation 기능은 아직 실험 단계이다!
instrumentation은 어플리케이션의 내부 동작을 관찰할 수 있게 해주는 API이다. 기존의 비즈니스 로직이나 라우터 핸들러 코드를 변경하지 않고도 로깅, 에러 리포트, 성능 측정 등을 수행할 수 있다. instrumentation의 중요한 원칙은 read-only라는 점이다. 런타임에 어떤 동작이 발생하는지 관찰만 할 수 있고, 런타임의 동작을 변경하는 것은 불가능하다.
export const unstable_instrumentations = [
{
handler(handler) {
handler.instrument({
async request(handleRequest, { request, context }) {
// Runs around ALL requests to your app
await handleRequest();
},
});
},
},
];
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,
});
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();
},
});
},
},
];
instrumentation 코드가 런타임 동작에 영향을 끼치지 않기 위해 에러는 내부적으로 처리되고 밖으로 전달되지 않는다.
callHandler 함수에서는 에러가 발생하지 않는다. 대신 callHandler 함수는 { type: "success", error: undefined } | { type: "error", error: unknown } 형태의 결과를 반환한다.여러 개의 instrumentation 함수를 배열을 사용해서 병합할 수 있다. 앞의 instrumentation을 뒤의 instrumentation이 래핑하게 된다.
export const unstable_instrumentations = [
loggingInstrumentation,
performanceInstrumentation,
errorReportingInstrumentation,
];
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() {
/* ... */
},
});
},
},
];
미들웨어는 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
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;
createContext 함수를 사용하여 미들웨어 체인에 전달할 데이터를 담은 context 생성import { createContext } from "react-router";
import type { User } from "~/types";
export const userContext = createContext<User | null>(null);
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 Middleware
.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
next 함수가 dataStrategy의 결과값을 반환함async function clientMiddleware({ request }, next) {
console.log(request.method, request.url);
await next();
}
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;
}
context.user = user 처럼 자유롭게 속성을 추가하는 방식으로, 오타가 나거나 데이터 타입을 보장받기 어려움createContext로 컨텍스트를 생성하고 context.set과 context.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() 호출 후: 핸들러(로더/액션)가 실행된 후 결과물(Response)을 가지고 수행할 작업 (예: 로그 기록, 커스텀 헤더 추가).미들웨어의 위치에 따른 next 함수 동작
next() 실행: 다음 미들웨어 실행next() 실행: route 핸들러를 실행하고 request의 Response를 생성하여 반환한다.next 함수 생략하기
next() 호출을 명시적으로 작성하지 않아도 React Router가 알아서 next 함수를 실행해 준다.Error handling
next() 호출 전에 에러가 발생하면 loader가 아직 호출되지 않은 상태이므로 loader가 있는 가장 가까운 상위 라우트의 ErrorBoundary를 찾는다.next() 호출 후에 에러가 발생하면 이미 loader 실행이 완료된 상태이므로 일반적인 loader 에러처럼 해당 라우트의 ErrorBoundary를 찾는다.사용자가 폼 입력 도중 페이지를 나가는 것을 방지하기 위해 useBlocker를 사용할 수 있다.
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>
);
}
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>
);
}
let blocker = useBlocker(
useCallback(() => isDirty, [isDirty]),
);
{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>
)}
useEffect(() => {
if (fetcher.data?.ok) {
// clear the form in the effect
formRef.current?.reset();
if (blocker.state === "blocked") {
blocker.reset();
}
}
}, [fetcher.data]);
런타임이 아닌 빌드 타임에 미리 HTML을 생성해두는 것
react-router.config.ts에 prerender: true 설정
import type { Config } from "@react-router/dev/config";
export default {
prerender: true,
} satisfies Config;
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;
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;
⚠️ 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 파일
/) 경로의 사전 렌더링 여부에 따라 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 사용 허용presets는 외부 도구나 호스팅 환경과의 연동을 지원하기 위한 옵션으로, 아래 두 가지 작업이 가능하다.
reactRouterConfigreactRouterConfigResolved// 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;
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-modenpx create-react-router@latest --template remix-run/react-router-templates/unstable_rsc-data-mode-viteunstable_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 모듈을 반환해야 한다.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 설정을 사용해야 한다.@mdx-js/rollup 플러그인을 사용하면 MDX Route를 사용할 수 있다.app/entry.rsc.ts (or .tsx) - Custom RSC server entryapp/entry.ssr.ts (or .tsx) - Custom SSR server entryapp/entry.client.tsx - Custom client entrybuildEndprerenderpresetsrouteDiscoveryserverBundlesssr: false (SPA Mode)future.v8_splitRouteModulesfuture.unstable_subResourceIntegritymatchRSCServerRequest를 사용하여 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;
“use server” 지시어를 사용하여 서버 함수를 정의할 수 있다.“use client” 지시어를 사용하여 clientLoader, clientAction, shouldRevalidate를 사용할 수 있다.entry.rsc.tsx: 요청에 맞는 라우트를 매칭하고, 결과물로 RSC Payload 생성entry.ssr.tsx: 생성된 RSC Payload를 받아서 실제 사용자가 볼 수 있는 HTML로 변환entry.browser.tsx: 서버에서 받은 HTML을 받아 Hydrate하고 이후의 상호작용을 처리일반적인 라우트와 달리 이미지, 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",
},
});
}
<a> 태그 또는 <Link reloadDocument>를 사용해야 함. 단순히 <Link>를 사용하면 React Router가 클라이언트 사이드 라우팅으로 처리하려고 하여 오류가 발생할 수 있음GET: loader로 처리POST, PUT, PATCH, DELETE: action으로 처리Response 객체fetcher나 <Form> 제출: data() 함수throw new Error()와 같이 일반 에러를 던지면 서버의 handleError가 호출되고 500 응답 전송throw new Response(...)나 return data(..., { status: 401 })처럼 응답 객체를 반환하면 이는 성공적인 응답 생성으로 간주되어 서버 에러 로그를 남기지 않고 해당 상태 코드를 그대로 전달fetcher나 Form을 통해 호출되었을 때만 에러가 발생하면 UI 상의 가장 가까운 ErrorBoundary가 활성화됨React Router는 각 라우트 별로 URL 파라미터, Loader 데이터 등에 대한 타입을 자동으로 생성하여 타입 안정성을 강화함
.react-router 디렉토리에 타입을 생성하므로 .react-router 디렉토리를 .gitignore에 추가tsconfig.json 설정include에 “.react-router/types/**/*” 추가compilerOptions.rootDirs에 [".", "./.react-router/types"] 추가 (상대 경로로 타입을 임포트할 수 있도록 현재 디렉토리(.)와 생성된 타입 디렉토리를 연결)react-router typegen && tscAppLoadContext 타입 지정: 앱 전반의 컨텍스트 타입을 정의하려면 react-router 모듈을 확장하여 AppLoadContext 인터페이스를 정의tsconfing.json에 compilerOptions.verbatimModuleSyntax를 true로 설정하면 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에 대한 타입 추론이 가능해짐
}
앱에 CSP(Content-Security-Policy)를 적용할 때 unsafe-inline 지시문을 피하기 위해 inline <script>에 nonce 속성을 추가해야 할 경우, React Router에서는 nonce 속성 추가를 위해 다음과 같은 API들을 제공한다.
<Scripts nonce> (root.tsx)<ScrollRestoration nonce> (root.tsx)<ServerRouter nonce> (entry.server.tsx)renderToPipeableStream(..., { nonce }) (entry.server.tsx)renderToReadableStream(..., { nonce }) (entry.server.tsx)❓CSP(Content-Security-Policy)란?
브라우저가 신뢰할 수 있는 출처의 리소스만 실행하도록 강제하여 XSS 등 악성 코드 주입 공격을 막는 웹 보안 정책
❓CSP의
nonce속성이란?원래 CSP는
unsafe-inline을 써서 모든 인라인 스크립트를 허용하거나, 아예 금지해야 한다. 하지만 프레임워크가 생성하는 필수 인라인 스크립트가 있을 때, 서버에서 매번 새로운 임의의 값인nonce를 생성해 스크립트 태그에 넣어주고, CSP 헤더에도 같은 값을 보낸다. 이 때 브라우저는 nonce 값이 일치하는 스크립트를 안전하다고 판단하여 실행한다. 즉,nonce를 일회용 암호라고 생각하면 될 듯 하다.
일반적으로 React Router는 서버 코드를 하나의 번들로 빌드하지만, react-router.config.ts에서 serverBundles 옵션을 사용하여 여러 개의 번들로 나누어 빌드할 수 있다.
serverBundles 함수에서 각 라우트가 속할 번들의 ID를 반환
branch: 루트 라우트에서 해당 라우트까지 도달하기까지의 라우트 배열 목록branch의 각 라우트가 가진 속성id: 라우트의 공유한 IDpath: 라우트 경로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;
빌드가 완료되면 React Router는 buildManifest 객체를 전달하여 buildEnd 훅을 실행한다. 이 함수를 통해 빌드가 정확하게 수행되었는지를 검사할 수 있다.
buildManifest 구조
serverBundles: 각 번들 ID와 해당 파일 경로 매핑routeIdToServerBundleId: 특정 라우트 ID가 어떤 서버 번들에 속해 있는지에 대한 정보routes: 전체 라우트 메타데이터❓Build Manifest 파일이란?
빌드 프로세스 완료 후 생성되는 애플리케이션 자산(Assets)의 메타데이터를 담은 JSON 파일이다. 소스 코드의 라우트 ID와 실제 배포용 번들 파일 경로, 의존성 관계, 서버 번들 매핑 정보 등을 기록하여 런타임 시 서버가 요청에 맞는 리소스를 정확히 식별하고 서빙할 수 있도록 돕는 참조 데이터이다.
<div id="root"></div> 만 있는 진짜 거의 빈 HTML 사용react-router.config.ts에서 ssr: false로 설정HydreateFallback을 export하여 로딩 UI 제공loader를 export (optional) clientLoader, clientAction 사용 가능react-router build 실행 후 생성된 build/client 디렉토리를 static host에 배포 가능. 모든 경로에 대한 요청을 index.html로 연결해주는 기능이 없다면 설정 필요함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;
}
}
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 };
}
loaderData와 Await을 사용하여 렌더링하기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>;
}
기본적으로 4950ms로 설정되어 있고, 이를 변경하기 위해서는 entry.server.tsx에서 streamTimeout을 export해야 한다.
export const streamTimeout = 10_000;
❓
10_000처럼 써도 되나?브라우저나 서버가 코드를 실행할 때는 이 언더바(
_)를 완전히 무시하고 일반 숫자(10000)로 인식하기 때문에 성능이나 동작에는 영향을 주지 않는다고 한다!
handle이란?route module에서 export할 수 있는 사용자 적의 객체로, 임의의 데이터나 함수를 담을 수 있다.
handle과 useMatches를 사용하여 동적 UI 구현하기// 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>
),
};
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>
);
}
브라우저가 이전 페이지의 스냅샷과 새 페이지의 스냅샷을 찍어 두 상태 사이를 부드러운 페이드(Fade)나 이동 애니메이션으로 연결해주는 브라우저 기본 API로, 기존의 복잡한 CSS 애니메이션 라이브러리 없이도 앱과 같은 매끄러운 전환 효과를 낼 수 있다.
<Link to="/about" viewTransition>About</Link>navigate("/about", { viewTransition: true }) <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>
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>
);
}