React 19 & 19.1 알아보기
목차
1. 프로젝트 세팅하기⌗
1.1 vite 프로젝트 세팅⌗
우선 vite를 이용해서 react 프로젝트를 설정합니다.
> npm create vite@latest react-19
Need to install the following packages:
create-vite@6.3.1
Ok to proceed? (y) y
> npx
> create-vite react-19
│
◇ Select a framework:
│ React
│
◇ Select a variant:
│ TypeScript + SWC
│
◇ Scaffolding project in /Users/onlifecoding/src/studies/react-19...
│
└ Done. Now run:
cd react-19
npm install
npm run dev
> cd react-19
> npm i
이렇게 프로젝트를 설정하고 에디터를 통해 프로젝트를 열어보면, 다음과 같이 react 19 프로젝트가 설정된 것을 확인할 수 있습니다.
{
...
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
...
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
...
}
}
1.2 tailwindcss 설정⌗
tailwindcss를 설치합니다.
> npm i tailwindcss @tailwindcss/vite
vite.config.ts 파일에 tailwindcss를 설정합니다.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 3000,
},
});
src/index.css 파일의 최상단에 다음과 같이 작성합니다.
@import "tailwindcss";
1.3 react-router-dom 및 기본 라우팅 설정⌗
페이지간의 이동을 위해 react-router-dom을 설치합니다.
> npm install react-router-dom
그리고 src/Router.tsx 파일을 생성하고 다음과 같이 코드를 작성합니다.
import { lazy } from "react";
import { createBrowserRouter, Outlet } from "react-router-dom";
const Index = lazy(() => import("./pages/Index"));
const About = lazy(() => import("./pages/About"));
// 루트 레이아웃 컴포넌트
const RootLayout = () => {
return (
<div className="h-screen w-full">
<Outlet />
</div>
);
};
export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{
index: true,
element: <Index />,
},
{
path: "/about",
element: <About />,
},
],
},
]);
라우팅 확인을 위해 src/pages/Index.tsx 파일을 다음과 같이 작성합니다.
import { Link } from "react-router-dom";
const Index = () => {
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="text-4xl font-bold">Index</div>
</div>
);
};
export default Index;
이어서 src/pages/About.tsx 파일을 다음과 같이 작성합니다.
import { Link } from "react-router-dom";
const About = () => {
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="text-4xl font-bold">About</div>
</div>
);
};
export default About;
마지막으로 라우팅이 동작하도록 src/App.tsx 파일을 다음과 같이 작성합니다.
import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { router } from "./Router";
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
);
}
export default App;
이제 프로젝트를 실행하고 라우팅이 동작하는 것을 확인할 수 있습니다.
> npm run dev
2. React 19 알아보기⌗
2.1 Actions⌗
React에서는 데이터를 작성하고 저장하거나 수정하는 일이 빈번하게 발생합니다. 사실 웹 페이지라는 게 데이터를 저장하고, 표시하고, 수정하고, 삭제하는 것이 전부이기 때문입니다. 이렇게 데이터를 다루는 과정에서 무언가 상태 값이 변경되겠죠?(ex: 서버에 데이터를 전송하고 처리되기 기다릴 때, 요청 상태는 요청중 -> 요청 완료 또는 요청중 -> 요청 실패 등으로 변경될 겁니다!) 이렇게 상태가 변경되는 걸 상태 전이(transition)이라고 합니다. 그리고 공식 문서에 따르면, 비동기 상태 전이(async transition)을 사용하는 함수를 Action이라고 정의하고 있습니다.
2.1.1 useTransition⌗
예시를 하나 들어볼까요? 서버에 데이터를 전송하는 경우를 생각해보죠. 서버와의 통신 작업을 진행할 때 중요한 점은, 사용자가 실수로 저장 버튼을 두번 클릭하는 걸 방지할 수 있어야 합니다. 만약 이런 처리가 제대로 되지 않는다면, 데이터가 중복으로 저장되어 문제가 발생할 수 있습니다. 예시를 위해서 src/pages/Index.tsx 파일을 다음과 같이 작성합니다.
import { useState } from "react";
import { Link } from "react-router-dom";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const Index = () => {
const [message, setMessage] = useState("");
const [log, setLog] = useState<string[]>([]);
const handleSave = async () => {
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
setLog((prev: string[]) => [...prev, message]);
};
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="메세지를 입력하세요"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={handleSave}>저장</button>
</div>
<div className="flex items-center gap-2">
{log.map((item, index) => (
<div key={index}>{item}</div>
))}
</div>
</div>
);
};
export default Index;
완성된 페이지는 다음과 같습니다.

