Shadcn UI คืออะไร
Shadcn UI เป็นแนวคิดใหม่ของ Component Library ที่ไม่ใช่ npm Package แบบ MUI หรือ Ant Design แต่เป็น Collection ของ Beautifully Designed Components ที่ Copy Source Code ลงใน Project โดยตรง ใช้ Radix UI เป็น Headless Primitive สำหรับ Accessibility และ Tailwind CSS สำหรับ Styling ข้อดีคือเป็นเจ้าของ Code ทั้งหมด Customize ได้เต็มที่ ไม่ต้อง Override CSS ของ Library
เมื่อรวมกับ Pub/Sub Architecture จะได้ Real-time UI ที่อัปเดตทันทีเมื่อมี Event เกิดขึ้นในระบบ เช่น Dashboard ที่แสดง Metrics ล่าสุด, Notification System ที่แจ้งเตือนทันที หรือ Collaborative Editor ที่หลายคนแก้ไขพร้อมกัน
ติดตั้ง Shadcn UI และ Project Setup
# สร้าง Next.js Project
npx create-next-app@latest realtime-dashboard --typescript --tailwind --eslint --app
cd realtime-dashboard
# ติดตั้ง Shadcn UI
npx shadcn-ui@latest init
# เลือก: New York style, Zinc color, CSS variables: yes
# เพิ่ม Components ที่ต้องการ
npx shadcn-ui@latest add card
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add table
npx shadcn-ui@latest add alert
npx shadcn-ui@latest add toast
npx shadcn-ui@latest add tabs
npx shadcn-ui@latest add chart
# ติดตั้ง Dependencies สำหรับ Pub/Sub
npm install socket.io-client zustand
# โครงสร้าง Project
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── dashboard/
│ └── page.tsx
├── components/
│ ├── ui/ # Shadcn Components
│ ├── dashboard/
│ │ ├── MetricsCards.tsx
│ │ ├── EventsTable.tsx
│ │ ├── AlertsFeed.tsx
│ │ └── RealtimeChart.tsx
│ └── providers/
│ └── PubSubProvider.tsx
├── hooks/
│ ├── useWebSocket.ts
│ └── usePubSub.ts
├── lib/
│ ├── pubsub.ts
│ └── utils.ts
└── stores/
└── dashboardStore.ts
Pub/Sub Client กับ Zustand Store
// lib/pubsub.ts — Pub/Sub Client ด้วย WebSocket
import { io, Socket } from "socket.io-client";
type MessageHandler = (data: any) => void;
class PubSubClient {
private socket: Socket | null = null;
private subscriptions: Map<string, Set<MessageHandler>> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
connect(url: string) {
this.socket = io(url, {
transports: ["websocket"],
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
this.socket.on("connect", () => {
console.log("PubSub connected");
this.reconnectAttempts = 0;
// Re-subscribe ทุก Topic หลัง Reconnect
this.subscriptions.forEach((_, topic) => {
this.socket?.emit("subscribe", { topic });
});
});
this.socket.on("message", (data: { topic: string; payload: any }) => {
const handlers = this.subscriptions.get(data.topic);
if (handlers) {
handlers.forEach((handler) => handler(data.payload));
}
});
this.socket.on("disconnect", () => {
console.log("PubSub disconnected");
});
}
subscribe(topic: string, handler: MessageHandler) {
if (!this.subscriptions.has(topic)) {
this.subscriptions.set(topic, new Set());
this.socket?.emit("subscribe", { topic });
}
this.subscriptions.get(topic)!.add(handler);
// Return Unsubscribe function
return () => {
const handlers = this.subscriptions.get(topic);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.subscriptions.delete(topic);
this.socket?.emit("unsubscribe", { topic });
}
}
};
}
publish(topic: string, payload: any) {
this.socket?.emit("publish", { topic, payload });
}
disconnect() {
this.socket?.disconnect();
}
}
export const pubsub = new PubSubClient();
// ---
// stores/dashboardStore.ts — Zustand Store สำหรับ Dashboard State
import { create } from "zustand";
interface MetricData {
name: string;
value: number;
change: number;
trend: "up" | "down" | "stable";
}
interface EventData {
id: string;
timestamp: string;
type: string;
message: string;
severity: "info" | "warning" | "error" | "critical";
}
interface DashboardState {
metrics: MetricData[];
events: EventData[];
alerts: EventData[];
isConnected: boolean;
lastUpdated: string;
updateMetric: (metric: MetricData) => void;
addEvent: (event: EventData) => void;
addAlert: (alert: EventData) => void;
setConnected: (connected: boolean) => void;
}
export const useDashboardStore = create<DashboardState>((set) => ({
metrics: [],
events: [],
alerts: [],
isConnected: false,
lastUpdated: "",
updateMetric: (metric) =>
set((state) => ({
metrics: state.metrics.some((m) => m.name === metric.name)
? state.metrics.map((m) => (m.name === metric.name ? metric : m))
: [...state.metrics, metric],
lastUpdated: new Date().toISOString(),
})),
addEvent: (event) =>
set((state) => ({
events: [event, ...state.events].slice(0, 100), // เก็บ 100 รายการล่าสุด
})),
addAlert: (alert) =>
set((state) => ({
alerts: [alert, ...state.alerts].slice(0, 50),
})),
setConnected: (connected) => set({ isConnected: connected }),
}));
// ---
// hooks/usePubSub.ts — Custom Hook สำหรับ Pub/Sub
import { useEffect } from "react";
import { pubsub } from "@/lib/pubsub";
import { useDashboardStore } from "@/stores/dashboardStore";
export function usePubSub() {
const { updateMetric, addEvent, addAlert, setConnected } =
useDashboardStore();
useEffect(() => {
pubsub.connect(process.env.NEXT_PUBLIC_WS_URL || "http://localhost:3001");
setConnected(true);
const unsubs = [
pubsub.subscribe("metrics", (data) => updateMetric(data)),
pubsub.subscribe("events", (data) => addEvent(data)),
pubsub.subscribe("alerts", (data) => addAlert(data)),
];
return () => {
unsubs.forEach((unsub) => unsub());
pubsub.disconnect();
setConnected(false);
};
}, []);
}
Real-time Dashboard Components ด้วย Shadcn
// components/dashboard/MetricsCards.tsx
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useDashboardStore } from "@/stores/dashboardStore";
import { ArrowUpIcon, ArrowDownIcon, MinusIcon } from "lucide-react";
export function MetricsCards() {
const { metrics, isConnected, lastUpdated } = useDashboardStore();
const trendIcon = (trend: string) => {
switch (trend) {
case "up": return <ArrowUpIcon className="h-4 w-4 text-green-500" />;
case "down": return <ArrowDownIcon className="h-4 w-4 text-red-500" />;
default: return <MinusIcon className="h-4 w-4 text-gray-400" />;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Dashboard</h2>
<Badge variant={isConnected ? "default" : "destructive"}>
{isConnected ? "Live" : "Disconnected"}
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.name}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{metric.name}
</CardTitle>
{trendIcon(metric.trend)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metric.value.toLocaleString()}
</div>
<p className={`text-xs `}>
{metric.change > 0 ? "+" : ""}{metric.change}% from last hour
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// components/dashboard/AlertsFeed.tsx
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useDashboardStore } from "@/stores/dashboardStore";
import { AlertCircle, AlertTriangle, Info, XCircle } from "lucide-react";
export function AlertsFeed() {
const { alerts } = useDashboardStore();
const severityConfig = {
info: { icon: Info, variant: "default" as const },
warning: { icon: AlertTriangle, variant: "default" as const },
error: { icon: AlertCircle, variant: "destructive" as const },
critical: { icon: XCircle, variant: "destructive" as const },
};
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold">Recent Alerts</h3>
{alerts.slice(0, 10).map((alert) => {
const config = severityConfig[alert.severity];
const Icon = config.icon;
return (
<Alert key={alert.id} variant={config.variant}>
<Icon className="h-4 w-4" />
<AlertTitle className="flex items-center gap-2">
{alert.type}
<Badge variant="outline" className="text-xs">
{alert.severity}
</Badge>
</AlertTitle>
<AlertDescription>
{alert.message}
<span className="block text-xs text-muted-foreground mt-1">
{new Date(alert.timestamp).toLocaleString()}
</span>
</AlertDescription>
</Alert>
);
})}
</div>
);
}
Pub/Sub Server ด้วย Node.js
// server.ts — Pub/Sub Server ด้วย Socket.io
import { Server } from "socket.io";
import { createServer } from "http";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: "*", methods: ["GET", "POST"] },
});
// Track Subscriptions
const subscriptions = new Map<string, Set<string>>();
io.on("connection", (socket) => {
console.log(`Client connected: `);
socket.on("subscribe", ({ topic }) => {
socket.join(topic);
if (!subscriptions.has(topic)) {
subscriptions.set(topic, new Set());
}
subscriptions.get(topic)!.add(socket.id);
console.log(` subscribed to `);
});
socket.on("unsubscribe", ({ topic }) => {
socket.leave(topic);
subscriptions.get(topic)?.delete(socket.id);
});
socket.on("publish", ({ topic, payload }) => {
io.to(topic).emit("message", { topic, payload });
});
socket.on("disconnect", () => {
subscriptions.forEach((clients, topic) => {
clients.delete(socket.id);
});
});
});
// Simulate Real-time Metrics
setInterval(() => {
const metrics = [
{ name: "Active Users", value: Math.floor(Math.random() * 1000) + 500,
change: +(Math.random() * 10 - 5).toFixed(1), trend: "up" },
{ name: "Requests/sec", value: Math.floor(Math.random() * 5000) + 2000,
change: +(Math.random() * 8 - 4).toFixed(1), trend: "stable" },
{ name: "Error Rate", value: +(Math.random() * 2).toFixed(2),
change: +(Math.random() * 1 - 0.5).toFixed(1), trend: "down" },
{ name: "Latency P95", value: Math.floor(Math.random() * 100) + 50,
change: +(Math.random() * 6 - 3).toFixed(1), trend: "stable" },
];
metrics.forEach((m) => {
io.to("metrics").emit("message", { topic: "metrics", payload: m });
});
}, 3000);
httpServer.listen(3001, () => console.log("PubSub server on :3001"));
Pub/Sub Patterns สำหรับ Production
- Topic Naming Convention: ใช้ Hierarchical Topics เช่น metrics.cpu.server-01, alerts.critical.database เพื่อ Subscribe แบบ Wildcard ได้
- Message Schema: กำหนด Schema ให้ทุก Message เช่น {topic, payload, timestamp, source} เพื่อ Consistency
- Backpressure: ถ้า Client ช้ากว่า Publisher ใช้ Buffer หรือ Drop Message เก่าเพื่อป้องกัน Memory Leak
- Dead Letter Queue: เก็บ Message ที่ส่งไม่สำเร็จไว้ใน Queue แยก สำหรับ Retry หรือ Debug
- Authentication: ใช้ JWT Token สำหรับ WebSocket Connection และตรวจสอบ Permission ต่อ Topic
- Rate Limiting: จำกัดจำนวน Message ต่อ Client ต่อวินาทีเพื่อป้องกัน Abuse
Shadcn UI คืออะไร
Shadcn UI เป็น Component Library สำหรับ React ที่ Copy Source Code ลง Project โดยตรง ใช้ Radix UI และ Tailwind CSS Customize ได้เต็มที่เพราะเป็นเจ้าของ Code ไม่เพิ่ม Bundle Size จาก External Library เหมาะกับ Project ที่ต้องการ Design ที่แตกต่าง
Pub/Sub Architecture คืออะไร
Pub/Sub เป็น Messaging Pattern ที่ Publisher ส่ง Message ไปยัง Topic, Subscriber ที่สนใจได้รับอัตโนมัติ ไม่ต้องรู้จักกัน ทำให้ระบบ Decouple ได้ดี เหมาะกับ Real-time Updates, Event-driven Architecture, Notification System และ Chat Application
ทำไมต้องใช้ Pub/Sub กับ UI
เพื่อให้ UI อัปเดต Real-time เมื่อมี Event เกิดขึ้น ไม่ต้อง Polling ลด Server Load, Dashboard แสดงข้อมูลล่าสุดทันที, Notification แจ้งเตือนทันที, Chat Message ส่งถึงทันที ให้ User Experience ที่ดีกว่า HTTP Polling มาก
Shadcn UI ต่างจาก Material UI และ Ant Design อย่างไร
Shadcn UI Copy Code ลง Project, Customize ได้เต็มที่, ไม่เพิ่ม Bundle Size MUI และ Ant Design เป็น npm Package, Import มาใช้, Customize ยากกว่า, เพิ่ม Bundle Size Shadcn เหมาะกับ Project ที่ต้องการ Design เฉพาะ MUI เหมาะกับ Rapid Prototyping
สรุปและแนวทางปฏิบัติ
การรวม Shadcn UI กับ Pub/Sub Architecture ทำให้ได้ Real-time Dashboard ที่สวยงามและ Update ทันที Shadcn ให้ Component ที่ Customize ได้เต็มที่ Pub/Sub ให้ Real-time Communication ที่ Scalable สิ่งสำคัญคือใช้ Zustand หรือ State Management ที่ดีสำหรับจัดการ State, ตั้ง Reconnection Logic สำหรับ WebSocket, กำหนด Topic Naming Convention ที่ชัดเจน และมี Backpressure Handling สำหรับ High-throughput Scenarios
