SiamCafe.net Blog
Cybersecurity

Shadcn UI Pub Sub Architecture

shadcn ui pub sub architecture
Shadcn UI Pub Sub Architecture | SiamCafe Blog
2025-09-10· อ. บอม — SiamCafe.net· 11,448 คำ

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

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

📖 บทความที่เกี่ยวข้อง

Redis Pub Sub SSL TLS Certificateอ่านบทความ → Redis Pub Sub Troubleshooting แก้ปัญหาอ่านบทความ → Radix UI Primitives Pub Sub Architectureอ่านบทความ → Solid.js Signals Pub Sub Architectureอ่านบทความ → MySQL Replication Pub Sub Architectureอ่านบทความ →

📚 ดูบทความทั้งหมด →