跳至主要内容

[react] React Router DOM v6.4 詳細介紹

資料夾與檔案結構 (建議先將檔案建好)

src
| App.js
| ...
|
└─── components
│ │ MainNavigation.jsx
| | ProductItems.jsx
| | ProductList.jsx
| | ProductForm.jsx
| | ProductDeferTest.jsx

└─── pages
│ │ Error.jsx
│ │ Home.jsx
│ │ ProductAction.jsx
│ │ ProductRoot.jsx
│ │ ProductDetail.jsx
│ │ Products.jsx
│ │ Root.jsx

說明

React Router DOM v6.4 版本新增了許多實用的功能,但如果要使用這些功能就不能用 v6 版本的 BrowserRouter,而是得使用新的 createBrowserRouterRouterProvider,v6.4 版主要是著重在 data loading 和 date fetch 的部份。

之前在 這部影片 有稍微介紹過,但介紹的功能沒有那麼多,這次就一次補完。

完整程式碼

Basic

createBrowserRouter & RouterProvider

先將 createBrowserRouterRouterProvider 引入,接著使用 createBrowserRouter 建立 Router 後,在 RouterProvider 將 router 傳遞進去。

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import HomePage from "./pages/Home";import ProductsPage from "./pages/Products";const router = createBrowserRouter([  { path: "/", element: <HomePage /> },  {    path: "/products",    element: <ProductsPage />,  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;
pages/Home.jsx
const HomePage = () => {  return <div>Home</div>;};export default HomePage;
pages/Products.jsx
const ProductsPage = () => {  return <div>Products</div>;};export default ProductsPage;
備註

將 path 指定為 '/' 的意思就是我們的 Domain, http://localhost:5173,如果網頁的網址為 http://example.com ,則 '/' 就是 http://example.com

所以現在只要在網址輸入 http://localhost:5173(因環境而異) 就會渲染出 HomePage, http://localhost:5173/products,則渲染出 ProductsPage。

createBrowserRouter & createRoutesFromElements & RouterProvider

如果不習慣用物件的方式定義 Router,也可以使用原本的 JSX 方式,只要引入 createRoutesFromElementsRoute 即可。

App.jsx
import {  createBrowserRouter,  createRoutesFromElements,  Route,  RouterProvider,} from "react-router-dom";import HomePage from "./pages/Home";import ProductsPage from "./pages/Products";const routeDefinitions = createRoutesFromElements(  <Route>    <Route path="/" element={<HomePage />} />    <Route path="/products" element={<ProductsPage />} />  </Route>);const router = createBrowserRouter(routeDefinitions);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

在剛學 React Router 的時候,可能有些人會不懂為什麼要使用 Router 提供的 Link Component 來連結到我們其他的 Component,這是因為如果使用超連結 a 標籤的方式來指定路徑,當我們點擊 a 標籤時,瀏覽器會以為我們要到新的頁面,進而重新發送 Request 取得我們要的頁面,等同於重新整理 React Application 的意思,而重新整理頁面的話,我們的 React State 就會全部遺失,所以這顯然不是個好方法。

而 React Router 提供的 Link 能防止瀏覽器送出 Request(Prevent Default Behavior),相反的,Link Component 檢查我們的 URL Path,如果有在 Router 中定義該 Path 的話,就直接渲染 Path 中定義的 element(component) 畫面。

pages/Home.jsx
import { Link } from "react-router-dom";const HomePage = () => {  return (    <div>      <h1>Home</h1>      <p>        Go to <Link to="/products">products</Link>      </p>    </div>  );};export default HomePage;

Layout & Outlet

現在如果要新增一個 Navbar 來讓使用者能夠到達其他頁面的話,我們可以在 Router 定義一個新的 Route。

先新增一個 Root Layout 和 Navbar:

pages/Root.jsx
const RootLayout = () => {  return (    <div>      <h1>Root Layout</h1>    </div>  );};export default RootLayout;
components/MainNavigation.jsx
import { Link } from "react-router-dom";const MainNavigation = () => {  return (    <header>      <ul>        <li>          <Link to="/">Home</Link>        </li>        <li>          <Link to="/products">Products</Link>        </li>      </ul>    </header>  );};export default MainNavigation;

因為要讓 Navbar 顯示在每一個頁面的上方,所以要重新定義一下 Router,先回到 App.jsx,將 Router 改為以下:

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import HomePage from "./pages/Home";import ProductsPage from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    children: [      { path: "", element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

可以看到我們多了一個 children 的屬性,並將先前定義的 Route 搬到 children 裡面,但這時候回到畫面,只會看到 RootLayout 的畫面,原先 HomePage 的畫面消失了。

這是因為我們必須在 Parent Route(RootLayout) 中,引入 Outlet Component 並渲染,Outlet 的作用為讓 Parent Route 能夠渲染出 Child Route 的畫面。

Root.jsx
import { Outlet } from "react-router-dom";import MainNavigation from "../components/MainNavigation";const RootLayout = () => {  return (    <div>      <MainNavigation />      <Outlet />    </div>  );};export default RootLayout;

Error Element

現在嘗試在網址的地方打上不存在的 Path,會看到以下 404 錯誤畫面:

Image

如果要客製化 404 Not Found 頁面或是錯誤頁面,則可以在 Router 定義 errorElement 屬性,並把要顯示的 Component 定義好。

pages/Error.jsx
import MainNavigation from "../components/MainNavigation";const ErrorPage = () => {  return (    <>      <MainNavigation />      <div>        <h1>An error occurred!!</h1>        <p>Could not find this page</p>      </div>    </>  );};export default ErrorPage;
App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductsPage from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { path: "", element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

如果想要讓使用者了解目前在哪個頁面,則可以使用 NavLink,NavLink 提供了 isActive 的屬性,當 isActive 為 true 時,代表使用者目前在該頁面,所以可以簡單做個判斷,並附上簡單的 CSS。

components/MainNavigation.jsx
import { NavLink } from "react-router-dom";import classes from "./MainNavigation.module.css";const MainNavigation = () => {  return (    <header className={classes.header}>      <ul className={classes.list}>        <li>          <NavLink            to="/"            className={({ isActive }) =>              isActive ? classes.active : undefined            }            // style={({ isActive }) => ({            //   textDecoration: isActive ? "underline" : "none",            // })} 這樣寫 inline-style 也可以 看個人          >            Home          </NavLink>        </li>        <li>          <NavLink            to="/products"            className={({ isActive }) =>              isActive ? classes.active : undefined            }            // style={({ isActive }) => ({            //   textDecoration: isActive ? "underline" : "none",            // })} 這樣寫 inline-style 也可以 看個人          >            Products          </NavLink>        </li>      </ul>    </header>  );};export default MainNavigation;

useNavigate

有時候我們會希望觸發某個 function 後,導向到其他頁面,這時候就可以使用 useNavigate 來達到該功能。

這邊只是 Demo 用,後續不會將該程式碼新增到後面的教學。

pages/Home.jsx
import { Link, useNavigate } from "react-router-dom";const HomePage = () => {  const navigate = useNavigate();  function navigateHandler() {    navigate("/products"); // 當 navigateHandler function 觸發,導向到 /products  }  return (    <div>      <h1>Home</h1>      <p>        Go to <Link to="/products">products</Link>      </p>    </div>  );};export default HomePage;

Dynamic Routes

如果要達到動態 Route 的功能,只需在 path 後面加上 :id 即可。

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage from "./pages/ProductDetail";import ProductsPage from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { path: "", element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

ProductDetail.jsx 中,只需將 useParams 引入,即可取得我們在 path 定義的 id。

pages/ProductDetail.jsx
import { useParams } from "react-router-dom";const ProductDetailPage = () => {  const params = useParams();  return <div>ProductDetail {params.productId}</div>;};export default ProductDetailPage;

然後將 Products.jsx 中的程式碼改成以下,就完成動態 Routes 的功能了:

pages/Products.jsx
import { Link } from "react-router-dom";const PRODUCTS = [  { id: "p1", title: "Product 1" },  { id: "p2", title: "Product 2" },  { id: "p3", title: "Product 3" },];const ProductsPage = () => {  return (    <div>      <h1>Products Page</h1>      <ul>        {PRODUCTS.map((product) => (          <li key={product.id}>            <Link to={`/products/${product.id}`}>{product.title}</Link>          </li>        ))}      </ul>    </div>  );};export default ProductsPage;

Relative Path & Relative Route

假設我們想要在 ProductDetail.jsx 中,實作回到上一頁功能,也就是回到 Product.jsx,你可能會這樣做:

ProductDetail.jsx
import { Link, useParams } from "react-router-dom";const ProductDetailPage = () => {  const params = useParams();  return (    <div>      <h1>ProductDetail {params.productId}</h1>      <p>        <Link to="..">Back</Link>      </p>    </div>  );};export default ProductDetailPage;
備註

.. 是回到上一層的意思,而 . 是當前目錄。

但當我們點擊 Back 後,會發現不是回到 Product.jsx,反而是回到 Home.jsx

這就要從我們設定的 Router 開始解釋,先看一下先前設定的 Router,我們的 Parent Route 在程式碼第 3 行,也就是 path:"/",而底下的 Child Route 為程式碼第 7 行至第 12 行。

這時候如果在 path:"products/:productId" 底下回到上一層,則是會回到 Parent Route,也就是 path:"/"

App.jsx
const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { path: "", element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);

這是因為 React Router Dom 的 Link Component 預設的 relative 屬性是 route,會根據我們設置的 Router 父子關係而有所不同。

但 relative 還有提供另一個值讓我們使用,叫做 path,將 relative 屬性更改為 path 後,就可以解決上述的問題。

path 屬性是將我們當前的網址移除一個 segment,所以假設我們的網址是 http://localhost:5173/products/p1,回到上一層 .. 就是 http://localhost:5173/products

ProductDetail.jsx
import { Link, useParams } from "react-router-dom";const ProductDetailPage = () => {  const params = useParams();  return (    <div>      <h1>ProductDetail {params.productId}</h1>      <p>        <Link to=".." relative="path">          Back        </Link>      </p>    </div>  );};export default ProductDetailPage;

Index

再來看一下我們的 Router,不知道你有沒有發現我們的 <HomePage /> 主頁面的 path 是空值 "",空值的意思就是匹配到 Parent Route,也就是 path : "/"。

App.jsx
const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { path: "", element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);

如果不想將 path 定義為空值來匹配 Parent Route 的話,可以改為 index : true,這樣定義跟 path : "/" 是一樣的。

App.jsx
const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);

Advanced

loader

如果要取得外部 API 資料的話,基本上都會使用 useEffect 搭配 Fetch Function,但 React Router Dom 也有提供 Data Fetching 的功能,叫做 loader

要使用 loader 的話,先在 Products.jsx 定義它然後 export ,也順便將 Products.jsx 裡面的程式碼修改一下:

pages/Products.jsx
import ProductsList from "../components/ProductsList";const ProductsPage = () => {  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  const data = await response.json();  return data.products;};
components/ProductsList.jsx
const ProductsList = () => {  return <div>ProductsList</div>;};export default ProductsList;

之後回到 App.jsxpath: "products" 的地方新增 loader 屬性,並將剛剛的 loader 帶進去。

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage from "./pages/ProductDetail";import ProductsPage, { loader as ProductsLoader } from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,        loader: ProductsLoader,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;
警告

注意 loader 是在 Client-Side 執行,並不是 Server-Side,所以你可以在 loader 裡面使用任何瀏覽器的 Function,例如:localStorage、cookie 等。

現在進入到 Products 頁面,應該會發現 Products 頁面卡了一下才顯示畫面,這時候可以開啟 Networks 來看一下,會發現進來 Products 頁面時,我們發了一筆 API Request。

Image

警告

React Router Dom 會等到 loader 執行完,才去渲染畫面,意思就是要等資料取完才會渲染畫面,後面會講在取資料時要怎麼顯示 Loading 文字在畫面上。

如果要取得 API 資料的話,我們可以在 Products.jsx 引入 useLoaderData

pages/Products.jsx
import { useLoaderData } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const products = useLoaderData();  console.log(products);  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  const data = await response.json();  return data.products;};

Image

loader scope

可以發現我們在 Products.jsx 中渲染了 ProductsList Component,而因為 ProductsList 包含在 Products 底下,所以 ProductsList 也可以使用 useLoaderData 來取得 API 資料。

pages/Products.jsx
import ProductsList from "../components/ProductsList";const ProductsPage = () => {  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  const data = await response.json();  return data.products;};
components/ProductsList.jsx
import { useLoaderData } from "react-router-dom";const ProductsList = () => {  const products = useLoaderData();  console.log("ProductsList : ", products);  return <div>ProductsList</div>;};export default ProductsList;

Image

useNavigation

前面有提到,loader 執行完才會渲染畫面,所以我們可以使用 useNavigation,來判斷目前的狀態為何。

useNavigation 會提供 state,當我們在取得資料時,state 為 loading,而其他時間則為 idle,所以可以判斷當下的 state 是否為 loading。

pages/Root.jsx
import { Outlet, useNavigation } from "react-router-dom";import MainNavigation from "../components/MainNavigation";const RootLayout = () => {  const navigation = useNavigation();  return (    <div>      <MainNavigation />      {navigation.state === "loading" ? <h1>Loading..</h1> : <Outlet />}    </div>  );};export default RootLayout;

Error Handling & Custom Errors

我們當然也可以客製化錯誤訊息,將 Products.jsx 的程式碼改成以下:

pages/Products.jsx
import { useLoaderData } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  if (data.isError) {    return <h1>{data.message}</h1>;  }  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://aaaaaaadummyjson.com/products?limit=5");  if (!response.ok) {    return {      isError: true,      message: "Something went wrong!!!",    };  } else {    const data = await response.json();    return data.products;  }};

到 Products 頁面就會看到我們的錯誤訊息:

Image

Error Handling & Custom Errors (Bubble Up)

上述的客製化錯誤訊息算是比較偷懶的做法,所以我們可以使用較正式的做法,先將 Products.jsx 的程式碼改為以下:

pages/Products.jsx
import { useLoaderData } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://aaaaaaadummyjson.com/products?limit=5");  if (!response.ok) {    throw { message: "Something went wrong!!!" };  } else {    const data = await response.json();    return data.products;  }};

再次來到 Products 頁面後,會發現這次出現的錯誤訊息是之前在 Error.jsx 定義的訊息。

pages/Error.jsx
import MainNavigation from "../components/MainNavigation";const ErrorPage = () => {  return (    <>      <MainNavigation />      <div>        <h1>An error occurred!!</h1>        <p>Could not find this page</p>      </div>    </>  );};export default ErrorPage;

Image

這是因為我們在 Router 中有定義 errorElement,當錯誤發生時,React Router Dom 會先檢查在該 path 底下有沒有定義 errorElement,沒有的話就往上尋找(Bubble Up),找到之後就渲染出 errorElement 的畫面。

App.jsx
const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,        loader: ProductsLoader,      },      { path: "products/:productId", element: <ProductDetailPage /> },    ],  },]);

useRouteError

現在可以再更進階一點,根據 status code 給予不同的錯誤訊息,將 Products.jsx 程式碼改為以下:

pages/Products.jsx
import { useLoaderData } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://aaaaaaadummyjson.com/products?limit=5");  if (!response.ok) {    throw new Response(JSON.stringify({ message: "Something went wrong!!!" }), {      status: 500,    });  } else {    const data = await response.json();    return data.products;  }};

Error.jsx 中,使用 useRouteError 將錯誤資訊取出來,並做判斷來顯示不同的文字。

pages/Error.jsx
import { useRouteError } from "react-router-dom";import MainNavigation from "../components/MainNavigation";const ErrorPage = () => {  const error = useRouteError();  let title = "An error occurred!!";  let message = "Could not find this page";  if (error.status === 500) {    message = JSON.parse(error.data).message;  }  if (error.status === 404) {    title = "Not Found!";    message = "Could not find this page";  }  return (    <>      <MainNavigation />      <div>        <h1>{title}</h1>        <p>{message}</p>      </div>    </>  );};export default ErrorPage;

json

不知道你會不會覺得上述的 Error Handling 方式都有些麻煩,要使用 Response 建立一個物件,並使用 JSON.stringify 將資料轉成 Json 字串,拿資料的時候又要利用 JSON.parse 將資料轉換回來。

所以 React Router Dom 提供了 json 方法讓我們能更方便的處理 Error 訊息。

Products.jsxError.jsx 中的程式碼改為以下即可:

pages/Products.jsx
import { useLoaderData, json } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  return <ProductsList />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://aaaaaaadummyjson.com/products?limit=5");  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  } else {    const data = await response.json();    return data.products;  }};
pages/Error.jsx
import { useRouteError } from "react-router-dom";import MainNavigation from "../components/MainNavigation";const ErrorPage = () => {  const error = useRouteError();  let title = "An error occurred!!";  let message = "Could not find this page";  if (error.status === 500) {    message = error.data.message;  }  if (error.status === 404) {    title = "Not Found!";    message = "Could not find this page";  }  return (    <>      <MainNavigation />      <div>        <h1>{title}</h1>        <p>{message}</p>      </div>    </>  );};export default ErrorPage;

Dynamic Routes & loader

現在先將 Products.jsxProductsList.jsx 的程式碼改成以下:

pages/Products.jsx
import { useLoaderData, json } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  return <ProductsList data={data} />;};export default ProductsPage;export const loader = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  } else {    const data = await response.json();    return data.products;  }};

現在可以使用 id 來找到單一個產品的詳細資料了。

components/ProductList.jsx
import { Link } from "react-router-dom";const ProductsList = ({ data }) => {  return (    <div>      <h1>Products List</h1>      {data.map((product) => (        <div key={product.id}>          <p>{product.title}</p>          <Link to={"/products/" + product.id}>            <img width={100} src={product.images[0]} />          </Link>        </div>      ))}    </div>  );};export default ProductsList;

接著在 components 資料夾底下 新增 ProductItem.jsx,並修改 ProductDetail.jsx 內的程式碼:

components/ProductItem.jsx
const ProductItem = () => {  return <div>ProductItem</div>;};export default ProductItem;

當我們使用 loader 時,它會自帶兩個參數,一個是 request 另一個則是 params,我們可以使用 params 來取得 id,跟 useParams 的用途是一樣的。

這邊也直接將取得的 data 傳遞至 ProductItem Component。

pages/ProductDetail.jsx
import { useLoaderData, json } from "react-router-dom";import ProductItem from "../components/ProductItem";const ProductDetailPage = () => {  const data = useLoaderData();  return <ProductItem data={data} />;};export default ProductDetailPage;export const loader = async ({ request, params }) => {  const id = params.productId;  const response = await fetch(`https://dummyjson.com/products/${id}`);  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  } else {    const data = await response.json();    return data;  }};

別忘記定義完 loader 後,也要在該 path 引入,所以回到 App.jsx,將 loader 新增至 path: "products/:productId"

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage, {  loader as ProductDetailLoader,} from "./pages/ProductDetail";import ProductsPage, { loader as ProductsLoader } from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,        loader: ProductsLoader,      },      {        path: "products/:productId",        element: <ProductDetailPage />,        loader: ProductDetailLoader,      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

現在可以可以修改 ProductItem.jsx 內的程式碼了:

components/ProductItem.jsx
import { Link } from "react-router-dom";const ProductItem = ({ data }) => {  return (    <div>      <p>{data.title}</p>      <img width={100} src={data.images[0]} />      <br />      <Link to=".." relative="path">        Back      </Link>    </div>  );};export default ProductItem;

useRouteLoaderData

useRouteLoaderData 是讓 Child Route 可以去使用 Parent Route 所定義的 loader。

先在 App.jsx 的 Parent Route 的地方新增 id 和 loader:

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage, {  loader as ProductDetailLoader,} from "./pages/ProductDetail";import ProductsPage, { loader as ProductsLoader } from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    id: "root",    loader: () => {      return "Hello World";    },    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,        loader: ProductsLoader,      },      {        path: "products/:productId",        element: <ProductDetailPage />,        loader: ProductDetailLoader,      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

接著我們可以在底下的 Child Route 使用 useRouteLoaderData,將 Home.jsx 程式碼改為以下,即可取得 Parent Route loader 回傳的值:

pages/Home.jsx
import { Link, useRouteLoaderData } from "react-router-dom";const HomePage = () => {  const data = useRouteLoaderData("root"); // 依靠 id 取得 root loader 的值  console.log(data); // Hello World  return (    <div>      <h1>Home</h1>      <p>        Go to <Link to="/products">products</Link>      </p>    </div>  );};export default HomePage;

action

如果我們要提交表單的資料,則可以使用 action,action 和 loader 非常類似,但 action 可以接收表單內的資料,所以 action 通常是拿來發 Post Request,action 和 loader 一樣,都必須在 path 中去定義。

要讓 action 能夠接收表單資料,得先引入 React Router Dom 的 Form,所以先來新增兩個檔案,ProductForm.jsxProductAction.jsx

components/ProductForm.jsx
import { Form } from "react-router-dom";const ProductForm = () => {  return (    <Form method="post">      <p>        <label htmlFor="title">Title</label>        <input type="text" id="title" name="title" />      </p>      <p>        <label htmlFor="price">Price</label>        <input type="text" id="price" name="price" />      </p>      <p>        <label htmlFor="description">Description</label>        <textarea id="description" name="description" />      </p>      <button type="submit">Submit</button>    </Form>  );};export default ProductForm;

我們也可以引入 redirect,假設 Post Request 發送成功的話,就導向至主頁面。

pages/ProductAction.jsx
import { redirect } from "react-router-dom";import ProductForm from "../components/ProductForm";const ProductActionPage = () => {  return <ProductForm />;};export default ProductActionPage;export const action = async ({ request, params }) => {  const data = await request.formData(); // 接收 Form 表單裡面的資料  const productData = {    title: data.get("title"),    price: data.get("price"),    description: data.get("description"),  };  const response = await fetch("https://dummyjson.com/products/add", {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify(productData),  });  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  }  return redirect("/");};

定義完 action 以後,將 ProductAction.jsx 的 path 定義一下,同時將 action 傳入:

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage, {  loader as ProductDetailLoader,} from "./pages/ProductDetail";import ProductActionPage, {  action as ProductAction,} from "./pages/ProductAction";import ProductsPage, { loader as ProductsLoader } from "./pages/Products";import RootLayout from "./pages/Root";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    id: "root",    loader: () => {      return "Hello World";    },    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductsPage />,        loader: ProductsLoader,      },      {        path: "products/:productId",        element: <ProductDetailPage />,        loader: ProductDetailLoader,      },      {        path: "products/add",        element: <ProductActionPage />,        action: ProductAction,      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

Home.jsx 中的程式碼也要修改一下,才能進入到 products/add

App.jsx
import { Link, useRouteLoaderData } from "react-router-dom";const HomePage = () => {  const data = useRouteLoaderData("root");  console.log(data);  return (    <div>      <h1>Home</h1>      <p>        Go to <Link to="/products">products</Link>      </p>      <p>        Go to <Link to="/products/add">add product</Link>      </p>    </div>  );};export default HomePage;

Form Submitting

前面提到 useNavigation 時,有提到 useNavigation 能取得當前的 state,而當我們的 Form 表單正在送出處理時,state 會是 submitting,所以我們也可以利用這一個特性來告知使用者目前表單的處理狀況。

ProductForm.jsx 的程式碼修改為以下:

components/ProductForm.jsx
import { Form, useNavigation } from "react-router-dom";const ProductForm = () => {  const navigation = useNavigation();  const isSubmitting = navigation.state === "submitting";  return (    <Form method="post">      <p>        <label htmlFor="title">Title</label>        <input type="text" id="title" name="title" />      </p>      <p>        <label htmlFor="price">Price</label>        <input type="text" id="price" name="price" />      </p>      <p>        <label htmlFor="description">Description</label>        <textarea id="description" name="description" />      </p>      <button type="submit">{isSubmitting ? "Submit..." : "Submit"}</button>    </Form>  );};export default ProductForm;

defer & Await

要 Demo deferAwait的話,我們需要再新增兩個檔案,ProductDeferTest.jsxProductRoot.jsx

components/ProductDeferTest.jsx
const ProductDeferTest = () => {  return <div>ProductDeferTest</div>;};export default ProductDeferTest;
pages/ProductRoot.jsx
import { Outlet } from "react-router-dom";import ProductDeferTest from "../components/ProductDeferTest";const ProductRoot = () => {  return (    <>      <ProductDeferTest />      <Outlet />    </>  );};export default ProductRoot;

之後再將 App.jsx 內的 Router 更改一下:

App.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";import "./App.css";import ErrorPage from "./pages/Error";import HomePage from "./pages/Home";import ProductDetailPage, {  loader as ProductDetailLoader,} from "./pages/ProductDetail";import ProductActionPage, {  action as ProductAction,} from "./pages/ProductAction";import ProductsPage, { loader as ProductsLoader } from "./pages/Products";import RootLayout from "./pages/Root";import ProductRoot from "./pages/ProductRoot";const router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    id: "root",    loader: () => {      return "Hello World";    },    children: [      { index: true, element: <HomePage /> },      {        path: "products",        element: <ProductRoot />,        children: [          {            index: true,            element: <ProductsPage />,            loader: ProductsLoader,          },          {            path: ":productId",            element: <ProductDetailPage />,            loader: ProductDetailLoader,          },          {            path: "add",            element: <ProductActionPage />,            action: ProductAction,          },        ],      },    ],  },]);function App() {  return (    <div className="App">      <RouterProvider router={router} />    </div>  );}export default App;

現在進入到 path 為 products 底下的頁面,都會看到 ProductRoot.jsx 內的文字 ProductDeferTest

但不知道你有沒有發現一個問題,我們的 ProductDeferTest 文字,是等 loader 處理完 API 資料,並顯示在畫面上後才出現,如果今天 ProductRoot.jsx 內要顯示的畫面對使用者來說是重要的,這樣的使用者體驗就不太好。

Products.jsx 為例,我們可以先將原本的 loader 程式碼搬移出去,建立另外一個 Function,名為 loadProducts,並在原本的 loader return defer 並執行 loadProducts。

pages/Products.jsx
import { useLoaderData, json, defer } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const data = useLoaderData();  return <ProductsList data={data} />;};export default ProductsPage;const loadProducts = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  } else {    const data = await response.json();    return data.products;  }};export const loader = async () => {  return defer({    data: loadProducts(),  });};
備註

defer 內能執行多個 Promise Function,只要給不同的 key 即可,以上述的例子來看,我們的 loadProducts() 對應的 key 為 data,所以在使用 useLoaderData 時,需要將 data 解構出來做使用。

接著需要搭配 Suspense 和 Await,讓資料讀取的時候能顯示文字在畫面上,等到資料讀取完畢後,才會顯示 ProductsList Component 裡面的內容。

pages/Products.jsx
import { Suspense } from "react";import { useLoaderData, json, defer, Await } from "react-router-dom";import ProductsList from "../components/ProductsList";const ProductsPage = () => {  const { data } = useLoaderData();  return (    <Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>      <Await resolve={data}>        {(loadProducts) => <ProductsList data={loadProducts} />}      </Await>    </Suspense>  );};export default ProductsPage;const loadProducts = async () => {  const response = await fetch("https://dummyjson.com/products?limit=5");  if (!response.ok) {    throw json({ message: "Something went wrong!!!" }, { status: 500 });  } else {    const data = await response.json();    return data.products;  }};export const loader = async () => {  return defer({    data: loadProducts(),  });};