สร้างแดชบอร์ดวิดเจ็ตที่ปรับแต่งได้ด้วย TanStack Query
แดชบอร์ดของผลิตภัณฑ์ส่วนใหญ่สร้างด้วยวิธีเดียวกัน: คอมโพเนนต์ระดับบนสุดเรียก API สิบครั้ง รอให้ทุกคำขอเสร็จ แล้วแสดงผลทั้งหมดพร้อมกัน วิธีนี้ดูดีในการสาธิต แต่เมื่อใช้งานจริงกลับก่อให้เกิดปัญหาที่สะสมและทวีความรุนแรงขึ้นเรื่อย ๆ ปลายทางเพียงแห่งเดียวที่ตอบสนองช้าทำให้ทั้งหน้าต้องรอ ข้อผิดพลาดชั่วคราวในวิดเจ็ตเดียวทำให้ทั้งหน้าจอใช้งานไม่ได้ การอัปเดตแบบเรียลไทม์ต้องประสานการดึงข้อมูลใหม่จนท้ายที่สุดดูแลต่อไม่ได้ การเพิ่มวิดเจ็ตใหม่ต้องพิจารณาสถานะส่วนกลางที่ไม่เคยออกแบบมาให้รองรับวิดเจ็ตนั้น
TanStack Query (เดิมชื่อ React Query) แก้ปัญหานี้โดยให้การดึงข้อมูลเป็นส่วนหลักของการออกแบบ แทนที่จะนำมาเสริมเข้ากับสถานะของคอมโพเนนต์ภายหลัง แต่ละ query มีคีย์แคช วงจรชีวิต และสถานะข้อผิดพลาดของตนเอง เมื่อนำมาใช้กับแดชบอร์ดวิดเจ็ต หลักการนี้แปลตรงตัวได้ว่าแต่ละวิดเจ็ตเป็นเจ้าของข้อมูลและรับผิดชอบสถานะกำลังโหลด ผิดพลาด และสำเร็จของตนเอง ส่วนอื่นของแอปพลิเคชันไม่จำเป็นต้องรับรู้หรือจัดการเรื่องเหล่านี้
แยกการจัดวางวิดเจ็ตออกจากข้อมูล
ก่อนเขียนตรรกะดึงข้อมูล สถาปัตยกรรมต้องแยกสองเรื่องให้ชัดเจน: วิดเจ็ตดึงข้อมูลอะไรและวางอยู่ตรงไหน ทั้งสองส่วนเปลี่ยนแปลงได้อย่างอิสระ ผู้ใช้ย้ายวิดเจ็ตได้โดยไม่เปลี่ยนข้อมูลที่แสดง และ backend ส่งข้อมูลใหม่ได้โดยไม่กระทบการจัดวาง การรวมสองเรื่องนี้เข้าด้วยกันจะทำให้เกิดปัญหาในการประสานงานภายหลัง
interface WidgetConfig {
id: string;
type: 'revenue' | 'orders' | 'users' | 'activity-feed';
title: string;
queryKey: string[];
refetchInterval?: number;
}
interface WidgetLayout {
i: string; // matches WidgetConfig.id
x: number;
y: number;
w: number;
h: number;
}
ฟิลด์ queryKey ใน WidgetConfig เชื่อมกับแคชของ TanStack Query วิดเจ็ตสองตัวที่มี queryKey เดียวกันจะใช้ผลลัพธ์ในแคชร่วมกัน กล่าวคือมีคำขอเครือข่ายหนึ่งครั้งและส่วนที่ใช้ข้อมูลสองส่วน ส่วนวิดเจ็ตสองตัวที่มีคีย์ต่างกันจะทำงานอย่างอิสระโดยสมบูรณ์ ความแตกต่างนี้สำคัญเมื่อสร้างแดชบอร์ดที่มีหลายมุมมองแสดงข้อมูลพื้นฐานชุดเดียวกัน
ดึงข้อมูลแยกต่อวิดเจ็ต
แต่ละคอมโพเนนต์วิดเจ็ตเรียก useQuery ด้วยค่ากำหนดของตนเอง staleTime บอก TanStack Query ว่าควรถือว่าข้อมูลในแคชยังเป็นปัจจุบันนานเท่าใด ก่อนจะระบุว่าข้อมูลนั้นไม่เป็นปัจจุบันแล้ว การตั้งค่านี้ให้เหมาะกับวิดเจ็ตแต่ละประเภทช่วยป้องกันการดึงข้อมูลซ้ำโดยไม่จำเป็น ขณะเดียวกันก็รักษาข้อมูลให้เป็นปัจจุบันในระดับที่เหมาะสม
function RevenueWidget({ config }: { config: WidgetConfig }) {
const { data, isLoading, isError } = useQuery({
queryKey: config.queryKey,
queryFn: () => fetchRevenue(),
staleTime: 60_000,
refetchInterval: config.refetchInterval,
});
if (isLoading) return <WidgetSkeleton />;
if (isError) return <WidgetError />;
return (
<div className="widget-content">
<p className="text-3xl font-bold">{formatCurrency(data.total)}</p>
<p className="text-sm text-muted">vs last period: {data.changePercent}%</p>
</div>
);
}
โครงร่างระหว่างโหลดและสถานะข้อผิดพลาดถูกออกแบบให้แสดงเฉพาะภายในวิดเจ็ต ไม่มีตัวหมุนแสดงการโหลดส่วนกลาง และไม่มีตัวจัดการข้อผิดพลาดระดับแอปพลิเคชันที่คอยรับข้อผิดพลาดจากวิดเจ็ต แต่ละวิดเจ็ตแสดงสถานะของตนเองให้ผู้ใช้เห็น ดังนั้นการที่วิดเจ็ตรายได้แสดงโครงร่างระหว่างโหลด ขณะที่วิดเจ็ตคำสั่งซื้อแสดงข้อมูลปัจจุบันอยู่ จึงเป็นพฤติกรรมที่ถูกต้องตามที่ออกแบบไว้ ไม่ใช่สัญญาณว่าระบบมีปัญหา
แยก error boundary ต่อวิดเจ็ต
สถานะ isError จาก useQuery จัดการข้อผิดพลาดในการดึงข้อมูล แต่ข้อผิดพลาดระหว่างเรนเดอร์ที่เกิดขึ้นขณะคอมโพเนนต์ทำงานต้องอาศัยกลไก error boundary ของ React การครอบแต่ละวิดเจ็ตด้วย error boundary ของตนเองทำให้ข้อผิดพลาดระหว่างเรนเดอร์ในวิดเจ็ตหนึ่งไม่ทำให้ทั้งแดชบอร์ดหยุดทำงาน ผู้ใช้ยังทำงานกับวิดเจ็ตอื่นต่อได้ ขณะที่วิดเจ็ตที่มีปัญหาแสดงสถานะข้อผิดพลาดที่สามารถกู้คืนได้
class WidgetErrorBoundary extends React.Component<
{ children: React.ReactNode; widgetId: string },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="widget-error">
<p>This widget encountered an error.</p>
<button onClick={() => this.setState({ hasError: false })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
function Widget({ config }: { config: WidgetConfig }) {
return (
<WidgetErrorBoundary widgetId={config.id}>
<WidgetRouter config={config} />
</WidgetErrorBoundary>
);
}
ปุ่มลองใหม่รีเซ็ตสถานะของ error boundary ทำให้ผู้ใช้กู้คืนการทำงานได้โดยไม่ต้องโหลดหน้าใหม่ทั้งหมด ในทางปฏิบัติ ข้อผิดพลาดระหว่างเรนเดอร์ในคอมโพเนนต์วิดเจ็ตส่วนใหญ่เกิดจากข้อมูล API ที่มีค่า null ในตำแหน่งที่ไม่คาดคิด การลองใหม่ที่เรียก query เพื่อดึงข้อมูลใหม่จึงมักแก้ปัญหาได้ โปรดทราบว่าการรีเซ็ต boundary เพียงทำให้วิดเจ็ตเรนเดอร์อีกครั้ง ไม่ได้ดึงข้อมูลใหม่ด้วยตัวเอง และหากข้อมูลในแคชยังสดอยู่ วิดเจ็ตก็จะเรนเดอร์อีกครั้งด้วยข้อมูลเดิม หากต้องการลองใหม่ด้วย query ที่ดึงข้อมูลใหม่ ต้องรีเซ็ตหรือดึงข้อมูลของ query ของวิดเจ็ตใหม่ด้วยในตัวจัดการการลองใหม่ (เช่น ใช้ queryClient.resetQueries({ queryKey: config.queryKey })) หรือใช้ QueryErrorResetBoundary ของ TanStack Query เมื่อมีการโยนข้อผิดพลาดจาก query ไปยัง boundary
การลากและปรับขนาด
กลไกการลากและปรับขนาดเป็นเรื่องรองจากชั้นข้อมูล react-grid-layout จัดการตำแหน่งได้ดี โดยรับอาร์เรย์การจัดวาง เรนเดอร์คอมโพเนนต์ลูกลงในช่องของกริด และเรียก onLayoutChange ทุกครั้งที่ผู้ใช้ย้ายหรือปรับขนาดวิดเจ็ต การนำไปใช้งานทำได้ตรงไปตรงมา
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
function Dashboard({ widgets, layouts, onLayoutChange }: DashboardProps) {
return (
<GridLayout
layout={layouts}
cols={12}
rowHeight={60}
width={1200}
onLayoutChange={onLayoutChange}
draggableHandle=".widget-drag-handle"
>
{widgets.map(config => (
<div key={config.id}>
<div className="widget-drag-handle cursor-grab">
<span>{config.title}</span>
</div>
<Widget config={config} />
</div>
))}
</GridLayout>
);
}
ควรใส่ใจ prop draggableHandle หากไม่กำหนดค่านี้ การคลิกที่ใดก็ตามบนวิดเจ็ตจะเริ่มการลาก ซึ่งขัดกับองค์ประกอบที่โต้ตอบได้ภายในวิดเจ็ต เช่น กราฟ ปุ่ม และช่องกรอกข้อมูล การจำกัดให้ลากได้เฉพาะบริเวณที่กำหนดไว้ เช่น แถบชื่อหรือไอคอนจับลาก ช่วยแก้ความขัดแย้งนี้ได้อย่างตรงจุด
การบันทึกสถานะการจัดวาง
การบันทึกการจัดวางมีสองชั้น: บันทึกทันทีเพื่อไม่ให้ตำแหน่งที่จัดไว้หายไปเมื่อรีเฟรชหน้า และบันทึกฝั่งเซิร์ฟเวอร์เพื่อให้ผู้ใช้เห็นการจัดวางเดิมเมื่อเปลี่ยนอุปกรณ์ ทั้งสองชั้นต้องใช้วิธีดำเนินการต่างกัน และต้องออกแบบให้ทำงานร่วมกันอย่างรอบคอบเพื่อไม่ให้สำเนาในเครื่องและสำเนาบนเซิร์ฟเวอร์เขียนทับกัน
สำหรับการบันทึกทันที localStorage ใช้งานได้ดีและไม่ต้องส่งคำขอไปกลับผ่านเครือข่าย:
function useDashboardLayout(initialLayouts: WidgetLayout[]) {
const storageKey = 'dashboard-layout';
const [layouts, setLayouts] = useState<WidgetLayout[]>(() => {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : initialLayouts;
} catch {
return initialLayouts;
}
});
const handleLayoutChange = (newLayouts: WidgetLayout[]) => {
setLayouts(newLayouts);
localStorage.setItem(storageKey, JSON.stringify(newLayouts));
};
return { layouts, handleLayoutChange };
}
สำหรับการบันทึกเพื่อใช้ข้ามอุปกรณ์ การมองการจัดวางเป็นสถานะฝั่งเซิร์ฟเวอร์ โดยดึงผ่าน useQuery และอัปเดตผ่าน useMutation เข้ากับการตั้งค่า TanStack Query ที่มีอยู่ได้อย่างเป็นธรรมชาติ การหน่วงรวมคำขอบันทึก (debounce) ช่วยป้องกันไม่ให้ส่งคำขอเครือข่ายทุกครั้งที่ลากขยับไปหนึ่งพิกเซล hook ด้านล่างทำหน้าที่บันทึกเท่านั้น หากต้องการให้ layouts ที่ส่งกลับตรงกับการจัดวางที่ผู้ใช้เพิ่งปรับ ต้องเขียนการจัดวางใหม่ลงในแคชด้วย (เช่น ใช้ queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) หรือทำเครื่องหมายว่าข้อมูลของ query ไม่สดแล้ว (invalidate) หลังบันทึกสำเร็จ
function usePersistedLayout(userId: string, initialLayouts: WidgetLayout[]) {
const { data: layouts } = useQuery({
queryKey: ['dashboard-layout', userId],
queryFn: () => fetchUserLayout(userId),
initialData: initialLayouts,
});
const { mutate: saveLayout } = useMutation({
mutationFn: (newLayouts: WidgetLayout[]) =>
saveUserLayout(userId, newLayouts),
});
const debouncedSave = useMemo(
() => debounce(saveLayout, 1000),
[saveLayout]
);
return { layouts, handleLayoutChange: debouncedSave };
}
การอัปเดตแบบเรียลไทม์
ตัวเลือก refetchInterval ให้แต่ละวิดเจ็ตควบคุมความถี่ในการตรวจหาข้อมูลใหม่เป็นระยะได้อย่างอิสระ ฟีดกิจกรรมอาจต้องอัปเดตทุกสิบวินาที ส่วนสรุปรายได้รายเดือนอาจรีเฟรชเพียงชั่วโมงละครั้งก็เพียงพอ การตรวจหาข้อมูลใหม่พร้อมกันทั้งระบบ ซึ่งเป็นอีกวิธีที่ใช้กันทั่วไป บังคับให้ทุกวิดเจ็ตใช้ช่วงเวลาเดียวกัน ผลที่ตามมามักเป็นการดึงข้อมูลถี่เกินไปเพื่อให้ทันความต้องการของวิดเจ็ตที่ต้องอัปเดตบ่อยที่สุด หรือดึงข้อมูลน้อยเกินไปจนวิดเจ็ตที่ต้องใช้ข้อมูลทันเวลาแสดงข้อมูลที่ไม่เป็นปัจจุบัน
const WIDGET_REFRESH_INTERVALS: Record<WidgetConfig['type'], number | undefined> = {
'activity-feed': 10_000,
'orders': 30_000,
'revenue': 3_600_000,
'users': 300_000,
};
const config: WidgetConfig = {
id: 'orders-widget',
type: 'orders',
title: 'Recent Orders',
queryKey: ['orders'],
refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};
แต่ในทางปฏิบัติ การตรวจหาข้อมูลใหม่โดยไม่เลือกเงื่อนไขก็สร้างปัญหาอีกแบบหนึ่ง: แดชบอร์ดที่มีวิดเจ็ตหลายตัวตรวจหาข้อมูลถี่ ๆ ทำให้เกิดปริมาณการรับส่งข้อมูลผ่านเครือข่ายมาก แม้ขณะแท็บเบราว์เซอร์อยู่เบื้องหลัง TanStack Query มีวิธีแก้โดยตรงด้วย refetchIntervalInBackground: false ซึ่งพักการตรวจหาข้อมูลใหม่ในแท็บที่ไม่ได้แสดงอยู่ เมื่อใช้ร่วมกับ refetchOnWindowFocus: true (เปิดเป็นค่าเริ่มต้น) ก็ครอบคลุมการใช้งานส่วนใหญ่: ข้อมูลยังเป็นปัจจุบันขณะที่ผู้ใช้กำลังทำงาน และพักคำขอเครือข่ายเมื่อไม่ได้ใช้งาน
ตัวเลือกคอมโพเนนต์วิดเจ็ตและค่ากำหนดส่วนกลาง
คอมโพเนนต์ส่วนกลางที่จับคู่ประเภทวิดเจ็ตกับคอมโพเนนต์ที่ใช้แสดงผลช่วยรวมตรรกะการเรนเดอร์ไว้ในจุดเดียว การเพิ่มวิดเจ็ตประเภทใหม่จึงต้องเขียนคอมโพเนนต์และเพิ่มกรณีในตัวเลือกเพียงหนึ่งกรณี โดยไม่ต้องเปลี่ยนส่วนอื่น
function WidgetRouter({ config }: { config: WidgetConfig }) {
switch (config.type) {
case 'revenue': return <RevenueWidget config={config} />;
case 'orders': return <OrdersWidget config={config} />;
case 'users': return <UsersWidget config={config} />;
case 'activity-feed': return <ActivityFeedWidget config={config} />;
default: return <div>Unknown widget type</div>;
}
}
ค่ากำหนดส่วนกลางของ QueryClient ตั้งค่าเริ่มต้นที่แต่ละวิดเจ็ตสามารถกำหนดทับได้ สำหรับแดชบอร์ด การตั้งค่าเริ่มต้นของ staleTime เป็น 30 วินาทีช่วยป้องกันการดึงข้อมูลใหม่เบื้องหลังอย่างต่อเนื่อง ขณะเดียวกันก็รักษาข้อมูลให้เป็นปัจจุบันในระดับที่เหมาะสมหลังผู้ใช้ออกจากหน้าแล้วกลับมา
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
ปัญหาที่สถาปัตยกรรมนี้ป้องกัน
ปัญหาที่สถาปัตยกรรมนี้ป้องกันมีความสำคัญพอ ๆ กับสิ่งที่ทำให้เป็นไปได้ สถานะกำลังโหลดส่วนกลางไม่จำเป็นอีกต่อไป เพราะไม่มีช่วงเวลาที่ “ข้อมูลทั้งหมด” กำลังโหลด มีเพียงวิดเจ็ตแต่ละตัวที่กำลังโหลด ความล้มเหลวที่ลุกลามต่อกันมีโอกาสเกิดน้อยลงมาก ข้อผิดพลาดระหว่างเรนเดอร์ของวิดเจ็ตหนึ่งไม่อาจทำให้วิดเจ็ตอื่นหยุดทำงาน เพราะแต่ละ error boundary มีขอบเขตอยู่ภายในวิดเจ็ตเดียว และวิดเจ็ตที่ใช้ query key ต่างกันจะไม่ใช้สถานะการดึงข้อมูลร่วมกัน วิดเจ็ตที่ตั้งใจใช้ query key เดียวกันจะใช้ทั้งข้อมูลและสถานะข้อผิดพลาดของ query นั้นร่วมกันด้วย ดังนั้นความล้มเหลวที่เกิดขึ้นกับ query นั้นจึงกระทบทุกวิดเจ็ตที่ใช้คีย์เดียวกัน การเพิ่มวิดเจ็ตใหม่ไม่ต้องแก้สถานะร่วม หรือประสานความต้องการด้านข้อมูลกับวิดเจ็ตที่มีอยู่
การวิเคราะห์ประสิทธิภาพก็ทำได้ง่ายขึ้น กิจกรรมบนเครือข่ายเชื่อมโยงกับวิดเจ็ตแต่ละตัวอย่างชัดเจนผ่านคีย์ query ที่ปรากฏใน TanStack Query DevTools การค้นหาว่าวิดเจ็ตใดส่งคำขอมากเกินไป หรือ query ใดไม่เคยถูกใช้งานจริง ใช้เวลาเพียงไม่กี่วินาที แทนที่จะต้องวิเคราะห์แผนภาพลำดับคำขอเครือข่ายแบบ waterfall
กลไกการลากและปรับขนาด ซึ่งเป็นส่วนที่มองเห็นและโต้ตอบได้ ทำให้แดชบอร์ดดูประณีต แต่ยังเป็นเรื่องรองจากรากฐานด้านข้อมูลนี้ ไลบรารีกริดที่มีความสามารถเพียงพอจัดการตำแหน่งได้อยู่แล้ว สิ่งที่กำหนดว่าแดชบอร์ดวิดเจ็ตจะรองรับการใช้งานจริงได้หรือไม่ คือชั้นข้อมูลออกแบบให้รับมือกับความล้มเหลวโดยไม่กระทบส่วนอื่นและอัปเดตอย่างอิสระได้หรือไม่ สถาปัตยกรรมนี้ทำให้การออกแบบเช่นนั้นเป็นไปตามธรรมชาติโดยไม่ต้องพยายามจัดการเพิ่มเติม
สำหรับทีมที่สร้าง พอร์ทัลลูกค้า หรือเครื่องมือวิเคราะห์ข้อมูลภายใน รูปแบบนี้นำไปใช้ได้โดยไม่ต้องปรับเปลี่ยน แนวคิดการแยกส่วนเป็นวิดเจ็ตให้ประโยชน์ได้โดยไม่ต้องมีส่วนติดต่อแบบลากและวางที่ซับซ้อน แม้กริดการ์ดข้อมูลแบบง่าย ๆ ที่ปรับแต่งได้ ก็ยังได้ประโยชน์จากการแยก query และ error boundary ต่อวิดเจ็ต การจัดวางแบบกริดเป็นส่วนเสริม ส่วนสถาปัตยกรรมข้อมูลเป็นรากฐาน
บทความที่เกี่ยวข้อง
- นำแอป React ขึ้นใช้งานจริง: ตั้งค่า Docker แบบครบถ้วนพร้อม Traefik Reverse Proxy — เมื่อสร้างแดชบอร์ดเสร็จแล้ว การนำระบบขึ้นใช้งานด้วยคอนเทนเนอร์ร่วมกับ Traefik จะจัดการ HTTPS และการกำหนดเส้นทางโดยไม่ต้องจัดการใบรับรองด้วยตนเอง
- เพิ่มประสิทธิภาพ WooCommerce: ลดเวลาโหลดจาก 8 วินาทีเป็น 0.2 วินาที — การตัดสินใจด้านสถาปัตยกรรมการดึงข้อมูลส่งผลต่อประสิทธิภาพที่วัดได้ หลักการเดียวกับที่ใช้กับวิดเจ็ตแดชบอร์ดนำไปใช้กับ frontend ใดก็ตามที่ต้องจัดการข้อมูลจำนวนมากได้
- สร้างโครงสร้างพื้นฐานข้อมูลพร้อมใช้งานจริงสำหรับผู้ขาย Amazon: แนะนำ tva-fetch — โครงสร้างพื้นฐานข้อมูล backend ที่ส่งข้อมูลให้แดชบอร์ดเป็นตัวกำหนดคุณภาพของข้อมูลที่ TanStack Query สามารถเก็บในแคชและนำมาใช้งานได้
- รับมือ CVE-2025-55182: ประสบการณ์ของเรากับช่องโหว่ใน React Server Components — ความสามารถฝั่งเซิร์ฟเวอร์ที่เพิ่มขึ้นของ React เปิดช่องทางให้โจมตี ซึ่งสถาปัตยกรรมแดชบอร์ดฝั่งไคลเอนต์แบบนี้ตั้งใจหลีกเลี่ยง