Diagnóstico actual y objetivos
2.8MB
Bundle inicial
→ objetivo: < 800 KB
40+
Componentes de clase
→ migrar a hooks
15
Lifecycle duplicados
→ 1 useEffect por lógica
3
HOCs anidados
→ custom hooks
0
TypeScript coverage
→ objetivo: 100%
Inventario de componentes (prioridad)
PatientList
Alta
Lista paginada de 500+ pacientes con fetch en componentDidMount/DidUpdate. Causa re-renders en cascada.
componentDidMount + componentDidUpdate duplicados
Sin cancelación de requests en unmount
Estado de paginación en this.state
DashboardHeader
Alta
Envuelto en withAuth + withTheme + withAnalytics. Props drilleadas 3 niveles.
Triple HOC → prop hell
Difícil de testear aislado
Re-renders globales en cambio de tema
NutritionChart
Media
Usa contextType estático (legacy). Renderiza gráfico Chart.js con datos de contexto.
static contextType (una sola fuente)
Múltiples renders al cambiar período
AppointmentForm
Media
Formulario con 12 campos controlados en this.state. Validación manual en componentDidUpdate.
Estado complejo en clase
Validación acoplada al ciclo de vida
NotificationBell
Baja
Componente hoja simple. Sin children complejos. Fácil de migrar como primer ejercicio.
this.setState básico
PatientSearch
Baja
Input con debounce manual en componentDidUpdate. Sin optimización de re-renders.
Debounce con setTimeout en clase
Sin useTransition para resultados
Ruta de actualización incremental
16.14
Estado actual
Clases + HOCs
JS sin tipos
ReactDOM.render
→
17.0
Paso intermedio
Sin pooling de eventos
JSX transform (sin import React)
Ciclos de cleanup mejorados
→
18.3
Objetivo final
createRoot API
Batching automático
Transitions + Suspense
TypeScript estricto
Patrón 1 — PatientList: Estado + Lifecycle → Hooks
✕ Antes (React 16 — Clase)
JSX
class PatientList extends React.Component { constructor(props) { super(props); this.state = { patients: [], loading: true, page: 1, error: null }; } componentDidMount() { this.fetchPatients(this.props.clinicId); } componentDidUpdate(prevProps, prevState) { // duplicado de lógica de fetch if (prevProps.clinicId !== this.props.clinicId || prevState.page !== this.state.page) { this.fetchPatients(this.props.clinicId); } } componentWillUnmount() { // ¡sin cancelación de requests! } fetchPatients = async (clinicId) => { this.setState({ loading: true }); const data = await fetch( `/api/patients?clinic=${clinicId}&page=${this.state.page}` ).then(r => r.json()); this.setState({ patients: data, loading: false }); }; render() { const { patients, loading } = this.state; if (loading) return <Spinner />; return <ul>{patients.map(p => <PatientRow key={p.id} patient={p} /> )}</ul>; } }
✓ Después (React 18 — Hooks + TS)
TSX
interface PatientListProps { clinicId: string; } function PatientList({ clinicId }: PatientListProps) { const [patients, setPatients] = useState<Patient[]>([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [error, setError] = useState<string | null>(null); useEffect(() => { let cancelled = false; const load = async () => { setLoading(true); try { const res = await fetch( `/api/patients?clinic=${clinicId}&page=${page}` ); const data: Patient[] = await res.json(); if (!cancelled) { setPatients(data); setLoading(false); } } catch (e) { if (!cancelled) setError('Error cargando pacientes'); } }; load(); return () => { cancelled = true; }; // cleanup }, [clinicId, page]); // deps explícitas if (loading) return <Spinner />; if (error) return <ErrorBanner msg={error} />; return <ul> {patients.map(p => <PatientRow key={p.id} patient={p} />)} </ul>; }
Patrón 2 — DashboardHeader: HOCs → Custom Hooks
✕ Antes — Triple HOC
JSX
// HOC withAuth function withAuth(Component) { return class extends React.Component { state = { user: null }; componentDidMount() { getUser().then(u => this.setState({ user: u })); } render() { return <Component {...this.props} user={this.state.user} />; } }; } // Composición de HOCs — difícil de leer export default withAuth( withTheme( withAnalytics(DashboardHeader) ) ); // → propTypes y refs se pierden // → nombres confusos en DevTools // → testear requiere mock de 3 HOCs
✓ Después — Custom Hooks
TSX
// Hook useAuth function useAuth() { const [user, setUser] = useState<User | null>(null); useEffect(() => { getUser().then(setUser); }, []); return user; } // Hook useTheme function useTheme() { return useContext(ThemeContext); } // Hook useAnalytics function useAnalytics() { return useContext(AnalyticsContext); } // Componente limpio y tipado function DashboardHeader() { const user = useAuth(); const { theme } = useTheme(); const { track } = useAnalytics(); return ( <header style={{ background: theme.primary }}> <span>{user?.name}</span> <button onClick={() => track('logout')}> Salir </button> </header> ); } // Sin HOC wrapper — testar es trivial
React 18 — Features concurrentes aplicadas a NutriFlow
createRoot API
TSX
// Antes import ReactDOM from 'react-dom'; ReactDOM.render(<App />, root); // Después — React 18 import { createRoot } from 'react-dom/client'; const root = createRoot( document.getElementById('root')! ); root.render(<App />);
useTransition — PatientSearch
TSX
function PatientSearch() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { setQuery(e.target.value); // urgente startTransition(() => { // no urgente setResults(searchAPI(e.target.value)); }); }; return <> <input value={query} onChange={handleChange}/> {isPending && <Spinner size="sm" />} <Results data={results} /> >; }
Suspense + lazy (Code Splitting)
TSX
import { lazy, Suspense } from 'react'; // Lazy load de rutas pesadas const Dashboard = lazy(() => import('./pages/Dashboard')); const Reports = lazy(() => import('./pages/Reports')); function App() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/reports" element={<Reports />} /> </Routes> </Suspense> ); } // Bundle: 2.8MB → <800KB
Batching automático + useMemo
TSX
// React 18: batching en async también async function savePatient(patient) { await api.save(patient); setPatient(updated); // batched setDirty(false); // 1 solo re-render setLastSaved(new Date()); } // useMemo para lista filtrada pesada const activePatients = useMemo(() => patients.filter(p => p.status === 'active' && p.clinicId === clinicId ), [patients, clinicId] ); // no recalcula si deps no cambian
Optimización de performance
React.memo
Evita re-renders de PatientRow cuando la lista cambia de página pero la fila no fue modificada.
Reduce re-renders en 60-80%useCallback
Estabiliza handlers pasados a PatientRow como onSelect, onDelete — evita re-render de 500 filas.
Referential stabilityuseMemo
Filtros y agregaciones sobre el historial nutricional (pesados) se memoizan por período seleccionado.
CPU-bound optlazy + Suspense
Dashboard, Reports y NutritionHistory en chunks separados. Bundle inicial baja de 2.8 MB a <800 KB.
Bundle: −71%
Checklist de migración para el equipo NutriFlow
📦 Pre-migración
Crear rama
feature/react-18-migration Cobertura de tests >70% antes de tocar nada
Actualizar a React 17 primero (sin breaking changes)
Correr codemod
rename-unsafe-lifecycles Correr codemod
update-react-imports Instalar
@types/react @types/react-dom⚛ Clase → Hooks
Empezar por componentes hoja (NotificationBell)
Migrar estado a useState
Migrar ciclos de vida a useEffect (con deps)
Agregar cancelación de requests en cleanup
Extraer HOCs a custom hooks
Tipado TypeScript en cada componente migrado
🆕 React 18 upgrade
Actualizar react y react-dom a 18.3
Cambiar ReactDOM.render → createRoot
Activar StrictMode y corregir warnings
Implementar useTransition en PatientSearch
Lazy load de rutas con Suspense
Verificar batching en handlers async
🔥 Performance y QA final
Añadir React.memo a PatientRow e ítems de lista
useMemo en filtros del historial nutricional
useCallback en handlers pasados a listas
Medir bundle con webpack-bundle-analyzer
Test con React Testing Library + act()
Lighthouse Performance ≥ 90 en dashboard