[번역] Remixing React Router

Remixing React Router

원문 : https://remix.run/blog/remixing-react-router

React Router의 첫 번째 버전에는 실제로 willTransitionTo라는 데이터 로드를 지원하기 위한 비동기 훅이 있었습니다. 그 당시에는 React를 어떻게 사용해야 하는지 아무도 몰랐고 우리도 예외는 아니었습니다. 당시의 React Router는 좋다고 말할 수는 없었지만 적어도 올바른 방향으로 가고 있었습니다.

좋든 안 좋든 우리는 React Router v4의 컴포넌트에 전력을 다했고 그 훅을 제거했습니다. withTransitionTo가 사라지고, 컴포넌트가 주요 도구로 사용되면서 오늘날 거의 모든 React Router 앱은 컴포넌트 내부에서 데이터를 페칭합니다.

우리는 컴포넌트 안에서 데이터를 페칭하는 것이 가장 느린 UX로 가는 지름길이었다고 배웠습니다. (일반적으로 뒤따르는 모든 컨텐츠 레이아웃의 변경은 말할 것도 없습니다.)

고통받는 것은 UX뿐만이 아닙니다. 개발자 경험 또한 모든 컨텍스트 연결, 전역 상태 관리 솔루션(보통 서버 상태에 대한 클라이언트 캐시에 불과함)들로 인해 복잡해집니다. 그리고 데이터가 있는 모든 컴포넌트는 고유의 로딩, 에러, 성공 상태를 필요로 하게 됩니다. 행복한 길은 거의 없습니다!

Remix를 개발하면서 이러한 모든 문제를 해결하기 위해 React Router의 중첩 경로 추상화에 의존하는 연습을 많이 했습니다. 오늘 우리는 이러한 데이터 API를 React Router로 가져오는 작업을 시작했다고 발표하게 되어 기쁩니다. 이번에는 매우 훌륭합니다.

tl;dr

Remix의 데이터, 비동기 UI 관리에 대한 거의 모든 좋은 부분들이 React Router에서 제공됩니다.

  • 모든 데이터 컴포넌트들과 훅들 그리고 핵심적인 비동기 데이터 관리가 React Router에서 제공됩니다.
    • <Route Loader />로 데이터 로딩
    • <Route action /><Form>로 데이터 뮤테이션
    • 인터럽션, 에러, 재검증, 경쟁상태 등의 자동 처리
    • useFetcher와 비 탐색 데이터의 상호작용
  • 새로운 패키지인 @remix-run/router는 History, React Router의 매칭 그리고 Remix의 데이터 관리 등과 관련된 기능들을 view에 구애받지 않는 방식으로 합칩니다. 이것은 내부 종속성이기 때문에 여전히 npm install react-router-dom을 사용합니다.

컴포넌트 페칭과 렌더+페치 체인

컴포넌트들 내부에서 페치하면, 병렬 대신 순차적으로 여러 데이터 의존성을 페칭함으로써 페이지 로드 및 전환을 인위적으로 느리게 하는 렌더+페치 체인을 만들게 됩니다.

이 경로들을 생각해보세요:

<Routes>
  <Route element={<Root />}>
    <Route path="projects" element={<ProjectsList />}>
      <Route path=":projectId" element={<ProjectPage />} />
    </Route>
  </Route>
</Routes>

이제 이러한 각 컴포넌트가 자신의 데이터를 페칭한다고 생각해보세요.

function Root() {
  let data = useFetch("/api/user.json");

  if (!data) {
    return <BigSpinner />;
  }

  if (data.error) {
    return <ErrorPage />;
  }

  return (
    <div>
      <Header user={data.user} />
      <Outlet />
      <Footer />
    </div>
  );
}
function ProjectsList() {
  let data = useFetch("/api/projects.json");

  if (!data) {
    return <MediumSpinner />;
  }

  return (
    <div style={{ display: "flex" }}>
      <ProjectsSidebar project={data.projects}>
      <ProjectsContent>
        <Outlet />
      </ProjectContent>
    </div>
  );
}
function ProjectPage() {
  let params = useParams();
  let data = useFetch(`/api/projects/${params.projectId}.json`);

  if (!data) {
    return <div>Loading...</div>;
  }

  if (data.notFound) {
    return <NotFound />;
  }

  if (data.error) {
    return <ErrorPage />;
  }

  return (
    <div>
      <h3>{project.title}</h3>
      {/* ... */}
    </div>
  );
}

