Guía de referencia rápida para el equipo de desarrollo — migración de stack moderno con TypeScript, React Query, Zustand y Zod.
// ✅ 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>
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>
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 })
// 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} />
// 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)
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)
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> ) }
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) }
| 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 |