AgentSkillsCN

React Components

React 组件

SKILL.md

React 19 Component Patterns

Component Types

Server Component (Default)

tsx
// components/owner/ClientStats.tsx
// No 'use client' = Server Component

import { getClientCount } from '@/lib/firebase/firestore/clients'

export async function ClientStats() {
  const count = await getClientCount()

  return (
    <div className="p-4 bg-white rounded-lg shadow">
      <h3 className="text-lg font-semibold">Total Clientes</h3>
      <p className="text-3xl font-bold">{count}</p>
    </div>
  )
}

Client Component

tsx
'use client'
// components/owner/ClientForm.tsx

import { useState } from 'react'

interface ClientFormProps {
  onSubmit: (data: ClientData) => void
  initialData?: ClientData
}

export function ClientForm({ onSubmit, initialData }: ClientFormProps) {
  const [name, setName] = useState(initialData?.name ?? '')
  const [email, setEmail] = useState(initialData?.email ?? '')
  const [phone, setPhone] = useState(initialData?.phone ?? '')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    onSubmit({ name, email, phone })
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Nombre</label>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 w-full px-3 py-2 border rounded-md"
          required
        />
      </div>
      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 w-full px-3 py-2 border rounded-md"
          required
        />
      </div>
      <div>
        <label className="block text-sm font-medium">Teléfono</label>
        <input
          type="tel"
          value={phone}
          onChange={(e) => setPhone(e.target.value)}
          className="mt-1 w-full px-3 py-2 border rounded-md"
        />
      </div>
      <button
        type="submit"
        className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
      >
        Guardar
      </button>
    </form>
  )
}

Props Patterns

Basic Props

tsx
interface ButtonProps {
  children: React.ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  onClick?: () => void
}

export function Button({
  children,
  variant = 'primary',
  size = 'md',
  disabled = false,
  onClick
}: ButtonProps) {
  const baseStyles = 'font-medium rounded-md transition-colors'

  const variantStyles = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700',
  }

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

  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
    >
      {children}
    </button>
  )
}

Extending HTML Elements

tsx
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string
  error?: string
}

export function Input({ label, error, className = '', ...props }: InputProps) {
  return (
    <div className="space-y-1">
      {label && (
        <label className="block text-sm font-medium text-gray-700">
          {label}
        </label>
      )}
      <input
        className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
          error ? 'border-red-500' : 'border-gray-300'
        } ${className}`}
        {...props}
      />
      {error && <p className="text-sm text-red-500">{error}</p>}
    </div>
  )
}

Composition Pattern

tsx
// Card component with composable parts
interface CardProps {
  children: React.ReactNode
  className?: string
}

export function Card({ children, className = '' }: CardProps) {
  return (
    <div className={`bg-white rounded-lg shadow-md overflow-hidden ${className}`}>
      {children}
    </div>
  )
}

Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="px-4 py-3 border-b bg-gray-50">{children}</div>
}

Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="p-4">{children}</div>
}

Card.Footer = function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="px-4 py-3 border-t bg-gray-50">{children}</div>
}

// Usage
<Card>
  <Card.Header>
    <h3>Cliente</h3>
  </Card.Header>
  <Card.Body>
    <p>Información del cliente</p>
  </Card.Body>
  <Card.Footer>
    <Button>Editar</Button>
  </Card.Footer>
</Card>

Children Pattern

tsx
// Modal component
interface ModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  if (!isOpen) return null

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="fixed inset-0 bg-black/50" onClick={onClose} />
      <div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
        <div className="flex items-center justify-between p-4 border-b">
          <h2 className="text-lg font-semibold">{title}</h2>
          <button onClick={onClose} className="text-gray-500 hover:text-gray-700">
            ✕
          </button>
        </div>
        <div className="p-4">{children}</div>
      </div>
    </div>
  )
}

Render Props Pattern

tsx
interface DataListProps<T> {
  data: T[]
  loading?: boolean
  error?: string
  renderItem: (item: T, index: number) => React.ReactNode
  emptyMessage?: string
}

export function DataList<T>({
  data,
  loading,
  error,
  renderItem,
  emptyMessage = 'No hay datos'
}: DataListProps<T>) {
  if (loading) {
    return <div className="animate-pulse">Cargando...</div>
  }

  if (error) {
    return <div className="text-red-500">{error}</div>
  }

  if (data.length === 0) {
    return <div className="text-gray-500">{emptyMessage}</div>
  }

  return <div className="space-y-2">{data.map(renderItem)}</div>
}

// Usage
<DataList
  data={clients}
  loading={isLoading}
  renderItem={(client, i) => <ClientCard key={client.id} client={client} />}
  emptyMessage="No hay clientes registrados"
/>

Conditional Rendering

tsx
interface StatusBadgeProps {
  status: 'active' | 'inactive' | 'pending'
}

export function StatusBadge({ status }: StatusBadgeProps) {
  const styles = {
    active: 'bg-green-100 text-green-800',
    inactive: 'bg-gray-100 text-gray-800',
    pending: 'bg-yellow-100 text-yellow-800',
  }

  const labels = {
    active: 'Activo',
    inactive: 'Inactivo',
    pending: 'Pendiente',
  }

  return (
    <span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status]}`}>
      {labels[status]}
    </span>
  )
}

Event Handling

tsx
'use client'

interface ConfirmButtonProps {
  onConfirm: () => void
  children: React.ReactNode
}

export function ConfirmButton({ onConfirm, children }: ConfirmButtonProps) {
  const [showConfirm, setShowConfirm] = useState(false)

  const handleClick = () => {
    if (showConfirm) {
      onConfirm()
      setShowConfirm(false)
    } else {
      setShowConfirm(true)
      setTimeout(() => setShowConfirm(false), 3000)
    }
  }

  return (
    <button
      onClick={handleClick}
      className={`px-4 py-2 rounded-md ${
        showConfirm
          ? 'bg-red-600 text-white'
          : 'bg-gray-200 text-gray-800'
      }`}
    >
      {showConfirm ? '¿Confirmar?' : children}
    </button>
  )
}

Loading States

tsx
interface LoadingProps {
  size?: 'sm' | 'md' | 'lg'
}

export function Loading({ size = 'md' }: LoadingProps) {
  const sizeStyles = {
    sm: 'h-4 w-4',
    md: 'h-8 w-8',
    lg: 'h-12 w-12',
  }

  return (
    <div className="flex items-center justify-center">
      <div
        className={`${sizeStyles[size]} animate-spin rounded-full border-2 border-gray-300 border-t-blue-600`}
      />
    </div>
  )
}

File Naming Convention

TypeFile NameExample
UI ComponentPascalCase.tsxButton.tsx, Card.tsx
Feature ComponentPascalCase.tsxClientForm.tsx, AppointmentCard.tsx
Index exportindex.tsRe-export multiple components
Types alongsidetypes.tsComponent-specific types