사용자가 /projects/123을 방문하면 어떻게 될까요?

  1. <Root>/api/user.json을 페칭하고 <BigSpinner />를 렌더링합니다.
  2. 네트워크 응답
  3. <ProjectsList>/api/projects.json을 페칭하고 <MediumSpinner />를 렌더링합니다.
  4. 네트워크 응답
  5. <ProjectPage>/api/projects/123.json을 페칭하고 <div>Loading...</div>를 렌더링합니다.
  6. 네트워크 응답
  7. <ProjectPage>가 마침내 렌더링되고 페이지가 완성됩니다.

이처럼 컴포넌트 페칭은 앱 속도를 크게 저하시킵니다. 컴포넌트가 마운트될 때 페치가 시작됩니다. 하지만 상위 컴포넌트의 보류 중인 상태는 하위 컴포넌트의 렌더링을 차단하고 페칭 또한 차단하게 됩니다.

이것이 렌더 + 페치 체인입니다. 샘플 앱의 세 가지 페치는 모두 논리적으로 병렬로 나갈 수 있지만, UI 계층 구조로 연결되어 있고 상위의 로딩 상태에 의해서 차단되기 때문에 불가능합니다.

각 페치를 처리하기 위해 1초가 소요된다면, 전체 페이지는 렌더링하는 데는 최소 3초가 소요됩니다. 이것이 많은 React 앱이 로드가 느리고 전환이 느린 이유입니다.

데이터 페칭과 컴포넌트들의 결합은 렌더 + 페치 체인로 이어지게 됩니다. 해결책은 *결과를 읽어오는 곳*에서 *페칭 시작*을 분리하는 것입니다. 이것이 바로 오늘날 Remix API가 하는 일이고 React Router가 곧 할 일입니다. 중첩된 경로 경계(nested route boundaries)에서 페칭을 시작하면 요청 워터폴 체인이 평평해지고 3배 빨라집니다. 경로 페칭(route fetching)은 요청을 병렬화하여 느린 렌더 + 페치 체인을 제거합니다.

단지 사용자 경험에 관한 것만은 아닙니다. 새로운 API가 한 번에 해결하는 문제의 양은 코드의 단순성과 코딩하는 재미에 큰 영향을 미칩니다.

What's Coming

우리는 여전히 몇 가지 이름들을 무시하고 있지만 다음과 같이 예상할 수 있습니다.

import * as React from "react";
import {
  BrowserRouter,
  Routes,
  Route,
  useLoaderData,
  Form,
} from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <Routes
      // 서버 렌더링이 아니면 초기 로딩 상태를 관리합니다.
      fallbackElement={<BigSpinner />}
      // 모든 렌더링 또는 비동기 로딩 및 뮤테이션 에러들이 포착되면 자동으로
      // 여기에 렌더링 됩니다. 더 이상 에러 상태 추적, 렌더링 분기는 필요 없습니다.
      exceptionElement={<GlobalErrorPage />}
    >
      <Route
        // 로더(loader)는 경로(Route) 컴포넌트에 데이터를 제공하고
        // URL이 변경될 때 시작됩니다.
        loader={({ signal }) => {
          // React Router는 웹 페치 API를 사용하므로 웹 페치 응답을 반환할 수 있으며
          // `res.json()`으로 자동으로 역직렬화됩니다.
          // 더 이상 useFetch 훅을 필요로 하는 모든 컴포넌트에서
          // 보류 상태를 엉망으로 만들 필요가 없습니다.
          return fetch("/api/user.json", {
            // 또한 탐색 인터럽션을 처리하고 (신호를 전달하는 한) 실제 페치를 취소합니다.
            signal,
          });
        }}
      >
        <Route
          path="projects"
          element={<Projects />}
          // 예외(exception)가 버블링되므로 컨텍스트에서 처리하거나
          // 수많은 적절한 경로로 버블링되도록 할 수 있습니다.
          exceptionElement={<TasksErrorPage />}
          loader={async ({ signal }) => {
            // 직접 `페치`를 풀고 간단한 `async/await` 코드를 작성할 수도 있습니다.
            // (useEffect 🥺 내에서 시도해 보세요).
            let res = await fetch("/api/tasks.json", { signal });

            // 로드하려는 데이터를 기반으로 경로 컴포넌트를 렌더링할 수 없는 경우
            // 예외를 발생시켜서 exceptionElement가 대신 렌더링 되도록 합니다.
            // 이것은 적절한 경로를 적절하게 유지하고, 예외 경로를 예외적으로 유지합니다.
            if (res.status === 404) {
              throw { notFound: true };
            }

            return res.json();
          }}
        >
          <Route
            path=":projectId"
            element={<Projects />}
            // 많은 로딩이 이렇게 간단해질 겁니다.
            // React Router는 모든 보류 상태를 처리하고 이를 사용자에게 표시하여
            // 보류/낙관적 UI를 개발할 수 있도록 합니다.
            loader={async ({ signal, params }) =>
              fetch(`/api/projects/${params.projectId}`, { signal })
            }
          />
        </Route>
        <Route index element={<Index />} />
      </Route>
    </Routes>
  </BrowserRouter>
);
function Root() {
  // 컴포넌트는 이 훅을 사용해서 경로 데이터에 접근합니다. 데이터에는 오류가 없고
  // 데이터 의존성을 가진 모든 컴포넌트가 처리할 보류 상태도 없을 겁니다.
  let data = useLoaderData();

  // 전환(Transition)은 보류 인디케이터, 분주한 스피너, 낙관적 UI 및 부작용(side effects)을
  // 개발하는데 필요한 모든 것을 제공합니다.
  let transition = useTransition();

  return (
    <div>
      {/* GlobalNavSpinner를 Root에 둘 수 있고 로딩(loading) 상태에
      대해 걱정할 필요가 없습니다. 또는 Skeleton UI를 만들기 위해 Outlet 주변을 더욱 세분화해서
      사용자는 링크를 클릭할 때 즉각적으로 피드백을 받습니다. (우리는 다른 시간에 그것을 하는 방법을 보여줄 것입니다) */}
      <GlobalNavSpinner show={transition.state === "loading"} />
      <Header user={data.user} />
      <Outlet />
      <Footer />
    </div>
  );
}

데이터 뮤테이션도!

이러한 데이터 로딩 API로 앱 속도를 높일 뿐만 아니라, 데이터 뮤테이션 API를 가져오는 방법도 생각해냈습니다! 읽기와 쓰기를 모두 포함하는 라우팅 및 데이터 해결책이 있다면, 모든 문제를 한 번에 해결할 수 있습니다.

이 "새 프로젝트" 폼을 생각해보세요.

function NewProjectForm() {
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      <button type="submit">Create</button>
    </Form>
  );
}

UI가 있으면 폼 작업이 가리키는 경로에 대한 작업만 있으면 됩니다.

<Route
  path="projects"
  element={<Projects />}
  // 이 작업은 폼의 action prop과 일치하기 때문에 폼이 제출될 때 호출됩니다.
  // 이제 경로에서 필요한 모든 데이터를 읽고 쓸 수 있습니다.
  action={async ({ request, signal }) => {
    let values = Object.fromEntries(
      // React Router는 일반 브라우저 POST요청을 가로채서 여기에 표준 Web 페치 요청으로 제공합니다.
      // React Router에 의해 직렬화되고 요청 시 사용할 수 있는 formData입니다.
      // 표준 HTML과 DOM API, 새로운 것은 없습니다.
      await request.formData()
    );

    // 웹 페치 API를 다음과 같이 몇 년 동안 사용해 왔기 때문에 잘 알고 있을겁니다.
    let res = await fetch("/api/projects.json", {
      signal,
      method: "post",
      body: JSON.stringify(values),
      headers: { "Content-Type": "application/json; utf-8" },
    });

    let project = await res.json();

    // 만약 문제가 생기면 예외를 발생시키고 예외 요소가 렌더링 되어 적절한 경로를 유지합니다. (만약 계속 읽고 있는 중이라면 에러를 발생시키는 것이 더 좋습니다.)
    if (project.error) throw new Error(project.error);

    // 이제 여기에서 이 경로를 반환할 수도 새 프로젝트 경로를 위한 리다이렉트(물론 이건 실제로는 웹 페치 응답입니다.)를 반환할 수도 있습니다.
    return redirect(`/projects/${project.id}`);
  }}
