Communitygithub.com

Patrones de Desarrollo Frontend

Patrones modernos de frontend para React, Next.js, gestión de estado, optimización de rendimiento y mejores prácticas de UI. Útil al construir o revisar componentes, estado o rendimiento de renderizado en React o Next.js.

¿Qué es Patrones de Desarrollo Frontend?

Este skill proporciona una guía completa de patrones de desarrollo frontend modernos, centrados en React, Next.js y la creación de interfaces de usuario de alto rendimiento. Es ideal para desarrolladores que trabajan con componentes, gestión de estado, obtención de datos, formularios, animaciones y accesibilidad. El skill incluye ejemplos de código detallados en TypeScript para patrones como composición sobre herencia, componentes compuestos, render props, hooks personalizados (useToggle, useQuery, useDebounce), gestión de estado con Context + Reducer, y optimizaciones de rendimiento como memoización, división de código y virtualización de listas. También cubre el manejo de formularios con validación, boundaries de error, animaciones con Framer Motion y navegación por teclado accesible. Cada patrón se presenta con un enfoque práctico y listo para usar, explicando cuándo y por qué aplicarlo, lo que lo convierte en una referencia valiosa tanto para construir nuevas funcionalidades como para revisar código existente en proyectos frontend.

Compatible con✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ValentinCmbr/claude-skills/tree/HEAD/plugins/skills/skills/frontend-patterns

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

¿Qué hace Patrones de Desarrollo Frontend?

Patrones modernos de frontend para React, Next.js e interfaces de usuario de alto rendimiento.

Cuándo Activar

  • Al construir componentes de React (composición, props, renderizado)
  • Al gestionar estado (useState, useReducer, Zustand, Context)
  • Al implementar obtención de datos (SWR, React Query, componentes de servidor)
  • Al optimizar rendimiento (memoización, virtualización, división de código)
  • Al trabajar con formularios (validación, inputs controlados, esquemas Zod)
  • Al manejar enrutamiento y navegación del lado del cliente
  • Al construir patrones de UI accesibles y responsivos

Patrones de Componentes

Composición sobre Herencia

// PASA: BUENO: Composición de componentes
interface CardProps {
  children: React.ReactNode
  variant?: 'default' | 'outlined'
}

export function Card({ children, variant = 'default' }: CardProps) {
  return <div className={`card card-${variant}`}>{children}</div>
}

export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}

export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

// Uso
<Card>
  <CardHeader>Título</CardHeader>
  <CardBody>Contenido</CardBody>
</Card>

Componentes Compuestos

interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined)

export function Tabs({ children, defaultTab }: {
  children: React.ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}

export function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list">{children}</div>
}

export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab debe usarse dentro de Tabs')

  return (
    <button
      className={context.activeTab === id ? 'active' : ''}
      onClick={() => context.setActiveTab(id)}
    >
      {children}
    </button>
  )
}

// Uso
<Tabs defaultTab="resumen">
  <TabList>
    <Tab id="resumen">Resumen</Tab>
    <Tab id="detalles">Detalles</Tab>
  </TabList>
</Tabs>

Patrón Render Props

interface DataLoaderProps<T> {
  url: string
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}

export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return <>{children(data, loading, error)}</>
}

// Uso
<DataLoader<Market[]> url="/api/mercados">
  {(markets, loading, error) => {
    if (loading) return <Spinner />
    if (error) return <Error error={error} />
    return <MarketList markets={markets!} />
  }}
</DataLoader>

Patrones de Hooks Personalizados

Hook de Gestión de Estado

export function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => {
    setValue(v => !v)
  }, [])

  return [value, toggle]
}

// Uso
const [isOpen, toggleOpen] = useToggle()

Hook de Obtención de Datos Asíncrona

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}

