카테고리 없음

react-router의 loader와 action

Ahyeon, Jung 2024. 8. 27. 12:57

API 요청을 시작하고부터 가장 신경쓰이는 점은 응답이 가는동안 어떤값을 넣어줘야하는건지 혼란이 있었다. 사실 이때문에 동기로 해야하는거 아닌가 하는 의문이 있었다. 물론 tanstack-query 사용하면서 자연스럽게 잊혀졌다. 하지만 이번에 리액트 자바스크립트 axios로 돌아가면서 다시 마주하게 되었다.

 

이런 나한테 action 관련해서 뭔가 한마디 해주셨던거같은데 그때 바 튕겨냄 ㅋ.ㅋ

Layout Shift
useState를 통한 일반적인 데이터 패칭
React Router의 loader
Tanstack/query의 데이터 패칭 상태 관리
- useState를 통해서 데이터 패칭 상태 관리하기
- 캐시된 데이터 사용하기
React Router의 action
React Router의 fetcher

Layout Shift

Layout Shift란 페이지 콘텐츠가 예기치 않게 이동하는 현상이다. 일반적으로 리소스가 비동식으로 로드되거나 DOM 요소가 동적으로 추가되어 발생한다. 크기를 알 수 없는 이미지나 동영상, 대체 크기보다 크거나 작게 렌더링되는 폰트, 동적으로 조정되는 광고나 위젯이 원인이 되기도 한다.

 

useState를 통한 일반적인 데이터 패칭

일반적으로 비동기로 API 요청을 보낼때는 useEffect와 useState를 통해서 처음 요청을 보내고 상태를 업데이트한다.

const RouterExample = () => {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts");

      const data = await res.json();
      setPosts(data);
    };
    fetchData();
  });

  return (
    <main>
      {posts?.map(({ title }) => (
        <div key={title}>{title}</div>
      ))}
    </main>
  );
};

export default RouterExample;

 

컴포넌트가 렌더링을 시작하는 순서는 다음과 같다.

  1. 컴포넌트가 렌더링을 시작하며, React는 해당 컴포넌트의 JSX를 평가한다.
  2. 초기 렌더링 시 useState([])가 호출되어 posts는 빈 배열로 초기화된다.
  3. 컴포넌트가 마운트된 후, useEffect가 실행되면서 API가 호출된다.
  4. 데이터를 가져오는 동안 컴포넌트는 현재 상태인 빈 배열을 유지한다.
  5. 비동기 요청이 완료되면, posts 상태가 업데이트되고 컴포넌트를 다시 렌더링한다.
  6. 업데이트된 posts 데이터를 기반으로 컴포넌트가 마운트된다.

 

이 3 번과 4번 과정의 데이터 패칭 중인 과정에서 컴포넌트가 어떤 값을 보여줘야할지를 선택해야한다. 이미지 크기의 미지정, 스크롤의 유무 등으로 Layout Shift가 일어나 UX에 악영향을 미칠 수 있다.

초기값을 지정해서 상태를 초기화할 수도 있고, mock 데이터를 보여줄 수도 있으며 최근에는 Suspense를 통해 로딩중임을 나타내는 스피너 혹은 로티를 보여주는 것이 일반적이다. 특히 tanstack-query는 이러한 데이터 패칭 상태를 라이브러리로 관리하고 데이터를 캐시하는데 도움을 준다.

React Router의 loader

React Router는 버전 6.4 이상에서 데이터 로딩과 페이지 렌더링을 최적화하기 위한 다양한 접근을 하면서 loader를 도입하였다. loader는 컴포넌트가 생성되기 전에 컴포넌트에 데이터를 전달하는 역할을 수행한다.

 

기존에 데이터 패칭은 컴포넌트가 마운트되고, useEffect가 실행되어 데이터를 가져왔을 때 리렌더링을 한다. 따라서 두 번의 렌더링이 필요하다. 또한 데이터 응답을 받기 이전에 컴포넌트가 렌더링되므로, 로딩 스피너 혹은 스켈레톤 UI 등을 표시해줘야했다.

 

loader는 특장 라우트로 이동할 때 해당 라우트에 필요한 데이터를 미리 로드하고, 그 데이터를 기반으로 컴포넌트를 렌더링한다. 불필요한 초기 렌더링과 상태 업데이트가 발생하지 않으며 서버사이드 렌더링 환경에서 데이터를 미리 로드하여 페이지를 초기화할 수 있다. 이는 Next.js에서 getServerSideProps를 통해 데이터 패칭을 진행한 후 페이지를 응답하는 것과 비슷하다.

 

먼저 loader를 사용하기 위해서는 라우팅 방식을 변경해야한다.

import Root from "../components/Root";
import RouterExample from "../pages/RouterExample";
import { createBrowserRouter } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,

    children: [
      {
        path: "router",
        loader: async () => {
          const data = await fetch(
            "https://jsonplaceholder.typicode.com/posts"
          );
          return data;
        },
        element: <RouterExample />,
      },
    ],
  },
]);