/>

그게 다입니다. 간단한 비동기 함수에서 UI와 실제 애플리케이션별 뮤테이션 코드만 작성하면 됩니다.

에러나 성공 상태를 dispatch 할 필요도 없고, useEffect 의존성을 고려할 필요도 없으며, 반환을 위한 정리 함수(cleanup functions), 만료될 캐시 키가 필요 없습니다. 뮤테이션을 수행할 때 문제가 발생하면 예외를 발생시켜야 한다는 걱정거리만 있습니다. 비동기 UI, 뮤테이션 문제 및 예외 렌더링 경로들이 완전히 분리되었습니다.

거기에서 React Router는 다음과 같은 문제들을 모두 처리합니다.

  • 폼 제출 시 작업을 호출합니다. (더 이상 이벤트 핸들러, event.preventDefault(), 그리고 전역 데이터 컨텍스트 연결이 필요없습니다.)
  • 액션에서 무언가가 발생하면 예외 경계(exception boundary)를 렌더링합니다. (더 이상 뮤테이션이 있는 모든 컴포넌트에서 에러, 예외 상태를 다룰 필요가 없습니다.)
  • 페이지의 로더를 호출해서 페이지의 데이터를 재검증합니다. (더 이상 컨텍스트 연결, 서버 상태를 위한 전역 스토어, 캐시 키 만료가 필요 없고 코드가 훨씬 적어집니다.)
  • 사용자가 너무 많이 클릭하면 인터럽션을 처리해서 UI가 동기화되지 않는 것을 방지합니다.
  • 여러 뮤테이션과 재검증이 동시에 진행 중이면 재검증 경쟁 상태를 처리합니다.

이 모든 것을 처리하기 때문에 간단한 훅인 useTransition을 통해 알고 있는 모든 것을 노출할 수 있습니다. 이것이 사용자에게 피드백을 제공해서 앱이 견고하게 느껴지도록 하는 방법입니다. (그리고 처음에 React를 페이지에 넣은 이유!).

function NewProjectForm() {
  let transition = useTransition();

  let busy = transition.state === "submitting";
  // 이 훅은 전환 상태("idle", "submitting", "loading"),
  // 낙관적 UI를 위해 서버에 제출되는 formData 등 모든 것을 알려줍니다.

  // 디자이너가 꿈꾸는 가장 멋진 SPA UI를 만들 수 있습니다....
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      {/* ... 또는 버튼을 비활성화하세요 😂 */}
      <button type="submit" disabled={busy}>
        Create
      </button>
    </Form>
  );
}

만약 당신의 앱 대부분에서 API 경로에 페칭과 포스팅(posting)을 처리한다면, 이것이 출시될 때 많은 코드를 삭제할 준비를 하세요.

추상화를 위해 구축됨

많은 개발자가 이 API를 보고 경로 구성에 너무 많은 코드가 있다고 생각할 수 있습니다. Remix는 로더와 작업을 경로 모듈과 함께 배치할 수 있고, 파일 시스템에서 경로 설정 자체를 빌드합니다. 우리는 사람들이 앱에 대해 유사한 패턴을 만들기를 기대합니다.

다음은 큰 노력 없이 이러한 문제들을 함께 배치하는 방법에 대해 매우 간단한 예입니다. 실제로 있는 것을 동적 가져오기(dynamic import)를 사용해서 “경로 모듈”을 만듭니다. 이렇게 하면 코드 분할과 더 깔끔한 경로 설정이 가능합니다.

