Sean's Blog

An image showing avatar

Hi, I'm Sean

這裡記錄我學習網站開發的筆記
歡迎交流 (ゝ∀・)b

LinkedInGitHub

React Router V6 - Fetch data with Loader

上一篇介紹了 React Router V6 的基本架構,包含導頁、動態路由與巢狀路由,本文則會介紹 V6 全新的重要功能 Loader。

New Feature - Loader

「進入畫面後,需要 Call API 取得初始資料!」這個需求應該不陌生吧?許多頁面都有這個動作,例如:一進到商品頁,先取得商品資料。

這麼做並沒錯,但是這在使用者體驗 (UX) 上比較差,因爲當 User 進入頁面時,畫面或許還沒渲染完成。針對這一點,React Router V6 提供了 loader 這項功能,讓資料可以透過路由系統先行處理,在渲染前預先 Loading Data。

使用 Loader 與 useLoaderData 獲取資料

要透過路由系統取得資料,首先我們要在路由中定義 loader 這個參數,其值需要是一個函式,並且預期會回傳資料,讓我們能夠在元件中取用這份資料。

範例:我們使用 loader 與 Async/Await 去呼叫 API,雖然這會回傳一個 Promise,但是 React Router 會確保 API 資料已經回傳,讓我們能夠在元件中取得 resData.events 的資料。

1const router = createBrowserRouter([
2  {
3    path: '/',
4    element: <RootLayout />,
5    children: [
6      {
7        path: 'events',
8        element: <EventRootLayout />,
9        children: [
10          {
11            index: true,
12            element: <Events />,
13            loader: async () => {
14              const response = await fetch('http://localhost:8080/events');
15              if (!response.ok) {
16                // Handle Error...
17              } else {
18                const resData = await response.json();
19                return resData.events;
20              }
21            },
22          },
23        ],
24      },
25    ],
26  },
27]);

回傳成功後,我們就可以在元件裡面透過 useLoaderData 取得資料了。

範例中,我們透過 useLoaderData 取得 Events 的相關資料。

1const EventsPage = () => {
2  const events = useLoaderData();
3
4  return <EventsList events={events} />;
5};
6
7export default EventsPage;

不過,如果我們在 App.js 這種定義路由的檔案中撰寫 loader 的函式,這個檔案會變得很大一包,所以建議的做法還是將 Loader 寫在 Page Component 裡面再 export 到路由檔案去做定義喔。

例如:我們將 Loader 寫在 Page-Level 元件中。

1export default EventsPage;
2
3export const loader = async () => {
4  const response = await fetch('http://localhost:8080/events');
5  if (!response.ok) {
6    // Handle Error...
7  } else {
8    const resData = await response.json();
9    return resData.events;
10  }
11};

定義好 Loader 後,將元件裡面定義的 loader 通過 import 引入進來,並且可以使用 alias 定義不同頁面元件所使用的 Loader 名稱,常見的命名方式為 Events 頁面就叫做 eventsLoader

1import Events, { loader as eventsLoader } from './pages/Events';
2
3const router = createBrowserRouter([
4  {
5    path: '/',
6    element: <RootLayout />,
7    children: [
8      ,
9      {
10        path: 'events',
11        element: <EventRootLayout />,
12        children: [
13          {
14            index: true,
15            element: <Events />,
16            loader: eventsLoader,
17          },
18        ],
19      },
20    ],
21  },
22]);

最後一樣就可以在元件裡面透過 useLoaderData 取用資料啦!

Behind The Scenes: When Are loader() Functions Executed

如果你的 API 回傳時間較長,或是刻意讓 API 延遲回傳,就可以發現 Router 的跳轉,其實是等到 loader 取得資料後才執行。

這個機制的優點是能夠確保你已經取得資料,接著才去渲染畫面。

但缺點是,使用者在切換路由時可能會出現延遲,搞不好還會因此以為網頁壞掉了!?

關於這個問題,我們可以透過 useNavigationstate 去判斷該路由的狀態,藉由這個狀態動態地加上 Loading 樣式。

注意:useNavigation 是用來取得路由狀態等資訊,而上一篇提到的 useNavigate 則是用來執行程式化導頁等動作。

1const RootLayout = () => {
2  const navigation = useNavigation();
3
4  return (
5    <>
6      <MainNavigation />
7      <main>
8        {navigation.state === 'loading' && <p>Loading...</p>}
9        <Outlet />
10      </main>
11    </>
12  );
13};

另外,Loader 是在 Browser 環境中執行,而非在 Server 環境中執行。

但是,雖然 Loader 是在 Browser 中執行,但是 Loader 裡面還是「不能」使用像 useStateuseParams 等 React Hooks 喔。

Throw Responses and Catch Errors with useRouteError

在上一篇 Setup Routes 的文章裡,我們使用 errorElement 來指定當路由導向發生錯誤時,應該渲染的頁面或元件,而這個 Error 頁面除了用在處理錯誤的路由,同樣也能用來處理錯誤的 API 回應。

範例:我們在父路由(也就是最外層)設置錯誤頁面,預計在這一層處理 API 的錯誤回應。

1const router = createBrowserRouter([
2  {
3    path: '/',
4    element: <RootLayout />,
5    errorElement: <Error />, // catch any errors
6    children: [
7      { index: true, element: <Home /> },
8      {
9        path: 'events',
10        element: <EventRootLayout />,
11        children: [
12          {
13            index: true,
14            element: <Events />,
15            loader: eventsLoader,
16          },
17        ],
18      },
19    ],
20  },
21]);

根據需求,你也可以在父子路由個別設置 errorElement,如果子路由沒有設置,那麼子路由出現的 Error 就會 Bubble Up 到父路由。

