CULTIVA IA × NutriTrack Pro

Patrones Frontend React + Next.js

Guía de referencia rápida para el equipo de desarrollo — migración de stack moderno con TypeScript, React Query, Zustand y Zod.

React 18 Next.js 14 TypeScript React Query v5 Zustand Zod Framer Motion
8
Categorías de patrones
24+
Ejemplos TypeScript
100%
Type-safe
App Router
Next.js 14
WCAG AA
Accesibilidad
🧩
Patrones de Componentes
3 patrones
Composición — NutrientCard
Composición
// ✅ Composición sobre herencia
interface NutrientCardProps {
  children: React.ReactNode
  variant?: 'default' | 'highlight' | 'danger'
}

export function NutrientCard({ children, variant = 'default' }) {
  return <div className={`card card-${variant}`}>
    {children}</div>
}
export function CardMacros({ protein, carbs, fat }: Macros) {
  return <div className="card-macros">
    <span>P: {protein}g</span><span>C: {carbs}g</span>
  </div>
}

// Uso:
<NutrientCard variant="highlight">
  <CardMacros protein={32} carbs={45} fat={12} />
</NutrientCard>
Partes intercambiables sin herencia
Aplicado en: MealCard, EmployeeCard, MetricWidget
Componentes Compuestos — PlanTabs
Compound
const PlanContext = createContext<PlanContextValue | undefined>(undefined)

export function PlanTabs({ children, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab)
  return (
    <PlanContext.Provider value={{ activeTab, setActiveTab }}>
      {children}</PlanContext.Provider>
  )
}

// Uso en dashboard de empleado:
<PlanTabs defaultTab="nutricion">
  <TabList>
    <Tab id="nutricion">Nutrición</Tab>
    <Tab id="actividad">Actividad</Tab>
    <Tab id="progreso">Progreso</Tab>
  </TabList>
  <TabPanel id="nutricion"><MacroChart /></TabPanel>
</PlanTabs>
Estado compartido sin prop-drilling
Aplicado en: PlanTabs, WeekSelector, FilterGroup
🪝
Custom Hooks
3 hooks
useDebounce — Búsqueda de alimentos
Hook
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
}

// FoodSearch.tsx — API Garmin/USDA
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 400)

const { data: foods } = useQuery({
  queryKey: ['foods', debouncedQuery],
  queryFn: () => searchFoods(debouncedQuery),
  enabled: debouncedQuery.length > 2
})
Reduce llamadas API en ~80%
Delay 400ms para búsqueda de alimentos
useNutritionPlan — Data fetching
Hook
// hooks/useNutritionPlan.ts
export function useNutritionPlan(employeeId: string) {
  return useQuery({
    queryKey: ['nutrition-plan', employeeId],
    queryFn: async () => {
      const res = await fetch(`/api/plans/${employeeId}`)
      if (!res.ok) throw new Error('Plan no encontrado')
      return planSchema.parse(await res.json())
    },
    staleTime: 5 * 60 * 1000, // 5 min
    retry: 2
  })
}

// Uso con Zod runtime validation:
const { data: plan, isLoading, error } = useNutritionPlan(employee.id)
if (isLoading) return <PlanSkeleton />
if (error) return <PlanError error={error} />
Zod runtime validation en el fetch
Cache 5 min, retry x2
🗂️
Gestión de Estado
2 patrones
Zustand Store — Sesión de empresa
Estado global
// store/companyStore.ts
interface CompanyState {
  company: Company | null
  activePlan: 'starter' | 'growth' | 'enterprise'
  employeeCount: number
  setCompany: (c: Company) => void
  resetSession: () => void
}

export const useCompanyStore = create<CompanyState>((set) => ({
  company: null,
  activePlan: 'starter',
  employeeCount: 0,
  setCompany: (c) => set({
    company: c,
    activePlan: c.plan,
    employeeCount: c.employees.length
  }),
  resetSession: () => set({
    company: null, activePlan: 'starter'
  })
}))