export async function loader(args) {
  let actualLoader = await import("./actualModule").loader;
  return actualLoader(args);
}

export async function action(args) {
  let actualAction = await import("./actualModule").action;
  return actualAction(args);
}

export const Component = React.lazy(() => import("./actualModule").default);
import * as Tasks from "./tasks.route";

// ...
<Route
  path="/tasks"
  element={<Tasks.Component />}
  loader={Tasks.loader}
  action={Tasks.action}
/>;

Suspense + React Router = ❤️

React 서버 컴포넌트, Suspense 그리고 Streaming은 아직 출시되지 않았지만, React에서 구체화하고 있는 흥미로운 기능들입니다. React Router에서는 이 작업을 수행할 때 이러한 API들을 염두에 두고 있습니다.

이러한 React API는 렌더링 되기 전에 데이터를 불러오기 시작하는 시스템을 위해 설계되었습니다. 페칭을 시작하는 위치가 아니라 결과에 접근하는 위치를 정의하도록 설계되었습니다.

  • Suspense는 이미 시작된 페치, 보류 중인 UI 및 스트리밍할 때 HTML을 "플러시(flush)"할 때 기다리는 위치를 정의합니다.
  • React 서버 컴포넌트는 데이터 로딩 및 렌더링을 서버로 이동합니다.
  • Streaming은 데이터를 사용할 수 있게 되면 React 서버 컴포넌트를 렌더링하고 초기 SSR에 대한 Suspense 경계에서 HTML 청크를 보냅니다.

이러한 API는 로드를 시작하도록 설계된 것이 아니라 데이터를 사용할 수 있을 때 렌더링하는 방법과 위치를 지정합니다. Suspense 경계 내에서 페치들을 시작하면, 오늘날 React Router 앱에 여전히 존재하는 동일한 성능 문제를 가지고 컴포넌트를 페치하는 것입니다.

React Router의 새로운 데이터 로딩 API는 Suspense가 기대하는 바로 그 것입니다! URL이 변경되면 React Router는 렌더링 전에 일치하는 모든 경로에 대해 페치를 시작합니다. 이것은 새로운 React 기능이 빛나는 데 필요한 모든 것을 가져다줄 것입니다✨.

리퍼지토리 병합

이러한 기능을 개발하며 저희는 History, React Router 및 Remix 등 세 가지 리퍼지토리에 걸쳐 작업했습니다. 모든 것이 관련되어 있다면 도구(tooling), 문제(issues) 및 PR을 유지 관리하기에는 매우 나쁜 DX입니다. 커뮤니티에 기부금을 제공하기도 어렵습니다.

우리는 항상 Remix를 "React Router 용 컴파일러 및 서버"로 생각했습니다. 이제는 그들을 병합할 때입니다.

논리적으로 이는 다음을 의미합니다.

  • React Router는 우리가 하는 모든 것의 주된 의존성이므로 Remix를 React Router 리퍼지토리에 병합합니다. 또한 React Router는 지난 7년 동안 이슈, PR 및 백링크 등 웹상에서 가장 긴 역사를 가지고 있지만, 리믹스는 불과 몇 개월밖에 되지 않았습니다.
  • Remix 리퍼지토리의 이름을 "remix"에서 "remix-archive"로 변경하고 보관합니다.
  • 모든 패키지가 함께 있는 "react-router" 리퍼지토리의 이름을 "remix"로 바꿉니다.
  • 이전과 동일한 이름으로 모든 것을 NPM에 계속 게시합니다. 이것은 단지 소스 코드/프로젝트를 바꾸는 것이며 package.json은 영향을 받지 않습니다.

아직 끝내야 할 일이 많고 병합하려는 노력을 시작할 때 리퍼지토리에 있는 이슈(Issue)/PR이 이동되고, 병합되거나 폐쇄(Close)될 것으로 예상됩니다. 우리는 모든 기여자가 해당 커밋에 이름을 올릴 자격이 있다고 믿기 때문에 각 리퍼지토리의 git 기록을 유지하기 위해 최선을 다할 것입니다!

좋은 웹페이지 즐겨찾기