Mục lục

Module 4: Component Architecture

Thiết kế React components có tính tái sử dụng cao, API rõ ràng, và dễ maintain với composition patterns.

Thời lượng: 1 tuần
Level: Intermediate
Yêu cầu: Modules 1-3 hoàn thành, React cơ bản

Tổng quan Module

Module này dạy bạn cách thiết kế component API tốt, chọn giữa composition vs configuration, và implement compound components pattern cho flexibility.

Kết quả học tập

Sau module này, bạn sẽ:

  • Thiết kế component API dễ sử dụng
  • Áp dụng composition patterns
  • Implement compound components
  • Avoid prop drilling và API pollution

Bài 4.1: Component API Design

Lý thuyết

Good Component API:

tsx:
// Bad: Too many props, unclear purpose
<Button 
  color="blue"
  size="medium"
  rounded={true}
  shadow={true}
  loading={false}
  disabled={false}
  fullWidth={false}
/>

// Good: Clear variants, sensible defaults
<Button variant="primary" size="md">
  Click me
</Button>

API Design Principles:

  1. Sensible Defaults

    • Most common use case = zero config
  2. Progressive Disclosure

    • Simple things easy, complex things possible
  3. Consistency

    • Same pattern across all components
  4. Type Safety

    • TypeScript makes invalid states impossible

Nguyên tắc then chốt

"API tốt là API không cần documentation để hiểu. Tên prop tự giải thích."

Thực hành

Bài tập: Design Button component API

Requirements:

  • 3 variants: primary, secondary, ghost
  • 3 sizes: sm, md, lg
  • Optional: disabled, loading, fullWidth
  • Support: onClick, type (submit/button/reset)
tsx:
interface ButtonProps {
  /** Visual variant */
  variant?: 'primary' | 'secondary' | 'ghost';
  
  /** Size */
  size?: 'sm' | 'md' | 'lg';
  
  /** Disabled state */
  disabled?: boolean;
  
  /** Loading state with spinner */
  loading?: boolean;
  
  /** Full width */
  fullWidth?: boolean;
  
  /** Button type */
  type?: 'button' | 'submit' | 'reset';
  
  /** Click handler */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  
  /** Children */
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  disabled = false,
  loading = false,
  fullWidth = false,
  type = 'button',
  onClick,
  children
}) => {
  const classNames = [
    'button',
    `button--${variant}`,
    `button--${size}`,
    fullWidth && 'button--full-width',
    loading && 'button--loading'
  ].filter(Boolean).join(' ');
  
  return (
    <button
      type={type}
      className={classNames}
      disabled={disabled || loading}
      onClick={onClick}
    >
      {loading && <Spinner size={size} />}
      {children}
    </button>
  );
};

CSS:

css:
.button {
  font-family: var(--font-sans);
  font-weight: 600;
  border-radius: var(--radius-md);
  transition: var(--transition-base);
  cursor: pointer;
}

/* Variants */
.button--primary {
  background: var(--color-primary);
  color: white;
  border: none;
}

.button--primary:hover:not(:disabled) {
  background: var(--color-primary-hover);
}

.button--secondary {
  background: transparent;
  color: var(--color-primary);
  border: 2px solid var(--color-primary);
}

.button--ghost {
  background: transparent;
  color: var(--color-text-primary);
  border: none;
}

/* Sizes */
.button--sm {
  padding: var(--space-2) var(--space-4);
  font-size: var(--font-size-sm);
}

.button--md {
  padding: var(--space-3) var(--space-6);
  font-size: var(--font-size-base);
}

.button--lg {
  padding: var(--space-4) var(--space-8);
  font-size: var(--font-size-lg);
}

/* States */
.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.button--full-width {
  width: 100%;
}

### Demo: Button Component

Trải nghiệm Button component với các tùy chọn khác nhau:

```playground-config
{
  "controls": [
    {
      "name": "variant",
      "label": "Biến thể",
      "type": "select",
      "options": ["primary", "secondary", "ghost"],
      "default": "primary"
    },
    {
      "name": "size",
      "label": "Kích thước",
      "type": "select",
      "options": ["sm", "md", "lg"],
      "default": "md"
    },
    {
      "name": "text",
      "label": "Nội dung",
      "type": "text",
      "default": "Click me"
    },
    {
      "name": "radius",
      "label": "Bo góc",
      "type": "range",
      "min": 0,
      "max": 24,
      "step": 2,
      "default": 8
    }
  ]
}

Nhiệm vụ:

  • Implement Button với 3 variants
  • Add loading state với spinner
  • Support all sizes
  • Write Storybook stories

Câu hỏi thảo luận

  1. Boolean Props: <Button primary /> vs <Button variant="primary" />? Pros/cons?
  2. Polymorphic Components: Button có nên render <a> nếu có href không?
  3. Icon Buttons: <Button icon={<StarIcon />} /> hay <IconButton icon={<StarIcon />} />?

Bài 4.2: Composition vs Configuration

Lý thuyết

Configuration (Props-heavy):

tsx:
<Card
  title="Product"
  subtitle="Description"
  image="/product.jpg"
  actions={[
    { label: "Edit", onClick: handleEdit },
    { label: "Delete", onClick: handleDelete }
  ]}
/>

Composition (Slots):

tsx:
<Card>
  <Card.Image src="/product.jpg" />
  <Card.Header>
    <Card.Title>Product</Card.Title>
    <Card.Subtitle>Description</Card.Subtitle>
  </Card.Header>
  <Card.Actions>
    <Button onClick={handleEdit}>Edit</Button>
    <Button onClick={handleDelete}>Delete</Button>
  </Card.Actions>
</Card>

When to use each:

  • Configuration: Simple, limited variants
  • Composition: Complex, many variants

Nguyên tắc then chốt

"Composition over configuration khi có nhiều hơn 5 props. Props không scale."

Thực hành

Bài tập: Convert Modal từ configuration sang composition

Before (Configuration):

tsx:
<Modal
  title="Delete Item"
  content="Are you sure?"
  confirmText="Delete"
  cancelText="Cancel"
  onConfirm={handleDelete}
  onCancel={handleCancel}
  variant="danger"
/>

After (Composition):

tsx:
<Modal open={open} onClose={handleCancel}>
  <Modal.Header>
    <Modal.Title>Delete Item</Modal.Title>
  </Modal.Header>
  
  <Modal.Body>
    <Text>Are you sure you want to delete this item?</Text>
  </Modal.Body>
  
  <Modal.Footer>
    <Button variant="ghost" onClick={handleCancel}>
      Cancel
    </Button>
    <Button variant="danger" onClick={handleDelete}>
      Delete
    </Button>
  </Modal.Footer>
</Modal>

Implementation:

tsx:
interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> & {
  Header: typeof ModalHeader;
  Title: typeof ModalTitle;
  Body: typeof ModalBody;
  Footer: typeof ModalFooter;
} = ({ open, onClose, children }) => {
  if (!open) return null;
  
  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
};

const ModalHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  <div className="modal-header">{children}</div>
);

const ModalTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  <h2 className="modal-title">{children}</h2>
);

const ModalBody: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  <div className="modal-body">{children}</div>
);

const ModalFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  <div className="modal-footer">{children}</div>
);

Modal.Header = ModalHeader;
Modal.Title = ModalTitle;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;

Nhiệm vụ:

  • Implement Modal với compound components
  • Support close button trong Header
  • Add transitions (fade in/out)
  • Handle focus trap và escape key

Câu hỏi thảo luận

  1. Learning Curve: Composition khó học hơn configuration. Worth it không?
  2. Required Parts: Nếu Modal.Footer bắt buộc, làm sao enforce?
  3. Flexibility vs Consistency: Too much flexibility → inconsistent UIs?

Bài 4.3: Compound Components Pattern

Lý thuyết

Compound Components:

  • Parent component share state với children qua Context
  • Children components biết cách interact với parent
  • Flexible nhưng vẫn type-safe

Example: Tabs

tsx:
<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="password">Password</Tabs.Trigger>
  </Tabs.List>
  
  <Tabs.Panel value="account">
    <AccountSettings />
  </Tabs.Panel>
  
  <Tabs.Panel value="password">
    <PasswordSettings />
  </Tabs.Panel>
</Tabs>

Nguyên tắc then chốt

"Compound components chia sẻ state ngầm qua Context, không qua props drilling."

Thực hành

Bài tập: Implement Accordion với compound components

tsx:
<Accordion>
  <Accordion.Item value="item-1">
    <Accordion.Trigger>
      What is Design System?
    </Accordion.Trigger>
    <Accordion.Content>
      A collection of reusable components...
    </Accordion.Content>
  </Accordion.Item>
  
  <Accordion.Item value="item-2">
    <Accordion.Trigger>
      Why use Design Tokens?
    </Accordion.Trigger>
    <Accordion.Content>
      Tokens ensure consistency...
    </Accordion.Content>
  </Accordion.Item>
</Accordion>

Implementation:

tsx:
interface AccordionContextValue {
  openItems: Set<string>;
  toggle: (value: string) => void;
}

const AccordionContext = React.createContext<AccordionContextValue | null>(null);

const Accordion: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [openItems, setOpenItems] = React.useState<Set<string>>(new Set());
  
  const toggle = (value: string) => {
    setOpenItems(prev => {
      const next = new Set(prev);
      if (next.has(value)) {
        next.delete(value);
      } else {
        next.add(value);
      }
      return next;
    });
  };
  
  return (
    <AccordionContext.Provider value={{ openItems, toggle }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
};

const AccordionItem: React.FC<{ value: string; children: React.ReactNode }> = ({ 
  value, 
  children 
}) => (
  <div className="accordion-item" data-value={value}>
    {children}
  </div>
);

const AccordionTrigger: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const context = React.useContext(AccordionContext);
  if (!context) throw new Error('AccordionTrigger must be inside Accordion');
  
  // Get value from parent AccordionItem
  const itemElement = React.useRef<HTMLDivElement>(null);
  const value = itemElement.current?.closest('[data-value]')?.getAttribute('data-value') || '';
  const isOpen = context.openItems.has(value);
  
  return (
    <button
      className="accordion-trigger"
      onClick={() => context.toggle(value)}
      aria-expanded={isOpen}
    >
      {children}
      <ChevronIcon className={isOpen ? 'rotate-180' : ''} />
    </button>
  );
};

const AccordionContent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const context = React.useContext(AccordionContext);
  if (!context) throw new Error('AccordionContent must be inside Accordion');
  
  const itemElement = React.useRef<HTMLDivElement>(null);
  const value = itemElement.current?.closest('[data-value]')?.getAttribute('data-value') || '';
  const isOpen = context.openItems.has(value);
  
  return (
    <div className={`accordion-content ${isOpen ? 'open' : ''}`}>
      {children}
    </div>
  );
};

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

Nhiệm vụ:

  • Implement Accordion
  • Support controlled mode (value prop)
  • Add animations (height transition)
  • Support multiple mode (nhiều items mở cùng lúc)

Câu hỏi thảo luận

  1. Context Performance: Re-render toàn bộ tree khi state change? Optimize thế nào?
  2. TypeScript: Làm sao enforce "Trigger phải có Content matching"?
  3. Accessibility: ARIA attributes nào cần cho Accordion?

Tổng kết Module

Bạn đã học:

  • Component API design với sensible defaults
  • Composition vs Configuration trade-offs
  • Compound Components pattern với Context

Bước tiếp theo

Module 5 sẽ dạy về Form Components - inputs, validation, và error handling.

Tiếp tục Module 5 →


Tài liệu tham khảo

Quảng cáo
mdhorizontal