메세지를 입력할 수 있는 입력 필드와 저장 버튼을 표시하고, 저장 버튼을 클릭하면 메세지를 서버에 저장하는 기능을 구현했습니다. 그리고 서버에 저장하는 걸 흉내내기 위해서 저장 버튼을 누르면 2초 대기 후 저장된 메세지를 표시하도록 했습니다.
이 페이지에서 메세지를 입력하고, 저장 버튼을 누르면 2초 후에 저장된 메세지가 로그에 표시되는 걸 확인할 수 있습니다. 그런데 저장 버튼을 마구 누르면 어떻게 될까요?

서버에 저장하는 동안 입력을 방지하는 작업을 하지 않았기 때문에 누르는 만큼 중복되어 저장되는 걸 볼 수 있습니다. 그러면 입력을 방지하기 위한 장치를 추가해보겠습니다.
...
const Index = () => {
...
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
setLog((prev: string[]) => [...prev, message]);
setIsSaving(false);
};
return (
<div className="h-screen w-full flex flex-col gp">
...
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
...
<button
className="border-2 border-gray-300 rounded-md p-2 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSave}
disabled={isSaving}
>
저장
</button>
</div>
...
</div>
</div>
);
};
export default Index;
데이터를 저장하는 중인지 알 수 있는 상태 isSaving을 추가했습니다. 그리고 저장 버튼을 누르면 이 상태를 true로 변경하고, 서버와의 통신이 끝나면 false로 변경합니다. 이렇게 하면 저장 버튼이 눌리지 않은 상태에서는 비활성화되어 입력을 방지할 수 있습니다. 그리고 사용자에게 버튼을 누를 수 없다는 알림을 위해 disabled 속성과 스타일을 추가했습니다.
이제 저장 버튼을 누르면 중복으로 저장되는 걸 막을 수 있습니다. React 19에서는 이런 작업을 쉽게 할 수 있도록 useTransition 훅을 제공합니다. 이 훅은 서버에 데이터를 저장하는 등의 비동기 작업이 시작될 때 isPending 상태를 true로 변경하고, 끝나면 false로 변경합니다. 위 예제를 다음과 같이 수정할 수 있습니다.
import { useState, useTransition } from "react";
import { Link } from "react-router-dom";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const Index = () => {
const [message, setMessage] = useState("");
const [log, setLog] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const handleSave = async () => {
startTransition(async () => {
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
setLog((prev: string[]) => [...prev, message]);
});
};
return (
<div className="h-screen w-full flex flex-col gp">
...
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
...
<button
className="border-2 border-gray-300 rounded-md p-2 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSave}
disabled={isPending}
>
저장
</button>
</div>
...
</div>
</div>
);
};
export default Index;
isSaving과 같은 별도의 상태를 사용하지 않아도 되고, 사용자가 직업 setIsSaving(true), setIsSaving(false)를 호출해야 할 필요도 없어졌습니다. startTransition 함수를 사용하면, 호출이 시작될 때와 종료될 때 useTransition 훅이 자동으로 상태를 관리해줍니다.
useTransition은 React 18에서도 사용할 수 있습니다!
2.1.2 useActionState⌗
만약 데이터를 전송하던 중에 오류가 발생한다면 어떻게 될까요? 사용자에게 뭔가 오류가 발생했다는 사실을 알려줘야 합니다. 그렇지 않으면, 사용자는 뭐가 안되는지도 모르면서 계속 여기저기 클릭하다가 결국 사용을 포기하겠죠!
import { useState, useTransition } from "react";
import { Link } from "react-router-dom";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 서버에 데이터를 전송하는 함수, 일부러 오류를 발생시키기 위해 예외를 던집니다.
*/
const submitMessage = async (message: string) => {
console.log(`submitMessage: ${message}`);
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
throw new Error("서버에 문제가 발생했습니다.");
};
const Index = () => {
const [message, setMessage] = useState("");
const [log, setLog] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
setError(null);
startTransition(async () => {
try {
await submitMessage(message);
setLog((prev: string[]) => [...prev, message]);
} catch (error: unknown) {
setError(
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다."
);
}
});
};
return (
<div className="h-screen w-full flex flex-col gp">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
className="border-2 border-gray-300 rounded-md p-2"
type="text"
placeholder="메세지를 입력하세요"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
className="border-2 border-gray-300 rounded-md p-2 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSave}
disabled={isPending}
>
저장
</button>
{error && <p className="text-red-500">{error}</p>}
</div>
<div className="flex flex-col gap-2">
<p className="text-lg font-bold text-gray-500">로그</p>
<div className="flex items-center gap-2">
{log.map((item, index) => (
<div key={index}>{item}</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Index;
저장 버튼을 눌렀을 때, 서버에 메세지를 전송하는 걸 흉내내기 위해 submitMessage 함수를 추가했습니다. 그리고 서버에서 뭔가 오류가 발생하는 상황을 만들기 위해 일부러 예외를 던지도록 했습니다.
이제 저장 버튼을 누르면 2초 대기 후 에러 메세지가 표시되는 걸 볼 수 있습니다.

React 19에 추가된 useActionState 훅을 사용하면, 이런 폼 전송 작업의 상태와 에러 처리를 더 쉽게 할 수 있습니다.
import { useActionState, useState } from "react";
import { Link } from "react-router-dom";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
interface submitMessageResult {
error: string | null;
}
/**
* 서버에 데이터를 전송하는 함수, 일부러 오류를 발생시키기 위해 예외를 던집니다.
*/
const submitMessage = async (message: string) => {
console.log(`submitMessage: ${message}`);
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
throw new Error("서버에 문제가 발생했습니다.");
};
const Index = () => {
const [message, setMessage] = useState("");
const [log, setLog] = useState<string[]>([]);
/**
* 각각 Action의 결과, 폼의 값을 전송할 Action, Action의 진행 상태를 나타냅니다.
*
* 이 예제에서는 결과 값으로 오류 메세지만 리턴합니다.
*/
const [result, submitAction, isPending] = useActionState(
async (
prevState: submitMessageResult,
formData: FormData
): Promise<submitMessageResult> => {
const message = formData.get("message") as string;
if (prevState) {
console.log(`이전 상태 값: ${JSON.stringify(prevState)}`);
}
try {
await submitMessage(message);
setLog((prev: string[]) => [...prev, message]);
return { error: null }; // Action의 결과를 리턴합니다. 오류가 없었으므로 null을 리턴합니다.
} catch (error: unknown) {
const errorMessage =
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.";
return { error: errorMessage }; // 오류가 있었으므로 오류 메세지를 리턴합니다.
}
},
{ error: null } // 초기 상태는 null입니다.
);
return (
<div className="h-screen w-full flex flex-col gp">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="flex flex-col gap-2">
<form action={submitAction} className="flex items-center gap-2">
<input
name="message"
className="border-2 border-gray-300 rounded-md p-2"
type="text"
placeholder="메세지를 입력하세요"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button
className="border-2 border-gray-300 rounded-md p-2 disabled:opacity-50 disabled:cursor-not-allowed"
type="submit"
disabled={isPending}
>
저장
</button>
{!isPending && result.error && (
<p className="text-red-500">{result.error}</p>
)}
</form>
<div className="flex flex-col gap-2">
<p className="text-lg font-bold text-gray-500">로그</p>
<div className="flex items-center gap-2">
{log.map((item, index) => (
<div key={index}>{item}</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Index;
직접 setError를 통해 오류 메세지를 설정할 필요 없이 useActionState 훅을 통해 Action의 결과와 진행 상태를 관리할 수 있습니다. 그리고 이전 상태까지도 접근할 수 있어서, 뭔가 이전 상태에 따른 처리를 할 수도 있습니다.
2.1.3 use⌗
아마도 웹에서 가장 빈번한 작업을 서버에서 비동기(async)로 데이터를 조회해서 목록을 출력하는 작업일겁니다. 그런 작업을 하나 추가해볼까요? pages/List.tsx 파일을 추가하고 다음과 같이 작성합니다.
단순한 구현을 위해 코드 중복은 무시하겠습니다!
import { Suspense } from "react";
import { Link } from "react-router-dom";
import MemberList from "./components/MemberList";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 멤버 목록을 조회하는 함수
* @returns 멤버 목록
*/
const fetchMemberList = async () => {
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
return [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
{ id: 3, name: "Jim" },
];
};
const List = () => {
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="text-4xl font-bold">List</div>
<div className="flex flex-col gap-2">
{/* 비동기 처리를 위해 Suspense를 사용 */}
<Suspense fallback={<div>목록을 불러오는 중입니다...</div>}>
{/* 멤버 목록을 조회하는 함수의 Promise를 넘겨줍니다. */}
<MemberList memberListPromise={fetchMemberList()} />
</Suspense>
</div>
</div>
);
};
export default List;
MemberList 컴포넌트에 멤버 목록을 조회하는 함수의 결과인 Promise를 넘겨주고 있습니다.
이제 pages/components/MemberList.tsx 파일을 추가하고 다음과 같이 작성합니다.
import { use } from "react";
interface MemberListProps {
memberListPromise: Promise<
{
id: number;
name: string;
}[]
>;
}
/**
* 멤버 목록을 조회하는 컴포넌트
* @param memberListPromise 멤버 목록을 조회하는 Promise
* @returns 멤버 목록
*/
const MemberList = ({ memberListPromise }: MemberListProps) => {
// Promise의 결과가 resolve될 때까지 기다립니다.
const memberList = use(memberListPromise);
return (
<ul>
{memberList.map((member) => (
<li key={member.id}>{`${member.id} - ${member.name}`}</li>
))}
</ul>
);
};
export default MemberList;
Promise를 받아서 use 훅으로 넘겨줍니다. use 훅은 Promise 결과가 나올 때 까지(resolve 또는 reject) 기다리고, 그 결과를 반환합니다. 결과를 받아서 li 요소를 생성합니다.
마지막으로 Router.tsx 파일에 다음과 같이 라우팅을 추가합니다.
import { lazy } from "react";
import { createBrowserRouter, Outlet } from "react-router-dom";
const Index = lazy(() => import("./pages/Index"));
const About = lazy(() => import("./pages/About"));
const List = lazy(() => import("./pages/List"));
// 루트 레이아웃 컴포넌트
// eslint-disable-next-line react-refresh/only-export-components
const RootLayout = () => {
return (
<div className="h-screen w-full">
<Outlet />
</div>
);
};
export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{
index: true,
element: <Index />,
},
{
path: "/about",
element: <About />,
},
{
path: "/list",
element: <List />,
},
],
},
]);
이제 브라우저에서 http://localhost:3000/list 주소로 이동하면, 서버의 응답을 기다리는 동안 Suspense 컴포넌트의 fallback 속성으로 설정한 내용이 표시되다가 서버의 응답이 오면 멤버 목록을 조회하는 작업이 진행되는 걸 볼 수 있습니다.

만약 서버에서 데이터를 조회하는 작업이 실패한다면, 그러니까 Promise가 reject 된다면 어떻게 될까요? fetchMemberList 함수에서 일부러 오류가 발생하도록 수정해볼까요?
/**
* 멤버 목록을 조회하는 함수
* @returns 멤버 목록
*/
const fetchMemberList = async () => {
throw new Error("서버에 문제가 발생했습니다.");
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
return [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
{ id: 3, name: "Jim" },
];
};
페이지를 새로고침 해보면, 컴포넌트 내용 대신에 오류 메세지가 표시됩니다!

이럴 때는 Promise에 catch 핸들러를 추가할 수도 있지만, ErrorBoundary를 사용해서 오류를 사용자에게 친화적인 메시지로 표시할 수 있습니다. 우선, react-error-boundary 패키지를 설치합니다.
> npm install react-error-boundary
그리고 src/pages/List.tsx 파일의 Suspense 컴포넌트를 ErrorBoundary 컴포넌트로 감싸줍니다.
import { Suspense } from "react";
import { Link } from "react-router-dom";
import MemberList from "./components/MemberList";
import { ErrorBoundary } from "react-error-boundary";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 멤버 목록을 조회하는 함수
* @returns 멤버 목록
*/
const fetchMemberList = async () => {
throw new Error("서버에 문제가 발생했습니다.");
await wait(2000); // 서버와의 통신을 흉내내기 위해 2초 대기
return [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
{ id: 3, name: "Jim" },
];
};
const List = () => {
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="text-4xl font-bold">List</div>
<div className="flex flex-col gap-2">
<ErrorBoundary
fallback={<div>목록을 불러오는 중에 문제가 발생했습니다.</div>}
>
{/* 비동기 처리를 위해 Suspense를 사용 */}
<Suspense fallback={<div>목록을 불러오는 중입니다...</div>}>
{/* 멤버 목록을 조회하는 함수의 Promise를 넘겨줍니다. */}
<MemberList memberListPromise={fetchMemberList()} />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
};
export default List;
이렇게 하면 데이터를 조회하는 작업이 실패했을 때, 사용자에게 친화적인 메시지를 표시할 수 있습니다.

React 19.1 업데이트하기⌗
다음 명령을 통해 설치된 패키지의 현재 버전과 최신 버전을 확인합니다.
> npm outdated
Package Current Wanted Latest Location Depended by
@eslint/js 9.22.0 9.24.0 9.24.0 node_modules/@eslint/js react-19
@tailwindcss/vite 4.0.14 4.1.3 4.1.3 node_modules/@tailwindcss/vite react-19
@types/react 19.0.10 19.1.1 19.1.1 node_modules/@types/react react-19
@types/react-dom 19.0.4 19.1.2 19.1.2 node_modules/@types/react-dom react-19
@vitejs/plugin-react-swc 3.8.0 3.8.1 3.8.1 node_modules/@vitejs/plugin-react-swc react-19
eslint 9.22.0 9.24.0 9.24.0 node_modules/eslint react-19
globals 15.15.0 15.15.0 16.0.0 node_modules/globals react-19
react 19.0.0 19.1.0 19.1.0 node_modules/react react-19
react-dom 19.0.0 19.1.0 19.1.0 node_modules/react-dom react-19
react-router-dom 7.3.0 7.5.0 7.5.0 node_modules/react-router-dom react-19
tailwindcss 4.0.14 4.1.3 4.1.3 node_modules/tailwindcss react-19
typescript 5.7.3 5.7.3 5.8.3 node_modules/typescript react-19
typescript-eslint 8.26.1 8.29.1 8.29.1 node_modules/typescript-eslint react-19
vite 6.2.2 6.2.6 6.2.6 node_modules/vite react-19
위 내용 중 Current, Wanted, Latest 열은 다음과 같은 의미를 가집니다.
- Current: 현재 설치된 버전
- Wanted: package.json 파일에 설정된 버전 범위를 만족하는 최신 버전(ex: “~5.7.2"는 5.7.2 <= 패키지 버전 < 5.8.0, “^6.2.0"은 6.2.0 <= 패키지 버전 < 7.0.0)
- Latest: 최신 버전
자 그러면, 모든 패키지를 Wanted 버전으로 업데이트 하겠습니다.
> npm update
이제 다시 패키지 버전을 확인해보면, 다음과 같이 모든 패키지가 최신 버전으로 업데이트 된 것을 확인할 수 있습니다.
Owner Stacks⌗
React 19.1 버전에서 추가된 Owner Stacks는 컴포넌트의 오류가 발생했을 때, 오류가 발생한 컴포넌트의 계층 구조를 표시해줍니다. 그래서 실제로 어떤 컴포넌트에서 오류가 발생했고, 그 컴포넌트는 어떤 컴포넌트가 렌더링한 건지 확인할 수 있어 디버깅할 때 유용합니다.
공식 문서를 참조하여 다음과 같은 예제를 작성해보겠습니다.
우선, 무언가 에러가 발생했을 때 화면에 오버레이를 표시할 수 있도록 index.html 파일을 수정합니다.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<!--
Error dialog in raw HTML
since an error in the React app may crash.
-->
<div id="error-dialog" class="hidden">
<h1 id="error-title" class="text-red">Error</h1>
<p class="">
<pre id="error-body"></pre>
</p>
<div class="mb-10"></div>
<h2 class="">Owner Stack:</h2>
<pre id="error-owner-stack" class="nowrap"></pre>
<button
id="error-close"
class="mt-10"
onclick="document.getElementById('error-dialog').classList.add('hidden')"
>
Close
</button>
</div>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
그리고 이 오버레이의 스타일을 src/index.css 파일에 추가합니다.
#error-dialog {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: white;
padding: 15px;
opacity: 0.9;
text-wrap: wrap;
overflow: scroll;
}
#error-dialog h1 {
margin-top: 0;
font-size: 22px;
}
#error-dialog h2 {
margin-top: 0;
font-size: 20px;
}
#error-dialog .text-red {
color: red;
}
#error-dialog .mb-20 {
margin-bottom: 20px;
}
#error-dialog .mb-10 {
margin-bottom: 10px;
}
#error-dialog .mb-0 {
margin-bottom: 0;
}
#error-dialog .mt-10 {
margin-top: 10px;
}
#error-dialog pre {
text-wrap: wrap;
}
#error-dialog pre.nowrap {
text-wrap: nowrap;
}
#error-dialog .hidden {
display: none;
}
다음으로 오버레이에 에러 메세지와 Owner Stack을 표시할 수 있도록 하는 유틸 함수를 src/utils/errorOverlay.ts 파일에 추가합니다.
export const onConsoleError = ({
consoleMessage,
ownerStack,
}: {
consoleMessage: string;
ownerStack: string | null;
}) => {
const errorDialog = document.getElementById("error-dialog");
const errorBody = document.getElementById("error-body");
const errorOwnerStack = document.getElementById("error-owner-stack");
if (errorBody) {
// Display console.error() message
errorBody.innerText = consoleMessage;
}
if (errorOwnerStack) {
// Display owner stack
errorOwnerStack.innerText = ownerStack ?? "-no owner stack-";
}
if (errorDialog) {
// Show the dialog
errorDialog.classList.remove("hidden");
}
};
이제 마지막으로 src/main.tsx 파일에 console.error 함수를 재정의하여 console.error 함수가 호출될 때 오버레이에 에러 메세지와 Owner Stack을 표시할 수 있도록 합니다.
import { captureOwnerStack, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { onConsoleError } from "./utils/errorOverlay.ts";
// 원래의 console.error 함수를 저장
const originalConsoleError = console.error;
// 재정의된 console.error 함수
console.error = function patchedConsoleError(...args) {
// 원래의 console.error 함수를 호출
originalConsoleError.apply(console, args);
// Owner Stack을 캡쳐
const ownerStack = captureOwnerStack();
// 오버레이에 에러 메세지와 Owner Stack을 표시
onConsoleError({
consoleMessage: args[0],
ownerStack,
});
};
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
이제 브라우저에서 무언가 에러가 발생했을 때 오버레이에 에러 메세지와 Owner Stack을 표시할 수 있습니다. 그러면 간단하게 테스트를 해볼까요?
다음과 같이 src/pages/components/Parent.tsx와 src/pages/components/Child.tsx 파일을 추가합니다.
const Child = () => {
console.error("Child 컴포넌트에서 에러가 발생했습니다.");
return <div>Child</div>;
};
export default Child;
import Child from "./Child";
const Parent = () => {
return (
<>
<div>Parent</div>
<Child />
</>
);
};
export default Parent;
그리고 src/pages/About.tsx 파일에 Parent 컴포넌트를 추가합니다.
import { Link } from "react-router-dom";
import Parent from "./components/Parent";
const About = () => {
return (
<div className="h-screen w-full flex flex-col">
<div className="w-full flex items-center gap-2 text-blue-500 underline">
<Link to="/">Index</Link>
<Link to="/about">About</Link>
</div>
<div className="text-4xl font-bold">About</div>
<Parent />
</div>
);
};
export default About;
이제 브라우저에서 About 페이지로 이동해서 에러를 확인해볼까요?

오버레이가 표시되고, Owner Stack에 오류가 발생한 컴포넌트의 계층 구조가 표시되는 것을 확인할 수 있습니다. 에러가 발생한 컴포넌트의 부모 컴포넌트는 Parent 컴포넌트이고, Parent 컴포넌트의 부모 컴포넌트는 About 컴포넌트인 거죠.
단, Owner Stack은 개발 환경에서만 사용가능하기 때문에 main.tsx를 다음과 같이 수정할 필요가 있습니다.
import { captureOwnerStack, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { onConsoleError } from "./utils/errorOverlay.ts";
if (process.env.NODE_ENV === "development") {
const originalConsoleError = console.error;
console.error = function patchedConsoleError(...args) {
originalConsoleError.apply(console, args);
const ownerStack = captureOwnerStack();
onConsoleError({
consoleMessage: args[0],
ownerStack,
});
};
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
참고로 process를 사용하려면 다음 명령으로 node의 타입 정보 패키지를 설치해야 합니다.
> npm install --save-dev @types/node