Hướng dẫn Storybook 8 với React và Tailwind CSS: Xây dựng và kiểm thử UI Component độc lập

Development tutorial - IT technology blog
Development tutorial - IT technology blog

1. Ba hướng tiếp cận phổ biến khi dựng UI Component

Dựng giao diện với React và Tailwind CSS thoạt nhìn khá đơn giản: mở page, viết thẻ JSX, gắn class utility là xong. Thế nhưng khi dự án mở rộng, cách làm này nhanh chóng bộc lộ vấn đề. Thực tế hiện nay có 3 hướng tiếp cận chính:

  • Code trực tiếp trong Page hoặc Feature: Viết component ngay trong thư mục tính năng rồi import thẳng vào màn hình chính (Dashboard, Checkout). Giao diện bị gắn chặt vào API call, URL router và global state.
  • Tạo route Playground nội bộ: Mở riêng một path tạm như /dev-components để render hàng loạt button, modal và input form. Cách này giúp xem nhanh giao diện nhưng thiếu công cụ tương tác chuyên sâu.
  • Phát triển biệt lập (Component-Driven) với Storybook 8: Tách hoàn toàn UI khỏi logic nghiệp vụ. Mỗi component chạy trong một sandbox độc lập với đầy đủ trạng thái: hover, loading, error và disabled.

2. Ưu và nhược điểm từng phương pháp

Mỗi giải pháp đều có chỗ đứng riêng tùy theo quy mô sản phẩm và ngân sách thời gian của team:

Code trực tiếp trong Page/Feature

  • Ưu điểm: Triển khai nhanh nhất ở giai đoạn đầu. Không cần cài đặt thư viện ngoài, tiện đâu viết đấy.
  • Nhược điểm: Rất khó tái hiện các edge case. Ví dụ: một Modal thông báo lỗi thanh toán nằm sâu ở bước 4 của luồng Checkout. Mỗi lần muốn chỉnh giao diện hoặc test lỗi 500 từ server, bạn phải bấm lại từ bước 1. Ngoài ra, nếu backend chưa xong API, frontend buộc phải ngồi chờ.

Dựng trang Playground nội bộ

  • Ưu điểm: Gom các component về một màn hình tập trung, không làm xáo trộn luồng chạy chính của ứng dụng.
  • Nhược điểm: Tốn công bảo trì code rác. Khi số lượng component vượt qua con số 30, trang này sẽ cực kỳ lộn xộn. Chưa kể nếu quên cấu hình chặn route, trang thử nghiệm rất dễ bị lọt lên môi trường production.

Sử dụng Storybook 8

  • Ưu điểm: Khởi động cực nhanh (thường dưới 2 giây) nhờ kiến trúc native Vite. Bảng Controls cho phép đổi props trực tiếp trên trình duyệt mà không cần sửa code. Storybook 8 tích hợp sẵn interaction test để bắt lỗi thao tác người dùng, đồng thời đóng vai trò như một bản tài liệu sống cho Designer và QA đối soát.
  • Nhược điểm: Cần thời gian cấu hình ban đầu. Các thành viên trong team cần duy trì kỷ luật viết file .stories.tsx song song với code UI.

3. Khi nào nên đưa Storybook vào dự án?

Nếu bạn chỉ làm landing page quảng cáo hoặc đồ án cá nhân gói gọn trong 1-2 tuần, code thẳng trong page là lựa chọn thực tế nhất. Đừng làm phức tạp hóa vấn đề khi ứng dụng chưa cần đến.

Ngược lại, với web app thương mại từ 3 đến 5 frontend dev trở lên, Storybook là khoản đầu tư mang lại ROI (hiệu suất) rất rõ rệt. Trong một dự án thương mại điện tử gần đây, team mình áp dụng Storybook và giảm được khoảng 35% thời gian tích hợp UI. Một dev phụ trách dựng toàn bộ component theo Figma, người còn lại tập trung xử lý state và logic giỏ hàng. Khi backend bàn giao API chậm hơn dự kiến 1 tuần, phần giao diện vẫn hoàn tất đúng hạn mà không phụ thuộc vào dữ liệu thật.

4. Hướng dẫn tích hợp Storybook 8 vào React và Tailwind CSS

Các bước dưới đây áp dụng cho dự án React sử dụng Vite và TypeScript.

Bước 1: Khởi tạo dự án React và cấu hình Tailwind CSS

Nếu bạn đã có project sẵn, hãy chuyển thẳng sang Bước 2. Nếu chưa, tạo nhanh bằng Vite:

# Tạo dự án React + TypeScript với Vite
npm create vite@latest my-design-system -- --template react-ts
cd my-design-system

# Cài đặt Tailwind CSS v3
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Cấu hình tailwind.config.js để Tailwind quét cả các file story trong thư mục .storybook:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
    "./.storybook/**/*.{js,ts,jsx,tsx}"
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Khai báo Tailwind directive vào đầu file src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

Bước 2: Cài đặt Storybook 8

