Skip to content
CodeSook
CodeSook

React Router V7 ไม่ใช่แค่ Router สำหรับ React แล้วนะ


react-router v7 covere

CodeSookPublish: 29th December 2024

React Router V7 NextJS Alternative

ตอนนี้ React Router ไม่ได้เป็นแค่ Library ที่เอาไว้ใช้ทำ Routing ใน React อีกต่อไป

React Router เป็น Framework แล้ว สามารถทำ features ที่ Server side ทำได้แล้ว เช่น

  • Server Side Rendering (SSR) ได้
  • Static Site Generation (SSG) ได้
  • Code Splitting ได้
  • File based routing ได้

แต่ก็ยังสามารถใช้เป็น Router แบบเดิมได้เหมือนเดิม

และนี่คือ Version ใหม่ของ Remix นะครับ คือควบรวมกันเลย
ใครใช้ Remix v2 แล้วอยาก upgrade ก็มี script auto migrate มาให้ด้วย
ผมก็คิดว่า Remix น่าจะไม่ได้ถูกพัฒนาต่อแล้ว แต่ก็ไม่มีการยืนยันอะไรจากทีมงาน remix run นะครับ


Unified

react router v7 นี้ รวมทุกอย่างไว้ที่ library เดียวเลย

หากใครเคยใช้ react router มาก่อน น่าจะเคยใช้คู่กับ react-router-dom

แต่ตอนนี้ไม่ต้องแล้ว 😎
ทุกอย่างอยู่ใน react-router library เดียว

แต่ถ้าใครใช้ react router version 6 อยู่ก่อนแล้ว แล้วอยากจะ migrate มาใช้ version7 เรายังสามารถใช้ react-router-dom ได้อยู่เหมือนเดิมนะ

แต่ก็แนะนำให้ใช้ตัว react-router ตัว unified แหละ


Framework

ใช้ react router v7 ตอนนี้ ส่วนตัวผมแนะนำให้ใช้แบบ framework นะครับ

มาดูรายละเอียดกัน แต่ไม่ลึกมากนะ

SSR, SPA and Pre-rendering

การเปลี่ยนแปลงรอบนี้ ส่วนที่เห็นได้ชัดมากๆน่าจะเป็นส่วน framework นี้แหละ ทำให้ React router สามารถทำ fullstack website ได้ง่ายขึ้นมากๆ ไม่ต้องพึ่ง framework อื่นๆแล้ว แต่ก็ไม่สามารถทำ REST API แบบ NextJs หรือ Sveltekit ได้นะ

การ set จาก SPA เป็น SSR ก็ทำได้ง่ายๆในไฟล์ react-router.config.ts

react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;

หรือถ้าจะทำ Static Pre-rendering ก็ config แบบนี้

react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
// return a list of URLs to prerender at build time
async prerender() {
return ["/", "/about", "/contact"];
},
} satisfies Config;

Routing

การ config routing จะทำได้ 2 แบบ

  1. Route Module เป็นการ config ผ่าน function layout(), index() และ route() ซึ่งก็จะเป็นท่าที่คนใช้ react router คุ้นเคยอยู่แล้ว แค่เปลี่ยนจะ Tag <Route> มาเป็น function แทน

ตัวอย่าง

routes.ts
import {
type RouteConfig,
route,
index,
} from "@react-router/dev/routes";
export default [
// parent route
route("dashboard", "./dashboard.tsx", [
// child routes
index("./home.tsx"),
route("settings", "./settings.tsx"),
]),
] satisfies RouteConfig;
  1. File based routing (fs-route) อันนี้ก็จะเป็นเหมือน fullstack framework อื่นๆแหละ คือแยก pages ต่างๆตามชื่อไฟล์เลย แต่วิธีการเขียนจะต่างจาก fullstack framework เจ้าอื่นๆเลย แทนที่จะใช้ folder ในการแบ่ง paths ต่างๆตาม URL ที่อยากให้เกิดขึ้น react router ดันไปใช้ dot-delimiters แทนคนที่มาจาก framework อื่นๆอาจจะงงได้

การใช้ file based routing จะต้องติดตั้ง package เพิ่มนะ

Terminal window
pnpm add @react-router/fs-routes

แล้วในไฟล์ routes.ts ก็จะแก้ให้เป็นแบบนี้

routes.ts
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig;

แล้วก็สร้าง page ด้วยการสร้างไฟล์ ประมาณนี้

Terminal window
app/
├── routes/
├── _index.tsx
├── about.tsx
├── concerts.trending.tsx
├── concerts.salt-lake-city.tsx
└── concerts.san-diego.tsx
└── root.tsx