export default router;

 

path와 loader를 라우터에서 지정해주면 된다. createBrowserRouter를 기존의 <Routes />에서 사용하는 방법이 마이그레이션하기는 쉬우나, 개인적으로 객체로 배열로 관리하는 것이 직관적이어서 선호한다. loader에 데이터 패칭 함수를 지정해주면, 데이터 패칭 이후에 컴포넌트를 보여준다.

import { RouterProvider } from "react-router-dom";
import router from "./router";

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

export default App;

 

위에서 정의한 router를 RouterProvider를 통해 주입해준다. 

import { useLoaderData } from "react-router-dom";

interface PostType {
  title: string;
}
const RouterExample = () => {
  const posts = useLoaderData() as PostType[];

  return (
    <main>
      {posts?.map(({ title }) => (
        <div key={title}>{title}</div>
      ))}
    </main>
  );
};

export default RouterExample;

 

useLoaderData 훅을 사용하면 loader에서 반환한 데이터를 컴포넌트 내에서 불러올 수 있다.

 

현재는 아무것도 지정해주지 않았기 때문에 url을 변경하고 데이터를 패칭하는 동안 아무것도 렌더링되지 않다가, 데이터 패칭이 완료되면 페이지가 보여진다.

interface RouteObject {
  path?: string;
  index?: boolean;
  children?: React.ReactNode;
  caseSensitive?: boolean;
  id?: string;
  loader?: LoaderFunction;
  action?: ActionFunction;
  element?: React.ReactNode | null;
  hydrateFallbackElement?: React.ReactNode | null;
  errorElement?: React.ReactNode | null;
  Component?: React.ComponentType | null;
  HydrateFallback?: React.ComponentType | null;
  ErrorBoundary?: React.ComponentType | null;
  handle?: RouteObject["handle"];
  shouldRevalidate?: ShouldRevalidateFunction;
  lazy?: LazyRouteFunction<RouteObject>;
}

 

Route Object에서는 fallback, errorElement 등의 속성을 가지고 있어 데이터 패칭동안의 로딩 스피너와 에러 컴포넌트 역시 지정해줄 수 있다.

Tanstack/query의 데이터 패칭 상태 관리

그러나 이러한 기능이 발전되었음에도 불구하고, 이미 많은 개발자들은 데이터 패칭과 상태 관리에 강력한 기능을 제공하는 TanStack Query를 선호하고 있다. 그도 그럴것이 Layout Shift를 제거했지만 페이지 지연이 크게 체감된다. 위에서 본 것처럼, 빈 페이지를 보여주는게 상당히 좋은 사용자 경험이 아니고, fallback을 페이지 단위로 보여주는 것도 썩 좋지 않다. 

const RouterExample = () => {
  const {
    data: posts,
    isLoading,
    error,
  } = useQuery<PostType[]>({ queryKey: ["posts"], queryFn: fetchPosts });

  if (isLoading)
    return (
      <div className="center_container">
        <div className="emoji">🫥</div>
      </div>
    );
  if (error) return <div className="emoji">👿</div>;

  return (
    <main>
      {posts?.map(({ title }) => (
        <div key={title}>{title}</div>
      ))}
    </main>
  );
};

export default RouterExample;

 

결국 Layout Shift와 리렌더링을 감안하고 빠른 콘텐츠 제공을 선택했다.  

fallback 역시 라우팅 단위에서 보여주는 것보다는 컴포넌트 단위에서 보여주는 것이 더 나아보인다. Next.js에서 Pages router가 불평을 많이 받은 맥락을 함께 할 것 같다.

useState를 통해서 데이터 패칭 상태 관리하기

tanstack-query의 isLoading, error 등의 데이터 상태는 useState로 직접 구현이 가능하다. try ... catch ... finally 문을 통해서 패칭 상태를 만들 수 있다. 하지만 tanstack-query의 장점이 그 외에도 많기 때문에 대부분 라이브러리를 택한다.

const RouterExample = () => {
  const [isFetching, setIsFetching] = useState(false);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      setIsFetching(true);
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/posts");
        if (!res.ok) {
          throw new Error("Failed to fetch posts");
        }
        const data = await res.json();
        setPosts(data);
      } catch (error) {
        console.error("Error fetching posts:", error);
      } finally {
        setIsFetching(false);
      }
    };

    fetchData();
  }, []);
  return (
    <main>
      {isFetching && (
        <div className="center_container">
          <div className="emoji">🫥</div>
        </div>
      )}
      {posts?.map(({ id, title }) => (
        <div key={id}>{title}</div>
      ))}
    </main>
  );
};

export default RouterExample;

 

캐시된 데이터 사용하기

tanstack-query를 사용하는 경우 캐시된 데이터를 사용할 수 있다. 처음한번 받아온 데이터의 staleTime이 지나지 않으면 데이터를 그대로 보여주기 때문에 Layout Shift 역시 최적화했다고 볼 수 있다. 자동 리패칭 등 다른 기능도 많기 때문에 사용하지 않을 이유가 없다.

