บทเรียน (Tutorial)
ส่วนที่ 1: ร่างบนกระดาษ (On paper)
บทเรียนนี้จะพาไปดูแอป Real World หรือที่รู้จักกันในชื่อ Conduit เจ้า Conduit เนี่ยมันคือ Medium แบบย่อส่วน — ซึ่งให้เราอ่านและเขียนบทความได้ แถมยังคอมเมนต์บทความของชาวบ้านได้ด้วย

แอปนี้ค่อนข้างเล็ก เราเลยจะทำแบบเรียบง่ายและไม่แยกส่วน (decompose) เยอะเกินไป เป็นไปได้สูงว่าทั้งแอปจะใส่ลงใน 3 layers นี้ได้หมด: App, Pages, และ Shared แต่ถ้าไม่พอ เดี๋ยวเราค่อยเพิ่ม layers ทีหลังได้ พร้อมนะ?
เริ่มจากลิสต์หน้าทั้งหมดออกมา
ถ้าดูจากสกรีนช็อตข้างบน เราน่าจะมีหน้าพวกนี้แน่ๆ:
- Home (ฟีดบทความ)
- Sign in (เข้าสู่ระบบ) และ sign up (สมัครสมาชิก)
- Article reader (หน้าอ่านบทความ)
- Article editor (หน้าเขียนบทความ)
- User profile viewer (หน้าดูโปรไฟล์)
- User profile editor (ตั้งค่าผู้ใช้)
แต่ละหน้าพวกนี้จะกลายเป็น slice (สไลซ์) ของตัวเองใน layer (เลเยอร์) Pages จำได้ไหมจากภาพรวม (overview) ที่บอกว่า slices ก็คือโฟลเดอร์ใน layers และ layers ก็คือโฟลเดอร์ที่มีชื่อตามที่กำหนดไว้ เช่น pages
ดังนั้น โฟลเดอร์ Pages ของเราจะหน้าตาประมาณนี้:
📂 pages/
📁 feed/
📁 sign-in/
📁 article-read/
📁 article-edit/
📁 profile/
📁 settings/
จุดต่างสำคัญของ Feature-Sliced Design เมื่อเทียบกับโครงสร้างโค้ดแบบไม่มีกฎเกณฑ์ คือหน้าต่างๆ (Pages) จะอ้างอิงกันเองไม่ได้ นั่นคือ หน้าหนึ่งจะ import โค้ดจากอีกหน้าหนึ่งไม่ได้ นี่เป็นเพราะ กฎการ import ของ layers:
โมดูล (ไฟล์) ใน slice จะ import slices อื่นได้ก็ต่อเมื่อ slice นั้นอยู่ใน layers ที่ต่ำกว่าเท่านั้น
ในกรณีนี้ Page ถือเป็น slice ดังนั้นโมดูล (ไฟล์) ในหน้านี้จะอ้างอิงโค้ดจาก layers ที่อยู่ต่ำกว่าได้เท่านั้น แต่จะเอาจาก layer เดียวกัน (Pages) ไม่ได้
เจาะลึกหน้า Feed
มุมมองของผู้ใช้ทั่วไป (Anonymous)
มุมมองของผู้ใช้ที่ล็อกอินแล้ว (Authenticated)
มี 3 ส่วนที่ขยับได้ (dynamic areas) บนหน้า feed:
- ลิงก์ Sign-in พร้อมตัวบ่งบอกว่าล็อกอินอยู่หรือเปล่า
- รายการ tags สำหรับกรอง feed
- Feed บทความ (หนึ่งหรือสอง feed) โดยแต่ละบทความมีปุ่มกดไลก์
ลิงก์ Sign-in เป็นส่วนหนึ่งของ Header ที่ใช้ร่วมกันทุกหน้า เดี๋ยวเราค่อยกลับมาดูแยกอีกที
รายการ tags
การสร้างรายการ tags เราต้องดึง tags ที่มีมาแสดงผลแต่ละอันเป็น chip และเก็บ tags ที่เลือกไว้ในฝั่ง client การทำงานพวกนี้แบ่งเป็นหมวดหมู่ได้คือ "การคุยกับ API", "ส่วนติดต่อผู้ใช้ (UI)", และ "การจัดเก็บข้อมูล (Storage)" ตามลำดับ ใน Feature-Sliced Design โค้ดจะถูกแยกตามจุดประสงค์โดยใช้ segments (เซกเมนต์) ซึ่งก็คือโฟลเดอร์ใน slices นั่นเอง เราจะตั้งชื่ออะไรก็ได้ที่สื่อความหมาย แต่มีบางชื่อที่นิยมใช้กันเป็นมาตรฐาน:
- 📂
api/สำหรับการติดต่อกับ backend - 📂
ui/สำหรับโค้ดที่จัดการเรื่องการแสดงผลและหน้าตา - 📂
model/สำหรับ storage และ business logic - 📂
config/สำหรับ feature flags, environment variables และการตั้งค่าอื่นๆ
เราจะเอาโค้ดดึง tags ไว้ใน api, ตัว component tag ไว้ใน ui, และการโต้ตอบกับ storage ไว้ใน model
บทความ (Articles)
ใช้หลักการจัดกลุ่มเดียวกัน เราแยกองค์ประกอบของ article feed ออกเป็น 3 segments เหมือนกัน:
- 📂
api/: ดึงข้อมูลบทความแบบแบ่งหน้า (pagination) พร้อมจำนวนไลก์; กดไลก์บทความ - 📂
ui/:- รายการแท็บ (แสดงแท็บพิเศษถ้าเลือก tag ไว้)
- ตัวบทความ (individual article)
- ตัวแบ่งหน้า (pagination) ที่ใช้งานได้จริง
- 📂
model/: การเก็บข้อมูลฝั่ง client ของบทความที่โหลดมาแล้ว และหน้าปัจจุบัน (ถ้าต้องใช้)
ใช้โค้ดทั่วไปซ้ำ (Reuse generic code)
หน้าเว็บส่วนใหญ่มักมีเจตนาต่างกันชัดเจน แต่บางอย่างก็เหมือนกันทั้งแอป เช่น UI kit ที่ต้องตรงตาม design language หรือรูปแบบการคุยกับ backend ที่เป็น REST API เหมือนกันหมด เนื่องจากการทำ slices นั้นเน้นให้แยกขาดจากกัน (isolated) การแชร์โค้ดจึงเป็นหน้าที่ของ layer ที่ต่ำกว่า นั่นคือ Shared
Shared ต่างจาก layers อื่นตรงที่มันเก็บ segments ไม่ใช่ slices ทำให้ layer Shared เหมือนเป็นลูกผสมระหว่าง layer กับ slice
ปกติแล้วโค้ดใน Shared จะไม่ได้วางแผนไว้ล่วงหน้า แต่มักจะถูกแยกออกมา (extracted) ระหว่างการพัฒนา เพราะตอนทำจริงเราถึงจะรู้ว่าโค้ดส่วนไหนที่ใช้ร่วมกันบ่อยๆ แต่การจดจำไว้ว่าโค้ดประเภทไหนควรอยู่ใน Shared ก็ช่วยได้เยอะ:
- 📂
ui/— UI kit, หน้าตาล้วนๆ ไม่มี business logic เช่น ปุ่ม, modals, inputs - 📂
api/— wrappers สะดวกๆ สำหรับการยิง request (เช่นfetch()บน Web) และอาจรวมฟังก์ชันยิง request ตาม backend specification - 📂
config/— การแปลง environment variables - 📂
i18n/— การตั้งค่ารองรับหลายภาษา - 📂
router/— routing primitives และ route constants
นี่เป็นแค่ตัวอย่าง segment names ใน Shared นะ คุณจะตัดบางอันออกหรือสร้างใหม่เองก็ได้ สิ่งสำคัญเดียวที่ต้องจำไว้ตอนสร้าง segments ใหม่คือ ชื่อ segment ควรสื่อถึง จุดประสงค์ (ทำไม), ไม่ใช่เนื้อแท้ (คืออะไร) ชื่ออย่าง "components", "hooks", "modals" ไม่ควร ใช้ เพราะมันบอกแค่ว่าไฟล์คืออะไร แต่ไม่ได้ช่วยให้ค้นหาโค้ดข้างในง่ายขึ้น การตั้งชื่อแบบนั้นจะทำให้ทีมต้องรื้อดูทุกไฟล์ในโฟลเดอร์ และยังทำให้โค้ดที่ไม่เกี่ยวข้องกันมาอยู่ใกล้กัน ซึ่งจะทำให้การ refactor กระทบวงกว้าง และทำให้ code review กับ testing ยากขึ้นด้วย
กำหนด Public API ที่เข้มงวด
ในบริบทของ Feature-Sliced Design คำว่า public API หมายถึงการที่ slice หรือ segment ประกาศว่าโมดูลอื่นๆ ในโปรเจกต์สามารถ import อะไรจากมันได้บ้าง เช่น ใน JavaScript ก็อาจเป็นไฟล์ index.js ที่ re-export objects จากไฟล์อื่นๆ ใน slice วิธีนี้ทำให้เรามีอิสระในการ refactor โค้ดภายใน slice ตราบใดที่ข้อตกลงกับโลกภายนอก (public API) ยังเหมือนเดิม
สำหรับ layer Shared ที่ไม่มี slices การแยก public API ให้แต่ละ segment มักจะสะดวกกว่าการรวมทุกอย่างไว้ใน index เดียวของ Shared วิธีนี้ช่วยให้ imports จาก Shared เป็นระเบียบตามเจตนาการใช้งาน ส่วน layers อื่นที่มี slices จะกลับกัน — การมี index เดียวต่อ slice มักจะเวิร์กกว่า แล้วให้ slice ตัดสินใจเองว่าจะแบ่ง segments ข้างในยังไงโดยที่โลกภายนอกไม่ต้องรู้ เพราะ layers อื่นมักจะมี exports น้อยกว่าเยอะ
Slices/segments ของเราจะมองเห็นกันและกันแบบนี้:
📂 pages/
📁 feed/
📄 index
📁 sign-in/
📄 index
📁 article-read/
📄 index
📁 …
📂 shared/
📂 ui/
📄 index
📂 api/
📄 index
📁 …
อะไรที่ซ่อนอยู่ในโฟลเดอร์อย่าง pages/feed หรือ shared/ui จะรู้กันแค่ในโฟลเดอร์นั้น ไฟล์อื่นไม่ควรไปยุ่งกับโครงสร้างภายในของมัน
บล็อก UI ขนาดใหญ่ที่ใช้ซ้ำ (Large reused blocks in the UI)
ก่อนหน้านี้เราติดเรื่อง Header ที่โผล่ทุกหน้าไว้ การสร้างใหม่ทุกหน้าคงไม่เข้าท่า ดังนั้นการใช้ซ้ำคือคำตอบ เรามี Shared ไว้แชร์โค้ดอยู่แล้ว แต่เดี๋ยวก่อน! มีข้อควรระวังถ้าวาง UI ก้อนใหญ่ไว้ใน Shared — เพราะ layer Shared ไม่รู้เรื่อง layers ที่อยู่เหนือมันเลย
ระหว่าง Shared กับ Pages ยังมีอีก 3 layers: Entities, Features, และ Widgets บางโปรเจกต์อาจมีของใน layers พวกนี้ที่จำเป็นต้องใช้ใน UI block ใหญ่ๆ นั้น ซึ่งหมายความว่าเราเอา block นั้นไปไว้ใน Shared ไม่ได้ ไม่งั้นมันจะ import ของจาก layers ข้างบน ซึ่งผิดกฎ นั่นเลยเป็นที่มาของ layer Widgets มันอยู่เหนือ Shared, Entities, และ Features ทำให้มันเรียกใช้ของทั้งหมดนี้ได้
ในกรณีเรา Header เรียบง่ายมาก — มีแค่โลโก้นิ่งๆ กับเมนูนำทาง (navigation) เมนูอาจต้องยิง request ไป API เพื่อเช็คว่าล็อกอินยัง แต่เรื่องนั้นจัดการได้ด้วยการ import จาก api segment ดังนั้น เราเอา Header ไว้ใน Shared ได้เลย
เจาะลึกหน้าที่มีฟอร์ม (Close look at a page with a form)
มาดูหน้าที่มีไว้แก้ไขข้อมูลกันบ้าง ไม่ใช่แค่อ่านอย่างเดียว เช่น หน้าเขียนบทความ:

ดูเหมือนง่าย แต่มีหลายมิติของการทำแอปที่เรายังไม่ได้พูดถึง — การตรวจสอบความถูกต้องของฟอร์ม (form validation), สถานะ error, และการบันทึกข้อมูล (persistence)
ถ้าเราจะสร้างหน้านี้ เราคงหยิบ inputs กับปุ่มจาก Shared มาประกอบเป็นฟอร์มใน ui segment ของหน้านี้ จากนั้นใน api segment เราจะกำหนด mutation request เพื่อสร้างบทความบน backend
เพื่อตรวจสอบ request ก่อนส่ง เราต้องมี validation schema และที่ที่เหมาะจะวางมันคือ model segment เพราะมันคือ data model เราจะสร้างข้อความ error ที่นั่นและแสดงผลด้วย component อีกตัวใน ui segment
เพื่อประสบการณ์การใช้งานที่ดีขึ้น เราอาจจะบันทึก inputs ไว้กันพลาด (ข้อมูลหาย) นี่ก็เป็นหน้าที่ของ model segment เหมือนกัน
สรุป (Summary)
เราได้สำรวจหลายหน้าและวางโครงสร้างคร่าวๆ ของแอปเราได้แล้ว:
- Shared layer
uiจะเก็บ UI kit ที่ใช้ซ้ำได้apiจะเก็บการติดต่อพื้นฐานกับ backend- ส่วนที่เหลือค่อยเพิ่มเมื่อจำเป็น
- Pages layer — แต่ละหน้าแยกเป็น slice ต่างหาก
uiจะเก็บหน้าเว็บและส่วนประกอบทั้งหมดของหน้านั้นapiจะเก็บการดึงข้อมูลเฉพาะทาง โดยเรียกใช้shared/apimodelอาจเก็บข้อมูลฝั่ง client ที่เราจะแสดงผล
ลุยสร้างกันเลยดีกว่า!
ส่วนที่ 2: ลงมือโค้ด (In code)
ตอนนี้เรามีแผนแล้ว มาลงมือทำจริงกัน เราจะใช้ React และ Remix
มี template เตรียมไว้ให้แล้ว clone จาก GitHub เพื่อเริ่มต้นได้เลย: https://github.com/feature-sliced/tutorial-conduit/tree/clean
ติดตั้ง dependencies ด้วย npm install และเริ่ม development server ด้วย npm run dev เปิด http://localhost:3000 แล้วจะเห็นแอปหน้าขาวๆ
วางโครงร่างหน้าเว็บ (Lay out the pages)
เริ่มจากสร้าง components เปล่าๆ สำหรับทุกหน้า รันคำสั่งนี้ในโปรเจกต์:
npx fsd pages feed sign-in article-read article-edit profile settings --segments ui
คำสั่งนี้จะสร้างโฟลเดอร์พวก pages/feed/ui/ และไฟล์ index pages/feed/index.ts ให้ทุกหน้า
เชื่อมต่อหน้า Feed
มาเชื่อม root route ของแอปเข้ากับหน้า feed สร้าง component FeedPage.tsx ใน pages/feed/ui แล้วใส่โค้ดนี้ลงไป:
export function FeedPage() {
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
</div>
);
}
จากนั้น re-export component นี้ใน public API ของหน้า feed ซึ่งก็คือไฟล์ pages/feed/index.ts:
export { FeedPage } from "./ui/FeedPage";
ทีนี้เชื่อมมันเข้ากับ root route ใน Remix การ routing จะอ้างอิงตามไฟล์ (file-based) และไฟล์ route จะอยู่ในโฟลเดอร์ app/routes ซึ่งเข้ากับ Feature-Sliced Design ได้ดีเลย
ใช้ component FeedPage ใน app/routes/_index.tsx:
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
ถ้าคุณรัน dev server แล้วเปิดแอป ตอนนี้ควรจะเห็นแบนเนอร์ Conduit แล้ว!