Ready to Deploy

ถ้าเราใช้ตัว framework ที่เป็นการ init project ผ่าน cli เบื้องหลังมันก็ไปเอา template ที่ทีม remix-run ทำไว้ให้
ซึ่งในนั้นเขาเขียน Dockerfile มาให้เราเลย มีครบสำหรับการรันบน Nodejs(npm), Nodejs(pnpm), Bun

Typescript type

typescript ก็ supported แบบเต็มตัว อันนี้ก็ตั้งแต่ version ก่อนๆแล้วนะ แต่ใน framework mode นี้ type ที่เอามาใช้จะเกิดจากการที่ dev server เป็นคน generate มาให้เรา และเราต้อง import ให้ถูกตัวด้วยนะ ถ้าใครเคยใช้ sveltekit ก็ใช้วิธีการเดียวกันนี่แหละ

มาดูตัวอย่างกัน เราจะใช้ file-based routing นะ ใช้ dot-delimiters มาแทน folder

ในที่นี้อยากได้ url /products ก็เลย สร้างไฟล์ routes/products._index.tsx

routes/products._index.tsx
import type { Route } from "./+types/products._index";

จะเห็นว่าเราต้อง import Route มาจาก ./+types/products._index ซึ่งไฟล์นี้เราไม่ได้สร้าง และมันก็ไม่ได้อยู่ใน folder เดียวกันกับที่เราเขียนนี้ด้วย

ซึ่ง file ./+types/products._index นี้เป็นไฟล์ที่ react router สร้างมาให้อัตโนมัติ โดยไฟล์ที่สร้างขึ้นมานี้จะอยู่ใน folder .react-router นะ

อีกตัวอย่าง

อยากได้ url /users ก็จะสร้างไฟล์ users._index.tsx

routes/users._index.tsx
import type { Route } from "./+types/users._index";

data loading

การดึง data มาแสดง ก็จะมี 2 รูปแบบ

  1. โหลด data ที่ฝั่ง client เผื่อว่ามี data ที่อยากโหลดที่ browser เรียกว่า client data loading
  2. โหลด data ที่ฝั่ง server เท่านั้น เรียกว่า server data loading

มาดูกันทีละตัว

1. client data loading

เราจะต้อง export function ที่ชื่อว่า clientLoader() ซึ่ง function parameter จะมี type ที่มาจาก Route.ClientLoaderArgs

/routes/products.tsx
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
export async function clientLoader({
params,
}: Route.ClientLoaderArgs) {
const res = await fetch(`/api/products/${params.pid}`);
const product = await res.json();
return product;
}
export default function Product({
loaderData,
}: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}

จาก Code ตัวอย่างด้านบน

component ของ Page นี้จะต้องใช้ export default นะ ส่วน clientLoader() ให้ export ปกติ

ใน parameter Route.ClientLoaderArgs ไม่ได้มีแค่ params นะ มี request object ให้เราดึง headers ได้ด้วย

เมื่อหน้า page products ถูกโหลดที่ browser browser ก็จะทำการ fetch ไปที่ /api/products/${params.pid} เพื่อขอ data ให้เรา

แล้วจากนั้น ตัว compoment ของ page ก็จะรับ data ผ่าน props นะ ซึ่งก็จะมีเอา type มาจาก Route.ComponentProps มี property ที่ชื่อว่า loaderData

2. Server data loading

มาดูตัวอย่าง code กันก่อนเลย

// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
import { fakeDb } from "../db";
export async function loader({ params }: Route.LoaderArgs) {
const product = await fakeDb.getProduct(params.pid);
return product;
}
export default function Product({
loaderData,
}: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}

ส่วนของ page component ก็ให้ใช้ export default เหมือนเดิม

แต่ส่วน function ที่จะดึง data จาก server เท่านั้นเนี่ย ต้องตั้งชื่อว่า loader() และต้องใช้ export แบบธรรมดา

ส่วน function parameter ก็ใช้ type ที่มาจาก Route.LoaderArgs เพื่อรับ parameter ที่จะใช้ในการดึง data
ตรงนี้ก็เหมือนกัน มี params มี req ให้ใช้เหมือนใน clientLoader() เลย

ส่วนใน page component ก็จะรับ data ที่ได้ผ่าน Props loaderData เหมือนกัน

More

เราสามารถใช้ clientLoader() กับ loader() พร้อมกันได้นะ การทำงานก็จะทำแยกกัน ทำหน้าที่ของใครของมัน เพื่อนๆไปอ่านเพิ่มเติมได้ที่ use both loader

ยังมี Static pre-rendering อีกนะ แต่ไม่ได้มีอะไรซับซ้อน ก็เลยไม่ได้เอามาเล่าให้นะ
เพื่อนๆไปอ่านเพิ่มเติมได้ตรงนี้ static pre-rendering

Action

Action เป็นส่วนที่ใช้กับ Form นะ เวลาเรา submit form มันก็จะมาเรียกใช้ action นี้นี่แหละ

ซึ่งเราสามารถกำหนดได้อีกว่า action ที่อยากให้ทำงานเนี่ย ถูกทำงานที่ไหน ที่ server หรือ ที่ client(browser)

ใน React Router V7 ก็เลยมี action() ที่ทำงานบน server กับ clientAction() ที่ทำงานบน browser

วิธีการใช้เขียนก็เหมือนกับการใช้ loader เลย
มาดูตัวอย่างกัน

Server Action

ตัวอย่างโค้ด

/routes/project.tsx
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { fakeDb } from "../db";
export async function action({
request,
}: Route.ActionArgs) {
let formData = await request.formData();
let title = await formData.get("title");
let project = await fakeDb.updateProject({ title });
return project;
}
export default function Project({
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>Project</h1>
<Form method="post">
<input type="text" name="title" />
<button type="submit">Submit</button>
</Form>
{actionData ? (
<p>{actionData.title} updated</p>
) : null}
</div>
);
}

จากตัวอย่างด้านบน
เราแค่ export action(context: Route.ActionArgs) การทำงานจากการ submit form ก็เขียนใน function นี้แหละ ส่วน type ของ function parameter นี้คือ Route.ActionArgs นะ มีพวก request ให้ใช้เหมือนกัน
และในตัวอย่างนี้จะเห็นว่าถ้า form ทำงานเสร็จก็ return object ที่ต้องการออกไปได้เลย
ที่ page ก็จะเอา object นี้ไปใช้ต่อ จาก props ที่ชื่อว่า actionData และถ้า ้hover ดูที่ actionData ก็จะเห็น type ที่ return จาก action function เลย

แต่ถ้าเกิดว่า form มันเกิด error แล้วเราอยากจะส่ง error กลับไปให้ browser รู้ อยากจะใส่ status code ด้วย เราต้องใช้ตัวช่วยเพิ่มเข้ามา
เราจะใช้ function data() ที่ import มาจาก react-router lib
แบบนี้

/routes/project.tsx
import { data } from "react-router";
export async function action({
request,
}: Route.ActionArgs) {
try {
const formData = await request.formData();
const title = await formData.get("title");
const project = await fakeDb.updateProject({ title });
return project
} catch (error: unknown) {
return data(error, {
status: 500
})
}
}

Client Action

ถ้าเราอยากให้ form submittion ทำงานที่ client เราก็แค่ใช้ function export function clientAction() แทน function action() เท่านั้นเองที่เหลือก็เหมือนกันทั้งหมด

ตัวอย่าง

/routes/project.tsx
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { someApi } from "./api";
export async function clientAction({
request,
}: Route.ClientActionArgs) {
let formData = await request.formData();
let title = await formData.get("title");
let project = await someApi.updateProject({ title });
return project;
}
export default function Project({
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>Project</h1>
<Form method="post">
<input type="text" name="title" />
<button type="submit">Submit</button>
</Form>
{actionData ? (
<p>{actionData.title} updated</p>
) : null}
</div>
);
}

Streaming with Suspense

ส่วนนี้เป็นส่วนที่ผมชอบมากที่สุดในการอัพเดตครั้งนี้เลย

มันคือการที่เราสามารถ response html กลับไปก่อนได้โดยที่หน้านั้นยังโหลดข้อมูลไม่เสร็จทั้งหมด ทำให้ users สามารถเห็นว่าหน้าเว็ปโหลดมาแล้ว แต่มี data บางอย่างที่กำลังตามาทีหลัง

ผมจะยกตัวอย่างอธิบายเพิ่มเติมแบบนี้ ดูภาพประกอบได้ครับ

ปกติแล้วหน้าเว็ปของเราก็จะมีส่วนที่ไม่ได้เปลี่ยนแปลงเลย เช่น nav-bar, logo, menu, footer ซึ่งส่วนนี้เรา render ไปก่อนได้เลยเพราะรู้แน่ๆอยู่แล้วว่าคืออะไร ไม่ต้องรอถาม server ส่วนนี้คือสีขาวในรูป
และก็จะมีบางส่วนที่ต้องไปถาม server แต่ก็รอไม่นาน เช่น account name, account picture ในตัวอย่างนี้คือสีฟ้า ยังมีอีกคือเนื้อหา ในหน้าเว็ป ในแต่ละส่วนไม่จำเป็นต้องรอโหลดเป็นลำดับ สามารถโหลดข้อมูลเหล่านี้จาก server พร้อมๆกันได้เลย แล้วส่วนไหนได้ response ก่อนก็เอามาแสดงผลก่อน
ในรูปจะเป็นสีเขียว สีเหลืองและสีส้ม ทั้งหมดนี้คือเราโหลด data ที่ server ด้วยนะ
พอ server ได้ข้อมูลมาตัว framework ก็จะจัดการ stream ข้อมูลมาที่ browser และจัดการ render ในจุดที่ข้อมูลต้องไปแสดง ส่วนอื่นๆ ก็รอการ stream เพิ่มเติมต่อไปได้

react suspense

มาดูตัวอย่างการเขียนกัน

เราจะใช้ react suspense มาช่วย
และใช้ component <Await> ของ react-router

เราจะใช้ function loader() เพื่อโหลด data ที่ server

ทั้งนี้เราอาจจะดูว่า data ส่วนไหนสำคัญต้องแสดงผลเลย ก็ให้ return object นั้นไปเลย
แต่ส่วนไหนที่รอแสดงผลที่หลังได้ ก็ให้ return Promise

ส่วน type เราก็จะเห็นผ่าน Route.ComponentProps อยู่แล้ว

import type { Route } from "./+types/my-route";
export async function loader({}: Route.LoaderArgs) {
// note this is NOT awaited
let nonCriticalData = new Promise((res) =>
setTimeout(() => res("non-critical"), 5000)
);
let criticalData = await new Promise((res) =>
setTimeout(() => res("critical"), 300)
);
return { nonCriticalData, criticalData };
}

จากตัวอย่างด้านบน เรามี data ที่สำคัญจะต้อง render ทันที เราก็จะให้ render ที่ฝั่ง server ไปเลย คือ criticalData
และมีอีกส่วนหนึ่งคือ data ที่เราไม่ได้สำคัญ เดี๋ยวค่อย render ก็ได้ ก็จะให้ render ที่ฝั่ง client เมื่อ server ได้ data มาแล้ว แล้วค่อยๆ stream data มาที่ client ในตัวอย่างจะเป็น nonCriticalData

จะเห็นว่า ที่ criticalData เราใช้ await เราจะได้ data มาแน่ๆ ส่วน nonCriticalData เราไม่ได้ใช้ await ตรงนี้เราจะได้ Promise มาแทน

ด้านบนนั้นเป็นแค่ loader() function

เรามาดู Page component กันต่อ จะเอา data ที่ได้จาก loader() มาใช้ยังไง

import * as React from "react";
import { Await } from "react-router";
// `export function loader()` ...
export default function MyComponent({
loaderData,
}: Route.ComponentProps) {
let { criticalData, nonCriticalData } = loaderData;
return (
<div>
<h1>Streaming example</h1>
<h2>Critical data value: {criticalData}</h2>
<React.Suspense fallback={<div>Loading...</div>}>
<Await resolve={nonCriticalData}>
{(value) => <h3>Non critical value: {value}</h3>}
</Await>
<NonCriticalUI p={nonCriticalData} />
</React.Suspense>
</div>
);
}

ในส่วน criticalData เราเอาไปใช้ได้เลยตรงๆ เพราะเป็น data จริงๆที่ได้มาจาก server

ส่วน nonCriticalData เราจะเอาไปใช้ตรงๆไม่ได้เพราะมันเป็น Promise ตรงนี้แหละ เราจะเอา <React.Suspense> มาใช้
จะเห็นว่าเราใส่ fallback ลงไปเป็น attribute ของ <React.Suspense> ด้วย ตรงนี้เราสามารถใส่ Component อะไรก็ได้ที่บอก user ว่า data ตรงนี้ยังมาไม่ครบนะ ส่วนใหญ่ก็จะเป็น loading component อะนะ

ถัดมา children ของ React.Suspense component ก็จะใส่ <Await> component ที่มาจาก react-router พร้อมกับใส่ data ที่เป็น Promise ของเราเข้าไปที่ attribute resolve

ถัดมา children ของ <Await> component ก็จะเป็น callback function ที่ต้อง return React Component เอาง่ายๆมันก็เหมือน Promise.then() ที่ต้อง return jsx อะนะ เมื่อ data พร้อมใช้งานเราจะส่งมันให้กับ Component ไหนเพื่อเอาไป render ก็ให้มัน return ตรงนี้แหละ