⚛ Guía Técnica · React 16 → 18 · NutriFlow SaaS

Modernización React
Guía de Migración

Plan completo para migrar NutriFlow de React 16 + componentes de clase a React 18 con hooks, TypeScript y features concurrentes.

React 18.3
40+ componentes
TypeScript
Concurrent Features
📊
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 stability

useMemo

Filtros y agregaciones sobre el historial nutricional (pesados) se memoizan por período seleccionado.

CPU-bound opt

lazy + 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