API client
เพื่อคุยกับ RealWorld backend เรามาสร้าง API client สะดวกๆ ใน Shared กัน สร้าง 2 segments คือ api สำหรับ client และ config สำหรับตัวแปรต่างๆ เช่น backend base URL:
npx fsd shared --segments api config
แล้วสร้าง shared/config/backend.ts:
export { mockBackendUrl as backendBaseUrl } from "mocks/handlers";
export { backendBaseUrl } from "./backend";
เนื่องจากโปรเจกต์ RealWorld มี OpenAPI specification เตรียมไว้ให้ เราเลยใช้ประโยชน์จาก auto-generated types ได้ เราจะใช้ แพ็กเกจ openapi-fetch ซึ่งมาพร้อมตัวสร้าง type
รันคำสั่งเพื่อสร้าง API typings ล่าสุด:
npm run generate-api-types
จะได้ไฟล์ shared/api/v1.d.ts เราจะใช้ไฟล์นี้สร้าง typed API client ใน shared/api/client.ts:
import createClient from "openapi-fetch";
import { backendBaseUrl } from "shared/config";
import type { paths } from "./v1";
export const { GET, POST, PUT, DELETE } = createClient<paths>({ baseUrl: backendBaseUrl });
export { GET, POST, PUT, DELETE } from "./client";
ข้อมูลจริงใน Feed
ตอนนี้เราเพิ่มบทความลงใน feed ได้แล้วโดยดึงจาก backend เริ่มจากทำ component แสดงตัวอย่างบทความ (article preview) กัน
สร้าง pages/feed/ui/ArticlePreview.tsx ใส่เนื้อหาตามนี้:
export function ArticlePreview({ article }) { /* TODO */ }
เพราะเราเขียน TypeScript การมี typed article object คงจะดี ถ้าไปดูใน v1.d.ts ที่เจนมา จะเห็นว่า article object อยู่ที่ components["schemas"]["Article"] ดังนั้นสร้างไฟล์ data models ใน Shared แล้ว export models ออกมา:
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
กลับมาที่ article preview component ใส่ข้อมูลลงใน markup อัปเดตไฟล์ดังนี้:
import { Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
ปุ่มไลก์ตอนนี้ยังกดไม่ได้ เดี๋ยวเราค่อยมาแก้ตอนทำหน้า article reader และใส่ฟังก์ชันกดไลก์
ตอนนี้เราดึงบทความมาแสดงเป็นการ์ดเรียงกันได้แล้ว การดึงข้อมูลใน Remix ใช้ loaders — ฟังก์ชันฝั่ง server ที่ดึงเฉพาะสิ่งที่หน้าเว็บต้องการ Loaders คุยกับ API ในนามของหน้าเว็บ เราเลยเอามาไว้ใน api segment ของหน้า:
import { json } from "@remix-run/node";
import { GET } from "shared/api";
export const loader = async () => {
const { data: articles, error, response } = await GET("/articles");
if (error !== undefined) {
throw json(error, { status: response.status });
}
return json({ articles });
};
เชื่อมต่อกับหน้าเว็บ ต้อง export ชื่อ loader จากไฟล์ route:
export { FeedPage } from "./ui/FeedPage";
export { loader } from "./api/loader";
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export { loader } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
ขั้นตอนสุดท้ายคือแสดงการ์ดบทความเหล่านี้ใน feed อัปเดต FeedPage ของคุณ:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
</div>
</div>
</div>
);
}
การกรองด้วย Tag (Filtering by tag)
สำหรับ tags หน้าที่ของเราคือดึงมันจาก backend และเก็บ tag ที่เลือกอยู่ เราทำเรื่อง fetching เป็นแล้ว — ก็แค่ request ใน loader อีกตัว เราจะใช้ฟังก์ชันสะดวกๆ promiseHash จากแพ็กเกจ remix-utils ที่ติดตั้งไว้แล้ว
อัปเดตไฟล์ loader pages/feed/api/loader.ts ตามนี้:
import { json } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async () => {
return json(
await promiseHash({
articles: throwAnyErrors(GET("/articles")),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
อาจจะสังเกตเห็นว่าเราแยกส่วน error handling ออกไปเป็น generic function throwAnyErrors ดูมีประโยชน์ดี อาจจะต้องใช้ซ้ำวันหลัง แต่ตอนนี้พักไว้ก่อน
มาที่รายการ tags มันต้อง interactive — คลิกที่ tag แล้ว tag นั้นต้องถูกเลือก ตามธรรมเนียม Remix เราจะใช้ URL search parameters เป็นที่เก็บค่า tag ที่เลือก ให้ browser จัดการ storage ไป เราไปโฟกัสเรื่องสำคัญดีกว่า
อัปเดต pages/feed/ui/FeedPage.tsx ด้วยโค้ดนี้:
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles, tags } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
จากนั้นเราต้องใช้ search parameter tag ใน loader ของเรา เปลี่ยนฟังก์ชัน loader ใน pages/feed/api/loader.ts เป็นแบบนี้:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", { params: { query: { tag: selectedTag } } }),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
เรียบร้อย ไม่ต้องมี model segment เลย Remix นี่ของดีจริงๆ
การแบ่งหน้า (Pagination)
ในทำนองเดียวกัน เราทำ pagination ได้ ลองทำเองดูก็ได้นะ หรือจะก๊อปโค้ดข้างล่างนี้ก็ได้ ไม่มีใครว่าหรอก
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const [searchParams] = useSearchParams();
const { articles, tags } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button
className="page-link"
name="page"
value={index + 1}
>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
จบไปอีกเรื่อง ยังมีเรื่อง tab list ที่ทำคล้ายๆ กันได้ แต่พักไว้ก่อนจนกว่าจะทำ authentication เสร็จ พูดถึงก็มาพอดี!
Authentication
Authentication เกี่ยวข้องกับ 2 หน้า — หน้าหนึ่งล็อกอิน อีกหน้าลงทะเบียน ทั้งสองหน้าเหมือนกันมาก เลย make sense ที่จะเก็บไว้ใน slice เดียวกันคือ sign-in เพื่อให้แชร์โค้ดกันได้ถ้จำเป็น
สร้าง RegisterPage.tsx ใน ui segment ของ pages/sign-in ตามนี้:
import { Form, Link, useActionData } from "@remix-run/react";
import type { register } from "../api/register";
export function RegisterPage() {
const registerData = useActionData<typeof register>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign up</h1>
<p className="text-xs-center">
<Link to="/login">Have an account?</Link>
</p>
{registerData?.error && (
<ul className="error-messages">
{registerData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="username"
placeholder="Username"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="email"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="password"
name="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
เรามี import ที่พังต้องแก้ มันเกี่ยวข้องกับ segment ใหม่ สร้างเลย:
npx fsd pages sign-in -s api
แต่ก่อนจะ implement ส่วน backend ของการลงทะเบียน เราต้องมี infrastructure code ให้ Remix จัดการ sessions ก่อน อันนี้ไปที่ Shared เผื่อหน้าอื่นต้องใช้
ใส่โค้ดนี้ใน shared/api/auth.server.ts อันนี้เฉพาะทางของ Remix มากๆ ไม่ต้องกังวลมาก ก๊อป-วางได้เลย:
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { User } from "./models";
invariant(
process.env.SESSION_SECRET,
"SESSION_SECRET must be set for authentication to work",
);
const sessionStorage = createCookieSessionStorage<{
user: User;
}>({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
export async function createUserSession({
request,
user,
redirectTo,
}: {
request: Request;
user: User;
redirectTo: string;
}) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
session.set("user", user);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7, // 7 วัน
}),
},
});
}
export async function getUserFromSession(request: Request) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
return session.get("user") ?? null;
}
export async function requireUser(request: Request) {
const user = await getUserFromSession(request);
if (user === null) {
throw redirect("/login");
}
return user;
}
และ export User model จากไฟล์ models.ts ที่อยู่ข้างๆ กันด้วย:
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export type User = components["schemas"]["User"];
ก่อนโค้ดนี้จะทำงานได้ ต้องตั้งค่า SESSION_SECRET environment variable ก่อน สร้างไฟล์ชื่อ .env ที่ root ของโปรเจกต์ เขียนว่า SESSION_SECRET= แล้วพิมพ์มั่วๆ ลงไปให้ได้ string ยาวๆ จะได้หน้าตาประมาณนี้:
SESSION_SECRET=dontyoudarecopypastethis
สุดท้าย เพิ่ม exports ใน public API เพื่อให้เรียกใช้โค้ดพวกนี้ได้:
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
ตอนนี้เราเขียนโค้ดคุยกับ RealWorld backend เพื่อทำการลงทะเบียนจริงๆ ได้แล้ว เราจะเก็บไว้ใน pages/sign-in/api สร้างไฟล์ชื่อ register.ts แล้วใส่โค้ดนี้:
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const register = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const username = formData.get("username")?.toString() ?? "";
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users", {
body: { user: { email, password, username } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
เกือบเสร็จแล้ว! เหลือแค่เชื่อมหน้ากับ action เข้ากับ route /register สร้าง register.tsx ใน app/routes:
import { RegisterPage, register } from "pages/sign-in";
export { register as action };
export default RegisterPage;
ถ้าตอนนี้ไปที่ http://localhost:3000/register คุณน่าจะสร้าง user ใหม่ได้แล้ว! ส่วนอื่นๆ ของแอปอาจจะยังไม่ตอบสนอง เดี๋ยวเรามาจัดการกัน
ด้วยวิธีคล้ายๆ กัน เราทำหน้า login ได้ ลองทำดูหรือจะก๊อปโค้ดไปเลยก็ได้:
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const signIn = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users/login", {
body: { user: { email, password } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
import { Form, Link, useActionData } from "@remix-run/react";
import type { signIn } from "../api/sign-in";
export function SignInPage() {
const signInData = useActionData<typeof signIn>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign in</h1>
<p className="text-xs-center">
<Link to="/register">Need an account?</Link>
</p>
{signInData?.error && (
<ul className="error-messages">
{signInData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="email"
type="text"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="password"
type="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign in
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
export { SignInPage } from './ui/SignInPage';
export { signIn } from './api/sign-in';
import { SignInPage, signIn } from "pages/sign-in";
export { signIn as action };
export default SignInPage;
ทีนี้มาเพิ่มทางเข้าให้ user เข้าถึงหน้าพวกนี้กัน
Header (ส่วนหัว)
อย่างที่เราคุยกันในส่วนที่ 1 แอป header มักจะวางไว้ใน Widgets หรือไม่ก็ Shared เราจะเอาไว้ Shared เพราะมันง่ายมากและ business logic ทั้งหมดแยกออกไปได้ มาสร้างที่อยู่ให้มันกัน:
npx fsd shared ui
สร้าง shared/ui/Header.tsx ใส่เนื้อหาตามนี้:
import { useContext } from "react";
import { Link, useLocation } from "@remix-run/react";
import { CurrentUser } from "../api/currentUser";
export function Header() {
const currentUser = useContext(CurrentUser);
const { pathname } = useLocation();
return (
<nav className="navbar navbar-light">
<div className="container">
<Link className="navbar-brand" to="/" prefetch="intent">
conduit
</Link>
<ul className="nav navbar-nav pull-xs-right">
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/" ? "active" : ""}`}
to="/"
>
Home
</Link>
</li>
{currentUser == null ? (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/login" ? "active" : ""}`}
to="/login"
>
Sign in
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/register" ? "active" : ""}`}
to="/register"
>
Sign up
</Link>
</li>
</>
) : (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/editor" ? "active" : ""}`}
to="/editor"
>
<i className="ion-compose"></i> New Article{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/settings" ? "active" : ""}`}
to="/settings"
>
{" "}
<i className="ion-gear-a"></i> Settings{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname.includes("/profile") ? "active" : ""}`}
to={`/profile/${currentUser.username}`}
>
{currentUser.image && (
<img
width={25}
height={25}
src={currentUser.image}
className="user-pic"
alt=""
/>
)}
{currentUser.username}
</Link>
</li>
</>
)}
</ul>
</div>
</nav>
);
}
Export component นี้จาก shared/ui:
export { Header } from "./Header";
ใน header เราพึ่งพา context ที่เก็บไว้ใน shared/api สร้างมันขึ้นมาด้วย:
import { createContext } from "react";
import type { User } from "./models";
export const CurrentUser = createContext<User | null>(null);
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
export { CurrentUser } from "./currentUser";
ทีนี้เพิ่ม header เข้าไปในหน้าเว็บ เราอยากให้มันอยู่ทุกหน้า เลย make sense ที่จะเพิ่มมันใน root route และครอบ outlet (ที่ที่หน้าเว็บจะถูก render) ด้วย CurrentUser context provider ด้วยวิธีนี้ทั้งแอปและ header จะเข้าถึง user object ปัจจุบันได้ เราจะเพิ่ม loader เพื่อดึง user object จาก cookies มาจริงๆ ใส่โค้ดนี้ลงใน app/root.tsx:
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { Header } from "shared/ui";
import { getUserFromSession, CurrentUser } from "shared/api";
export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export const loader = ({ request }: LoaderFunctionArgs) =>
getUserFromSession(request);
export default function App() {
const user = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<link
href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
rel="stylesheet"
type="text/css"
/>
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet"
type="text/css"
/>
<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<style>{`
button {
border: 0;
}
`}</style>
</head>
<body>
<CurrentUser.Provider value={user}>
<Header />
<Outlet />
</CurrentUser.Provider>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
ถึงจุดนี้ คุณควรจะได้หน้าตาแบบนี้บนหน้า home:

Tabs (แท็บ)
ตอนนี้เราตรวจจับสถานะ authentication ได้แล้ว มาทำ tabs และ post likes ให้เสร็จกัน จะได้จบเรื่องหน้า feed เราต้องใช้อีก form แต่ไฟล์หน้าเริ่มใหญ่แล้ว ย้าย forms พวกนี้ไปไฟล์ข้างเคียงกันดีกว่า สร้าง Tabs.tsx, PopularTags.tsx, และ Pagination.tsx ด้วยเนื้อหาตามนี้:
import { useContext } from "react";
import { Form, useSearchParams } from "@remix-run/react";
import { CurrentUser } from "shared/api";
export function Tabs() {
const [searchParams] = useSearchParams();
const currentUser = useContext(CurrentUser);
return (
<Form>
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
{currentUser !== null && (
<li className="nav-item">
<button
name="source"
value="my-feed"
className={`nav-link ${searchParams.get("source") === "my-feed" ? "active" : ""}`}
>
Your Feed
</button>
</li>
)}
<li className="nav-item">
<button
className={`nav-link ${searchParams.has("tag") || searchParams.has("source") ? "" : "active"}`}
>
Global Feed
</button>
</li>
{searchParams.has("tag") && (
<li className="nav-item">
<span className="nav-link active">
<i className="ion-pound"></i> {searchParams.get("tag")}
</span>
</li>
)}
</ul>
</div>
</Form>
);
}
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
export function PopularTags() {
const { tags } = useLoaderData<typeof loader>();
return (
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page", "source"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
);
}
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
export function Pagination() {
const [searchParams] = useSearchParams();
const { articles } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button className="page-link" name="page" value={index + 1}>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
);
}
ตอนนี้เราลดรูปหน้า feed ลงได้เยอะเลย:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
import { Tabs } from "./Tabs";
import { PopularTags } from "./PopularTags";
import { Pagination } from "./Pagination";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
<Tabs />
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Pagination />
</div>
<div className="col-md-3">
<PopularTags />
</div>
</div>
</div>
</div>
);
}
เราต้องรองรับแท็บใหม่ในฟังก์ชัน loader ด้วย:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
/* unchanged */
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
if (url.searchParams.get("source") === "my-feed") {
const userSession = await requireUser(request);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles/feed", {
params: {
query: {
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
headers: { Authorization: `Token ${userSession.token}` },
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
}
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
ก่อนทิ้งหน้า feed ไป มาเพิ่มโค้ดจัดการไลก์กัน แก้ ArticlePreview.tsx เป็นแบบนี้:
import { Form, Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<Form
method="post"
action={`/article/${article.slug}`}
preventScrollReset
>
<button
name="_action"
value={article.favorited ? "unfavorite" : "favorite"}
className={`btn ${article.favorited ? "btn-primary" : "btn-outline-primary"} btn-sm pull-xs-right`}
>
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</Form>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
โค้ดนี้จะส่ง POST request ไปที่ /article/:slug พร้อม _action=favorite เพื่อกดไลก์บทความ ตอนนี้ยังไม่ทำงานนะ แต่พอเราเริ่มทำหน้า article reader เราจะทำส่วนนี้ด้วย
และแล้วเราก็ทำหน้า feed เสร็จอย่างเป็นทางการ! เย้!
Article reader (หน้าอ่านบทความ)
อย่างแรก ข้อมูล มาสร้าง loader กัน:
npx fsd pages article-read -s api
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, getUserFromSession } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
invariant(params.slug, "Expected a slug parameter");
const currentUser = await getUserFromSession(request);
const authorization = currentUser
? { Authorization: `Token ${currentUser.token}` }
: undefined;
return json(
await promiseHash({
article: throwAnyErrors(
GET("/articles/{slug}", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
comments: throwAnyErrors(
GET("/articles/{slug}/comments", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
}),
);
};
export { loader } from "./api/loader";
เชื่อมต่อกับ route /article/:slug โดยสร้างไฟล์ route ชื่อ article.$slug.tsx:
export { loader } from "pages/article-read";
ตัวหน้าเว็บประกอบด้วย 3 ส่วนหลัก — header ของบทความพร้อม actions (มี 2 จุด), เนื้อหาบทความ, และส่วน comments นี่คือ markup ของหน้า ไม่ค่อยมีอะไรน่าตื่นเต้นเท่าไหร่:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticleMeta } from "./ArticleMeta";
import { Comments } from "./Comments";
export function ArticleReadPage() {
const { article } = useLoaderData<typeof loader>();
return (
<div className="article-page">
<div className="banner">
<div className="container">
<h1>{article.article.title}</h1>
<ArticleMeta />
</div>
</div>
<div className="container page">
<div className="row article-content">
<div className="col-md-12">
<p>{article.article.body}</p>
<ul className="tag-list">
{article.article.tagList.map((tag) => (
<li className="tag-default tag-pill tag-outline" key={tag}>
{tag}
</li>
))}
</ul>
</div>
</div>
<hr />
<div className="article-actions">
<ArticleMeta />
</div>
<div className="row">
<Comments />
</div>
</div>
</div>
);
}
สิ่งที่น่าสนใจกว่าคือ ArticleMeta และ Comments เพราะมันมีการเขียนข้อมูล (write operations) เช่น กดไลก์บทความ, คอมเมนต์ ฯลฯ เพื่อให้มันทำงานได้ เราต้อง implement ฝั่ง backend ก่อน สร้าง action.ts ใน api segment ของหน้า:
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { namedAction } from "remix-utils/named-action";
import { redirectBack } from "remix-utils/redirect-back";
import invariant from "tiny-invariant";
import { DELETE, POST, requireUser } from "shared/api";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const currentUser = await requireUser(request);
const authorization = { Authorization: `Token ${currentUser.token}` };
const formData = await request.formData();
return namedAction(formData, {
async delete() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirect("/");
},
async favorite() {
invariant(params.slug, "Expected a slug parameter");
await POST("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfavorite() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async createComment() {
invariant(params.slug, "Expected a slug parameter");
const comment = formData.get("comment");
invariant(typeof comment === "string", "Expected a comment parameter");
await POST("/articles/{slug}/comments", {
params: { path: { slug: params.slug } },
headers: { ...authorization, "Content-Type": "application/json" },
body: { comment: { body: comment } },
});
return redirectBack(request, { fallback: "/" });
},
async deleteComment() {
invariant(params.slug, "Expected a slug parameter");
const commentId = formData.get("id");
invariant(typeof commentId === "string", "Expected an id parameter");
const commentIdNumeric = parseInt(commentId, 10);
invariant(
!Number.isNaN(commentIdNumeric),
"Expected a numeric id parameter",
);
await DELETE("/articles/{slug}/comments/{id}", {
params: { path: { slug: params.slug, id: commentIdNumeric } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async followAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await POST("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfollowAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await DELETE("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
});
};
Export จาก slice แล้วก็ตามด้วย route และเชื่อมหน้าเว็บไปด้วยเลย:
export { ArticleReadPage } from "./ui/ArticleReadPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleReadPage } from "pages/article-read";
export { loader, action } from "pages/article-read";
export default ArticleReadPage;
ตอนนี้ถึงแม้เราจะยังไม่ได้ใส่ปุ่มไลก์ในหน้าอ่าน แต่ปุ่มไลก์ในหน้า feed จะเริ่มทำงานแล้ว! เพราะมันส่ง "like" requests มาที่ route นี้ ลองเล่นดูได้
ArticleMeta และ Comments ก็เป็นกลุ่มของ forms เหมือนกัน เราเคยทำมาแล้ว ก๊อปโค้ดไปใช้โลด:
import { Form, Link, useLoaderData } from "@remix-run/react";
import { useContext } from "react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function ArticleMeta() {
const currentUser = useContext(CurrentUser);
const { article } = useLoaderData<typeof loader>();
return (
<Form method="post">
<div className="article-meta">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
>
<img src={article.article.author.image} alt="" />
</Link>
<div className="info">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
className="author"
>
{article.article.author.username}
</Link>
<span className="date">{article.article.createdAt}</span>
</div>
{article.article.author.username == currentUser?.username ? (
<>
<Link
prefetch="intent"
to={`/editor/${article.article.slug}`}
className="btn btn-sm btn-outline-secondary"
>
<i className="ion-edit"></i> Edit Article
</Link>
<button
name="_action"
value="delete"
className="btn btn-sm btn-outline-danger"
>
<i className="ion-trash-a"></i> Delete Article
</button>
</>
) : (
<>
<input
name="username"
value={article.article.author.username}
type="hidden"
/>
<button
name="_action"
value={
article.article.author.following
? "unfollowAuthor"
: "followAuthor"
}
className={`btn btn-sm ${article.article.author.following ? "btn-secondary" : "btn-outline-secondary"}`}
>
<i className="ion-plus-round"></i>
{" "}
{article.article.author.following
? "Unfollow"
: "Follow"}{" "}
{article.article.author.username}
</button>
<button
name="_action"
value={article.article.favorited ? "unfavorite" : "favorite"}
className={`btn btn-sm ${article.article.favorited ? "btn-primary" : "btn-outline-primary"}`}
>
<i className="ion-heart"></i>
{article.article.favorited
? "Unfavorite"
: "Favorite"}{" "}
Post{" "}
<span className="counter">
({article.article.favoritesCount})
</span>
</button>
</>
)}
</div>
</Form>
);
}
import { useContext } from "react";
import { Form, Link, useLoaderData } from "@remix-run/react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function Comments() {
const { comments } = useLoaderData<typeof loader>();
const currentUser = useContext(CurrentUser);
return (
<div className="col-xs-12 col-md-8 offset-md-2">
{currentUser !== null ? (
<Form
preventScrollReset={true}
method="post"
className="card comment-form"
>
<div className="card-block">
<textarea
required
className="form-control"
name="comment"
placeholder="Write a comment..."
rows={3}
></textarea>
</div>
<div className="card-footer">
<img
src={currentUser.image}
className="comment-author-img"
alt=""
/>
<button
className="btn btn-sm btn-primary"
name="_action"
value="createComment"
>
Post Comment
</button>
</div>
</Form>
) : (
<div className="row">
<div className="col-xs-12 col-md-8 offset-md-2">
<p>
<Link to="/login">Sign in</Link>
or
<Link to="/register">Sign up</Link>
to add comments on this article.
</p>
</div>
</div>
)}
{comments.comments.map((comment) => (
<div className="card" key={comment.id}>
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
<img
src={comment.author.image}
className="comment-author-img"
alt=""
/>
</Link>
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
{comment.author.username}
</Link>
<span className="date-posted">{comment.createdAt}</span>
{comment.author.username === currentUser?.username && (
<span className="mod-options">
<Form method="post" preventScrollReset={true}>
<input type="hidden" name="id" value={comment.id} />
<button
name="_action"
value="deleteComment"
style={{
border: "none",
outline: "none",
backgroundColor: "transparent",
}}
>
<i className="ion-trash-a"></i>
</button>
</Form>
</span>
)}
</div>
</div>
))}
</div>
);
}
และแล้วหน้าอ่านบทความก็เรียบร้อย! ปุ่ม follow ผู้เขียน, ไลก์โพสต์, และทิ้งคอมเมนต์ควรจะใช้ได้ตามคาด

Article editor (หน้าเขียนบทความ)
นี่เป็นหน้าสุดท้ายที่เราจะทำในบทเรียนนี้ และส่วนที่น่าสนใจที่สุดคือเราจะ validate ข้อมูลในฟอร์มยังไง
ตัวหน้าเว็บ article-edit/ui/ArticleEditPage.tsx จะค่อนข้างเรียบง่าย ความซับซ้อนส่วนเกินถูกซ่อนไว้ใน 2 components อื่น:
import { Form, useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { TagsInput } from "./TagsInput";
import { FormErrors } from "./FormErrors";
export function ArticleEditPage() {
const article = useLoaderData<typeof loader>();
return (
<div className="editor-page">
<div className="container page">
<div className="row">
<div className="col-md-10 offset-md-1 col-xs-12">
<FormErrors />
<Form method="post">
<fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control form-control-lg"
name="title"
placeholder="Article Title"
defaultValue={article.article?.title}
/>
</fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control"
name="description"
placeholder="What's this article about?"
defaultValue={article.article?.description}
/>
</fieldset>
<fieldset className="form-group">
<textarea
className="form-control"
name="body"
rows={8}
placeholder="Write your article (in markdown)"
defaultValue={article.article?.body}
></textarea>
</fieldset>
<fieldset className="form-group">
<TagsInput
name="tags"
defaultValue={article.article?.tagList ?? []}
/>
</fieldset>
<button className="btn btn-lg pull-xs-right btn-primary">
Publish Article
</button>
</fieldset>
</Form>
</div>
</div>
</div>
</div>
);
}
หน้านี้จะดึงบทความปัจจุบันมาแสดง (ถ้าไม่ได้เขียนใหม่) และเติมข้อมูลลงฟอร์ม เราเคยเห็นแบบนี้มาแล้ว ที่น่าสนใจคือ FormErrors เพราะมันจะรับผลการ validate และแสดงให้ user เห็น มาดูกัน:
import { useActionData } from "@remix-run/react";
import type { action } from "../api/action";
export function FormErrors() {
const actionData = useActionData<typeof action>();
return actionData?.errors != null ? (
<ul className="error-messages">
{actionData.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null;
}
ตรงนี้เราสมมติว่า action ของเราจะ return field errors ซึ่งเป็น array ของข้อความ error ที่คนอ่านรู้เรื่อง เราจะไปดูส่วน action กันเร็วๆ นี้
อีก component คือ tags input เป็นแค่ input field ธรรมดาที่มี preview tags ให้ดูด้วย ไม่มีอะไรมาก:
import { useEffect, useRef, useState } from "react";
export function TagsInput({
name,
defaultValue,
}: {
name: string;
defaultValue?: Array<string>;
}) {
const [tagListState, setTagListState] = useState(defaultValue ?? []);
function removeTag(tag: string): void {
const newTagList = tagListState.filter((t) => t !== tag);
setTagListState(newTagList);
}
const tagsInput = useRef<HTMLInputElement>(null);
useEffect(() => {
tagsInput.current && (tagsInput.current.value = tagListState.join(","));
}, [tagListState]);
return (
<>
<input
type="text"
className="form-control"
id="tags"
name={name}
placeholder="Enter tags"
defaultValue={tagListState.join(",")}
onChange={(e) =>
setTagListState(e.target.value.split(",").filter(Boolean))
}
/>
<div className="tag-list">
{tagListState.map((tag) => (
<span className="tag-default tag-pill" key={tag}>
<i
className="ion-close-round"
role="button"
tabIndex={0}
onKeyDown={(e) =>
[" ", "Enter"].includes(e.key) && removeTag(tag)
}
onClick={() => removeTag(tag)}
></i>{" "}
{tag}
</span>
))}
</div>
</>
);
}
ทีนี้มาดูส่วน API ตัว loader ควรจะดู URL ถ้ามี article slug แปลว่าเรากำลังแก้บทความเดิม และข้อมูลควรถูกโหลดมา แต่ถ้าไม่มี ก็ไม่ต้อง return อะไร มาสร้าง loader กัน:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const currentUser = await requireUser(request);
if (!params.slug) {
return { article: null };
}
return throwAnyErrors(
GET("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: { Authorization: `Token ${currentUser.token}` },
}),
);
};
ส่วน action จะรับค่าจาก fields ใหม่, เอาไปผ่าน data schema ของเรา และถ้าทุกอย่างถูกต้อง ก็ commit การเปลี่ยนแปลงไปที่ backend ไม่ว่าจะอัปเดตบทความเดิมหรือสร้างใหม่:
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { POST, PUT, requireUser } from "shared/api";
import { parseAsArticle } from "../model/parseAsArticle";
export const action = async ({ request, params }: ActionFunctionArgs) => {
try {
const { body, description, title, tags } = parseAsArticle(
await request.formData(),
);
const tagList = tags?.split(",") ?? [];
const currentUser = await requireUser(request);
const payload = {
body: {
article: {
title,
description,
body,
tagList,
},
},
headers: { Authorization: `Token ${currentUser.token}` },
};
const { data, error } = await (params.slug
? PUT("/articles/{slug}", {
params: { path: { slug: params.slug } },
...payload,
})
: POST("/articles", payload));
if (error) {
return json({ errors: error }, { status: 422 });
}
return redirect(`/article/${data.article.slug ?? ""}`);
} catch (errors) {
return json({ errors }, { status: 400 });
}
};
ตัว schema ยังทำหน้าที่เป็น parsing function สำหรับ FormData ด้วย ซึ่งช่วยให้เราดึง fields ที่ clean แล้วออกมาได้ง่ายๆ หรือจะ throw errors ออกมาจัดการตอนท้ายก็ได้ parsing function หน้าตาประมาณนี้:
export function parseAsArticle(data: FormData) {
const errors = [];
const title = data.get("title");
if (typeof title !== "string" || title === "") {
errors.push("Give this article a title");
}
const description = data.get("description");
if (typeof description !== "string" || description === "") {
errors.push("Describe what this article is about");
}
const body = data.get("body");
if (typeof body !== "string" || body === "") {
errors.push("Write the article itself");
}
const tags = data.get("tags");
if (typeof tags !== "string") {
errors.push("The tags must be a string");
}
if (errors.length > 0) {
throw errors;
}
return { title, description, body, tags: data.get("tags") ?? "" } as {
title: string;
description: string;
body: string;
tags: string;
};
}
ยอมรับว่ามันดูยาวและซ้ำซ้อนหน่อย แต่ก็คุ้มแลกกับ errors ที่อ่านรู้เรื่อง อันนี้อาจจะใช้ Zod schema ก็ได้ แต่เราต้องมานั่ง render error messages ฝั่ง frontend อีก ซึ่งฟอร์มนี้มันไม่คุ้มที่จะทำซับซ้อนขนาดนั้น
ขั้นตอนสุดท้าย — เชื่อมหน้าเว็บ, loader, และ action เข้ากับ routes เนื่องจากเรารองรับทั้งสร้างใหม่และแก้ไข เราเลย export สิ่งเดียวกันจาก editor._index.tsx และ editor.$slug.tsx ได้เลย:
export { ArticleEditPage } from "./ui/ArticleEditPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleEditPage } from "pages/article-edit";
export { loader, action } from "pages/article-edit";
export default ArticleEditPage;
เสร็จแล้ว! ลองล็อกอินแล้วสร้างบทความใหม่ดูนะ หรือจะแกล้งๆ "ลืม" เขียนบทความแล้วดูว่า validation ทำงานไหมก็ได้ 😉

ส่วนหน้า profile และ settings ก็คล้ายกับ article reader และ editor มาก ทิ้งไว้ให้เป็นแบบฝึกหัดสำหรับผู้อ่าน นั่นก็คือคุณไงล่ะ :)