loader의 의의

*개인적인 의견이 가득함

 

 react router의 loader 도입은 클라이언트 환경의 역사와 맥락이 비슷하다고 볼 수 있다. 이제 서버 사이드 렌더링이 주를 차지할 수 있도록 CSR과 SSR 환경의 통일성이 강조되고 있다. 또한 Next.js가 Pages Router에서 App Router로 나아갔듯이, 페이지 단위의 지연이 아니라 컴포넌트 단위의 지연으로 나아가지 않을까하는 생각이 든다.

 

무엇보다 가장 큰 의의는 현재 컴포넌트들이 각각 책임이 너무 크다는 점이 떠오르고 있다는 것이다. 컴포넌트는 처음에 클래스형 컴포넌트의 생애주기가 있었고, 데이터 패칭, 이동, 스타일링 등 한 컴포넌트에서 너무 많은 기능을 가지고 있다. 이러한 맥락이 디자인 패턴에서 어떻게 나아갈지는 모르겠지만, 분리가 필요하든게 제시되었다는점에서 큰 의의가 있다. 특히 리액트는 선언형 프로그래밍을 표방하며 라이브러리임을 확고히 한다. 이러한 상황에서 컴포넌트가 데이터 패칭, 사용자 인터랙션, 스타일링 등으로 분리되지 않을까하는 생각이 든다. 다만, 일단 이게 라우팅이 할 일은 아닌 것 같다..? 

React Router의 action 사용하기

물론 React Router의 새로운 기능에는 loader만 있는 것이 아니다. 사용자 상호작용에 따른 데이터 처리와 상태 업데이트를 관리하기 위한 action도 도입되었다. 이를 통해 페이지 내에서 사용자 인터렉션을 통해 발생하는 다양한 작업을 보다 일관되게 처리할 수 있다.

import { Form } from "react-router-dom";

const ActionExample = () => {
  return (
    <Form method="post">
      <div>
        <label htmlFor="name">이름</label>
        <input id="name" name="name" required />
      </div>
      <div>
        <label htmlFor="phone">전화번호</label>
        <input id="phone" name="phone" required />
      </div>
      <button type="submit">제출</button>
    </Form>
  );
};

export default ActionExample;

 

컴포넌트에서는 react-router-dom에서 Form을 import 해서 감싸주면 된다.

export const action = async ({ request }: { request: Request }) => {
  const formData = await request.formData();
  const name = formData.get("name") as string;
  const phone = formData.get("phone") as string;

  if (!name || !phone) {
    return { message: "모든 필드를 채워주세요." };
  }

  console.log(`이름: ${name}, 전화번호: ${phone}`);

  return redirect("/success");
};

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "action",
        element: <ActionExample />,
        action,
      },
      {
        path: "success",
        element: (
          <div className="center_container">
            <div className="emoji">😀</div>
          </div>
        ),
        action,
      },
    ],
  },
]);

export default router;

 

제출했을 때 실행할 동작들을 정의하여 action으로 넣어주면 된다. 서버 요청과 응답 처리를 action 내에서 처리하고, redirect를 통해 페이지 상태와 전환을 쉽게 관리할 수 있다.

라우팅에서 이걸 하는게 맞아?

fetcher 사용하기

fetcher는 클라이언트 측에서 비동기적으로 데이터를 로드하고 상태를 관리하기 위해 사용된다. 페이지 내에서 데이터를 비동기적으로 로드하거나, 사용자의 액션에 따라 데이터를 요청하고 처리한다.

import { useFetcher } from "react-router-dom";

function MyComponent() {
  const fetcher = useFetcher();

  useEffect(() => {
    fetcher.load("/api/data");
  }, []);

  if (fetcher.data) {
    return <div>Data: {JSON.stringify(fetcher.data)}</div>;
  }

  if (fetcher.error) {
    return <div>Error: {fetcher.error.message}</div>;
  }

  return <div>Loading...</div>;
}

 

useFetcher 훅을 사용하여 폼 제출을 처리할 수도 있다. fetcher를 사용하면 폼 데이터를 비동기적으로 제출하고, 서버로의 요청과 응답을 관리할 수 있다. 

import { useFetcher } from "react-router-dom";

const ActionExample: React.FC = () => {
  const fetcher = useFetcher();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    fetcher.submit(formData, {
      method: "post",
      action: "/submit",
    });
  };

  return ( 
    <fetcher.Form method="post" onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">이름</label>
        <input id="name" name="name" required />
      </div>
      <div>
        <label htmlFor="phone">전화번호</label>
        <input id="phone" name="phone" required />
      </div>
      <button type="submit">제출</button>
    </fetcher.Form>
  );
};

export default ActionExample;

느낀점

완전니,, 개발자를 위한 개발자의 라이브러리 변화

뭔가 선택지가 많아지는거 같다!

고도를 기다리며

Reference

Home v6.26.1 | React Router