export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)

  // Mantén el último fetcher/options en refs para que refetch se mantenga
  // referencialmente estable incluso cuando los llamadores pasan funciones inline y objetos literales.
  // Sin esto, cada renderizado crea un nuevo refetch, y el efecto de abajo
  // se re-ejecuta después de cada actualización de estado - un bucle infinito de fetch.
  const fetcherRef = useRef(fetcher)
  const optionsRef = useRef(options)
  useEffect(() => {
    fetcherRef.current = fetcher
    optionsRef.current = options
  })

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const result = await fetcherRef.current()
      setData(result)
      optionsRef.current?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      optionsRef.current?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [])

  const enabled = options?.enabled !== false

  useEffect(() => {
    if (enabled) {
      refetch()
    }
  }, [key, enabled, refetch])

  return { data, error, loading, refetch }
}

// Uso
const { data: markets, loading, error, refetch } = useQuery(
  'mercados',
  () => fetch('/api/mercados').then(r => r.json()),
  {
    onSuccess: data => console.log('Obtenidos', data.length, 'mercados'),
    onError: err => console.error('Falló:', err)
  }
)

Hook de Rebote (Debounce)

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(handler)
  }, [value, delay])

  return debouncedValue
}

// Uso
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)

useEffect(() => {
  if (debouncedQuery) {
    performSearch(debouncedQuery)
  }
}, [debouncedQuery])

Patrones de Gestión de Estado

Patrón Context + Reducer

interface State {
  markets: Market[]
  selectedMarket: Market | null
  loading: boolean
}

type Action =
  | { type: 'SET_MARKETS'; payload: Market[] }
  | { type: 'SELECT_MARKET'; payload: Market }
  | { type: 'SET_LOADING'; payload: boolean }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_MARKETS':
      return { ...state, markets: action.payload }
    case 'SELECT_MARKET':
      return { ...state, selectedMarket: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

const MarketContext = createContext<{
  state: State
  dispatch: Dispatch<Action>
} | undefined>(undefined)

export function MarketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    markets: [],
    selectedMarket: null,
    loading: false
  })

  return (
    <MarketContext.Provider value={{ state, dispatch }}>
      {children}
    </MarketContext.Provider>
  )
}

export function useMarkets() {
  const context = useContext(MarketContext)
  if (!context) throw new Error('useMarkets debe usarse dentro de MarketProvider')
  return context
}

Optimización de Rendimiento

Memoización

// PASA: useMemo para cálculos costosos
// Copia antes de ordenar - Array.prototype.sort muta en el lugar
const sortedMarkets = useMemo(() => {
  return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])

// PASA: useCallback para funciones pasadas a componentes hijos
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])

// PASA: React.memo para componentes puros
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
  return (
    <div className="market-card">
      <h3>{market.name}</h3>
      <p>{market.description}</p>
    </div>
  )
})

División de Código y Carga Perezosa

import { lazy, Suspense } from 'react'

// PASA: Carga perezosa de componentes pesados
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))

export function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>

      <Suspense fallback={null}>
        <ThreeJsBackground />
      </Suspense>
    </div>
  )
}

Virtualización para Listas Largas

import { useVirtualizer } from '@tanstack/react-virtual'

export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // Altura estimada de la fila
    overscan: 5  // Elementos adicionales para renderizar
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }}
          >
            <MarketCard market={markets[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

Patrones de Manejo de Formularios

Formulario Controlado con Validación

interface FormData {
  name: string
  description: string
  endDate: string
}

interface FormErrors {
  name?: string
  description?: string
  endDate?: string
}

export function CreateMarketForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    description: '',
    endDate: ''
  })

  const [errors, setErrors] = useState<FormErrors>({})

  const validate = (): boolean => {
    const newErrors: FormErrors = {}

    if (!formData.name.trim()) {
      newErrors.name = 'El nombre es obligatorio'
    } else if (formData.name.length > 200) {
      newErrors.name = 'El nombre debe tener menos de 200 caracteres'
    }

    if (!formData.description.trim()) {
      newErrors.description = 'La descripción es obligatoria'
    }

    if (!formData.endDate) {
      newErrors.endDate = 'La fecha de finalización es obligatoria'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validate()) return

    try {
      await createMarket(formData)
      // Manejo de éxito
    } catch (error) {
      // Manejo de error
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
        placeholder="Nombre del mercado"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* Otros campos */}

      <button type="submit">Crear Mercado</button>
    </form>
  )
}

Patrón de Límite de Error (Error Boundary)

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Límite de error atrapado:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Algo salió mal</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Intentar de nuevo
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// Uso
<ErrorBoundary>
  <App />
</ErrorBoundary>

Patrones de Animación

Animaciones con Framer Motion

import { motion, AnimatePresence } from 'framer-motion'

// PASA: Animaciones de listas
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
  return (
    <AnimatePresence>
      {markets.map(market => (
        <motion.div
          key={market.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ duration: 0.3 }}
        >
          <MarketCard market={market} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

// PASA: Animaciones de modal
export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="modal-overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.div
            className="modal-content"
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

Patrones de Accesibilidad

Navegación por Teclado

export function Dropdown({ options, onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, options.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        e.preventDefault()
        onSelect(options[activeIndex])
        setIsOpen(false)
        break
      case 'Escape':
        e.preventDefault()
        setIsOpen(false)
        break
    }
  }

  return (
    <div onKeyDown={handleKeyDown}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Seleccionar opción
      </button>
      {isOpen && (
        <ul role="listbox">
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={index === activeIndex}
              style={{ background: index === activeIndex ? '#e0e0e0' : 'transparent' }}
              onClick={() => {
                onSelect(option)
                setIsOpen(false)
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Etiquetas ARIA y Semántica HTML

// PASA: Uso adecuado de roles y propiedades ARIA
export function TabPanel({ id, active, children }: {
  id: string
  active: boolean
  children: React.ReactNode
}) {
  return (
    <div
      role="tabpanel"
      id={`panel-${id}`}
      aria-labelledby={`tab-${id}`}
      hidden={!active}
    >
      {children}
    </div>
  )
}

// PASA: Elementos de navegación semántica
export function MainNavigation() {
  return (
    <nav aria-label="Navegación principal">
      <ul>
        <li><a href="/">Inicio</a></li>
        <li><a href="/markets">Mercados</a></li>
        <li><a href="/about">Acerca de</a></li>
      </ul>
    </nav>
  )
}

// PASA: Botones accesibles con descripciones
export function IconButton({ icon, label, onClick }: {
  icon: React.ReactNode
  label: string
  onClick: () => void
}) {
  return (
    <button
      aria-label={label}
      onClick={onClick}
    >
      {icon}
    </button>
  )
}

Mejores Prácticas de Rendimiento

Estrategias de Renderizado

// PASA: Renderizado condicional con early return
export function MarketDetails({ marketId }: { marketId: string }) {
  const { data: market, loading } = useQuery(
    ['market', marketId],
    () => fetchMarket(marketId)
  )

  if (loading) return <MarketSkeleton />
  if (!market) return <NotFound />

  return (
    <div>
      <h1>{market.name}</h1>
      <p>{market.description}</p>
    </div>
  )
}

// PASA: Evitar renderizados innecesarios con keys estables
export function MarketList({ markets }: { markets: Market[] }) {
  return (
    <ul>
      {markets.map(market => (
        <MarketItem key={market.id} market={market} />
      ))}
    </ul>
  )
}

Optimización de Bundle

// PASA: Importaciones dinámicas para dependencias pesadas
const Chart = dynamic(() => import('./Chart'), {
  ssr: false,
  loading: () => <ChartSkeleton />
})

// PASA: Tree-shaking con importaciones selectivas
import { format, parseISO } from 'date-fns'
// Evitar: import { format } from 'date-fns/format'
// Mejor: import { format } from 'date-fns' (con tree-shaking)

// PASA: Separar código de vendor para caché de navegador
// En next.config.js:
// experimental: {
//   optimizePackageImports: ['@radix-ui/react-icons', 'lodash-es']
// }

Skills relacionados