// Selector granular (evita re-renders)
const plan = useCompanyStore((s) => s.activePlan)
Sin Provider — acceso directo
Selector granular = re-renders mínimos
Context + Reducer — Onboarding
useReducer
type OnboardingAction =
  | { type: 'SET_STEP'; payload: number }
  | { type: 'SET_EMPLOYEE_DATA'; payload: Partial<Employee> }
  | { type: 'COMPLETE' }

function onboardingReducer(state: OnboardingState, action: OnboardingAction) {
  switch (action.type) {
    case 'SET_STEP':
      return { ...state, currentStep: action.payload }
    case 'SET_EMPLOYEE_DATA':
      return { ...state,
        employeeData: { ...state.employeeData, ...action.payload }}
    case 'COMPLETE':
      return { ...state, completed: true }
    default: return state
  }
}
// Flujo: 4 pasos (perfil → objetivos → plan → confirmación)
Transitions predecibles y testeables
Wizard de 4 pasos de onboarding
Rendimiento y Formularios
4 patrones
Virtualización — Historial de comidas (500+ filas)
Performance
import { useVirtualizer } from '@tanstack/react-virtual'

export function MealHistory({ entries }: { entries: MealEntry[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: entries.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72, // px por fila
    overscan: 5
  })

  return (
    <div ref={parentRef} style={{ height: '520px', overflow: 'auto' }}>
      {virtualizer.getVirtualItems().map(row => (
        <MealRow
          key={row.key}
          entry={entries[row.index]}
          style={{ transform: `translateY(${row.start}px)` }}
        />
      ))}
    </div>
  )
}
Solo ~10 DOM nodes para 500+ filas
Scroll 60fps en datasets grandes
Formulario Zod — Alta de empleado
Formulario
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

const employeeSchema = z.object({
  name:   z.string().min(2, 'Nombre requerido'),
  email:  z.string().email('Email inválido'),
  weight: z.number().min(40).max(200),
  goal:   z.enum(['perder', 'mantener', 'ganar']),
  allergies: z.array(z.string()).optional()
})

type EmployeeForm = z.infer<typeof employeeSchema>

export function EmployeeOnboardingForm() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<EmployeeForm>({ resolver: zodResolver(employeeSchema) })

  const onSubmit = (data: EmployeeForm) =>
    createEmployee(data)
}
Schema compartido front + API
Errores tipados sin boilerplate
📊
Cuándo usar qué — Decisiones de estado
Necesidad Solución recomendada Alternativa NutriTrack: ejemplo
Estado local de UI (toggle, modal) useState ❌ Zustand (overkill) Modal "Añadir comida"
Lógica compleja multi-acción useReducer ❌ useState múltiple Wizard onboarding 4 pasos
Estado global cliente Zustand ❌ Context + Provider Sesión empresa, tema, idioma
Datos de servidor (REST/API) React Query ❌ useEffect manual Planes, empleados, métricas
Datos estáticos / SSR Server Components ❌ Client fetch Catálogo alimentos, planes base
Formularios complejos RHF + Zod ❌ Controlled + manual validate Alta empleado, config plan, perfil
Checklist de migración — NutriTrack Pro
Design System base — Card, Button, Input, Modal
Composición + Compound Components con Tailwind. Storybook configurado.
Completado
Zustand store — CompanyStore + UserStore
Selectores granulares, persistencia en localStorage (zustand/middleware).
Completado
React Query — hooks para planes, empleados, métricas
QueryClient con staleTime 5 min, retry 2, Zod parse en queryFn.
Completado
Formularios RHF + Zod — Alta empleado, Config plan
Schema compartido con API (tRPC). Errores en tiempo real, accesibles.
Completado
Virtualización — MealHistory + ActivityLog
@tanstack/react-virtual para tablas de 500+ registros. Estimado: 3h.
En progreso
Animaciones Framer Motion — Page transitions + list enter
AnimatePresence en rutas, motion.div en listas de empleados.
Pendiente
Accesibilidad WCAG AA — Keyboard nav + focus trap
Dropdown con ArrowUp/Down/Esc, focus restore en modales, aria-labels.
Pendiente