接下來我們用 throw new Response() 的方式拋出錯誤,這是一個近期比較推薦的做法,因為這樣可以讓前端依照不同的 status 顯示不同的資訊給使用者。

1const EventsPage = () => {
2  const data = useLoaderData();
3  const events = data.events;
4
5  return <EventsList events={events} />;
6};
7
8export default EventsPage;
9
10export const loader = async () => {
11  const response = await fetch('http://localhost:8080/events');
12  if (!response.ok) {
13    throw new Response(JSON.stringify({ message: 'Could not fetch events' }), {
14      status: 500,
15    });
16  } else {
17    return response;
18  }
19};

定義好錯誤訊息後,我們透過 React Router V6 提供的 useRouteError 去取得錯誤訊息。

當我們是 Throw Responses 的時候,useRouteError 能透過 JSON.parse(error.data) 去取得回傳資料,以及透過 error.status 取得不同的狀態。

1// Error.js
2
3const Error = () => {
4  const error = useRouteError();
5
6  let title = 'Oops!';
7  let message = 'Sorry, an unexpected error has occurred.';
8
9  // API Error
10  if (error.status === 500) {
11    message = JSON.parse(error.data).message;
12  }
13
14  // Path Error
15  if (error.status === 404) {
16    title = 'Not Found!';
17    message = 'Could not find resource or page.';
18  }
19
20  return (
21    <PageContent title={title}>
22      <p>{message}</p>
23    </PageContent>
24  );
25};

如果你是回傳一般物件,像是 return { message: "error" },那麼這個 error 就會是那個物件本身了。

The Utility Function: json()

看到上面這個做法(回傳 Responses)是不是覺得有點麻煩呢?雖然遵循這個方式可以定義不同的錯誤狀態,以給予更好的使用者體驗,但是這也讓 Code 變得複雜許多。

或許是因為 React Router V6 團隊也覺得很繁瑣?所以他們準備了一個 Utility Function 叫做 json() 讓我們使用!剛剛上面那一大串 new Response 可以改成以下寫法。

1// Events.js
2
3export const loader = async () => {
4  const response = await fetch('http://localhost:8080/events22');
5  if (!response.ok) {
6    // throw new Response(JSON.stringify({ message: "Could not fetch events" }), {
7    //   status: 500,
8    // });
9    throw json({ message: 'Could not fetch events' }, { status: 500 });
10  } else {
11    return response;
12  }
13};

與此同時,你用 useRouteError 取得 error.data 後也不需要再做 JSON.parse() 了。

1// Error.js
2
3if (error.status === 500) {
4  // message = JSON.parse(error.data).message;
5  message = error.data.message;
6}

可喜可賀 🍻

使用 loader() 的參數

loader 自帶兩個參數 requestparams

  • request 為一個 Request 的 Standard Web Object,可以存取像是 URL 等資訊
  • params 為 Route Parameters,也就是動態路由冒號後面的 Segments

例如:位於 /events/:eventId 頁面時,loader() 可以透過 params.eventId 取得動態路由的片段,進而取得活動的詳細資料。

1// EventDetail.js
2
3export const loader = async ({ request, params }) => {
4  const id = params.eventId;
5  const response = await fetch(`http://localhost:8080/events/${id}`);
6  if (!response.ok) {
7    throw json(
8      { message: 'Could not fetch details for the selected event' },
9      {
10        status: 500,
11      },
12    );
13  } else {
14    return response;
15  }
16};

通過 useRouteLoaderData() 在子路由之間分享 Loader

如果當前路由需要的 Loader 已經在其他路由定義過,我們不需要再重複撰寫,可以直接經由 useRouteLoaderData() Hook 取得這份 Loader。

useRouteLoaderData() 能夠讓同一個路由樹的子路由共享 Loader,換句話說,想要分享同一份 Loader 時必須是子路由。

為了達到這一點,我們可以新增一層父路由,但是不給予 element 只定義 loader,並且賦予一個 id,這樣裡面的子路由就能透過 useRouteLoaderData(id) 取得共享的資料囉。

範例:當 EditEvent 頁面也需要使用 EventDetail 頁面的 Loader 時,首先我們要重新配置路由,將 EventDetail 的 Loader 提出來作為父路由,再把兩個頁面都放在這個父路由底下。

1const router = createBrowserRouter([
2  {
3    path: '/',
4    children: [
5      {
6        path: 'events',
7        element: <EventRootLayout />,
8        children: [
9          {
10            path: ':eventId',
11            id: 'event-detail', // 記得加上 ID
12            loader: eventDetailLoader, // 共用的 Loader
13            children: [
14              {
15                index: true,
16                element: <EventDetail />,
17              },
18              { path: 'edit', element: <EditEvent /> },
19            ],
20          },
21        ],
22      },
23    ],
24  },
25]);

設定好 RouteLoader 後,進入 EventDetail 頁面將原本的 useLoaderData 改為使用 useRouteLoaderData

同樣的,EditEvent 頁面也要使用 useRouteLoaderData("event-detail") 來取得 RouteLoader 的資料。

1// EventDetail.js
2const EventDetail = () => {
3  const data = useRouteLoaderData('event-detail');
4
5  return <EventItem event={data.event} />;
6};
7
8// EditEvent.js
9const EditEvent = () => {
10  const data = useRouteLoaderData('event-detail');
11
12  return (
13    <>
14      <h1>EditEvent</h1>
15      <EventForm event={data.event} />
16    </>
17  );
18};

注意:useRouteLoaderData 必須接收 Routes ID 這一個參數才能運作喔。

回顧

看完這篇文章,我們認識了 React Router V6 新增的 Loader 功能,瞭解如何使用 Loader 幫助我們建立更完整的路由系統。

下一篇文章會介紹另一個全新功能 Action 的用法,它可以幫助我們更全面地處理表單送出與驗證等功能。

References