Chạy lệnh cài đặt tự động tại thư mục gốc của project:

npx storybook@latest init

CLI sẽ tự nhận diện Vite và sinh ra thư mục .storybook/. Để component trong Storybook nhận diện đúng các class của Tailwind, hãy import file CSS chính vào .storybook/preview.ts:

import type { Preview } from "@storybook/react";
import "../src/index.css"; // Nạp file cấu hình Tailwind CSS

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

Bước 3: Xây dựng Button Component chuẩn Design System

Chúng ta tạo component Button với nhiều biến thể (variant, size, trạng thái loading). Tạo file src/components/Button.tsx:

import React from "react";

export interface ButtonProps {
  variant?: "primary" | "secondary" | "danger";
  size?: "sm" | "md" | "lg";
  label: string;
  disabled?: boolean;
  isLoading?: boolean;
  onClick?: () => void;
}

export const Button: React.FC<ButtonProps> = ({
  variant = "primary",
  size = "md",
  label,
  disabled = false,
  isLoading = false,
  onClick,
}) => {
  const baseStyles = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";

  const sizeStyles = {
    sm: "px-3 py-1.5 text-xs",
    md: "px-4 py-2 text-sm",
    lg: "px-6 py-3 text-base",
  };

  const variantStyles = {
    primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-blue-300",
    secondary: "bg-gray-100 text-gray-800 hover:bg-gray-200 focus:ring-gray-400 disabled:bg-gray-50",
    danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-red-300",
  };

  return (
    <button
      type="button"
      disabled={disabled || isLoading}
      onClick={onClick}
      className={`${baseStyles} ${sizeStyles[size]} ${variantStyles[variant]} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
    >
      {isLoading && (
        <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
        </svg>
      )}
      {label}
    </button>
  );
};

Bước 4: Viết Story theo chuẩn CSF3

Storybook 8 áp dụng mặc định Component Story Format 3 (CSF3), giúp code ngắn hơn đáng kể. Tạo file src/components/Button.stories.tsx:

import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    variant: {
      control: { type: "select" },
      options: ["primary", "secondary", "danger"],
    },
    size: {
      control: { type: "radio" },
      options: ["sm", "md", "lg"],
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: "primary",
    label: "Lưu thay đổi",
  },
};

export const Danger: Story = {
  args: {
    variant: "danger",
    label: "Xóa tài khoản",
  },
};

export const LoadingState: Story = {
  args: {
    variant: "primary",
    label: "Đang xử lý...",
    isLoading: true,
  },
};

Khởi động Storybook lên để kiểm tra:

npm run storybook

Truy cập http://localhost:6006 trên trình duyệt. Bạn có thể bấm đổi size, đổi màu nền hoặc chỉnh sửa trực tiếp text label trong tab Controls để xem component co giãn linh hoạt ra sao.

Bước 5: Viết Interaction Test với hàm play()

Điểm mạnh nhất của Storybook 8 nằm ở gói @storybook/test. Gói này hợp nhất Testing Library và Vitest, cho phép bạn test hành vi click, gõ phím trực tiếp trên UI thay vì chỉ test chay trên terminal.

Thêm kịch bản click test vào cuối file src/components/Button.stories.tsx:

import { fn, within, userEvent, expect } from "@storybook/test";

export const InteractiveClick: Story = {
  args: {
    label: "Bấm thử nghiệm",
    onClick: fn(), // Mock function theo dõi lượt gọi
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button", { name: /bấm thử nghiệm/i });

    // Giả lập thao tác user click chuột
    await userEvent.click(button);

    // Kiểm tra hàm onClick đã kích hoạt chưa
    await expect(args.onClick).toHaveBeenCalledOnce();
  },
};

Mở story này trên trình duyệt, tab Interactions ở thanh công cụ bên dưới sẽ hiển thị trạng thái pass/fail theo từng miligiây. Tính năng time-travel debugging cho phép bạn tua lại từng bước thao tác, giúp việc gỡ lỗi focus hay submit form nhanh hơn rất nhiều.

5. Kinh nghiệm triển khai thực tế cho team

  • Đặt namespace rõ ràng cho Sidebar: Phân nhóm story theo cấu trúc Elements/Button, Forms/InputField, Overlays/Modal để cây thư mục luôn khoa học khi số lượng component lên tới hàng trăm cái.
  • Tận dụng autodocs để làm tài liệu: Giữ nguyên tag autodocs trong meta. Storybook sẽ tự phân tích TypeScript interface và tạo bảng tra cứu props chi tiết mà bạn không cần gõ markdown bằng tay.
  • Tích hợp vào quy trình Review (CI/CD): Khi chuẩn bị merge code, chạy lệnh npm run build-storybook để xuất thư mục tĩnh. Deploy thư mục này lên Vercel Preview hoặc Cloudflare Pages rồi đính link vào Pull Request. Designer và QA có thể bấm vào test trực tiếp trên link deploy thay vì chụp ảnh màn hình qua chat.
Share: