Patrones de Rendimiento en React: Memoización, Virtualización y Lazy Loading

Patrones de Rendimiento en React: Memoización, Virtualización y Lazy Loading
Photo by Łukasz Nieścioruk / Unsplash

Introducción

El rendimiento no es solo una métrica técnica—es un componente esencial de la experiencia de usuario. Una aplicación React puede tener todas las características correctas, pero si su interfaz se siente lenta o produce bloqueos visibles, los usuarios la percibirán como de baja calidad.

En el desarrollo frontend actual, donde las aplicaciones crecen en complejidad y los usuarios esperan experiencias instantáneas, dominar los patrones de rendimiento en React se ha convertido en una habilidad fundamental para cualquier desarrollador.

Este artículo explora tres pilares fundamentales que sustentan el rendimiento en aplicaciones React:

  • Computación eficiente: Minimizar el trabajo que realiza la CPU
  • Renderizado optimizado: Reducir actualizaciones innecesarias del DOM
  • Carga inteligente: Entregar solo el código necesario en el momento adecuado

A diferencia de otros recursos que ofrecen consejos generales, adoptaremos un enfoque metódico y estructurado para cada patrón, explorando no solo el "cómo" sino también el "cuándo" y el "por qué" de cada técnica.

💡 Pro tip
El rendimiento debe abordarse con datos, no intuiciones. Antes de implementar cualquier optimización, identifica y mide los problemas reales que experimentan tus usuarios.

Entendiendo el proceso de renderizado

Para optimizar el rendimiento en React, primero debemos entender qué ocurre cuando un componente se renderiza y cómo React determina qué debe actualizarse en el DOM.

Virtual DOM y algoritmo de reconciliación

React mantiene una representación ligera del DOM real, conocida como Virtual DOM. Cuando el estado de un componente cambia, React realiza el siguiente proceso:

  1. Ejecuta la función del componente para obtener un nuevo árbol de elementos React (Virtual DOM)
  2. Compara este nuevo árbol con la versión anterior (proceso de "diffing")
  3. Identifica las diferencias ("reconciliación")
  4. Aplica únicamente esos cambios al DOM real

Este proceso es increíblemente eficiente comparado con la manipulación directa del DOM, pero no es mágico—cada paso consume recursos, y cuando los componentes se vuelven complejos o los cambios de estado frecuentes, pueden surgir problemas de rendimiento.

Qué gatilla realmente un re-render

Uno de los conceptos más importantes—y frecuentemente malentendidos—en React es qué causa exactamente que un componente se vuelva a renderizar. Principalmente, los re-renders ocurren cuando:

  1. El estado propio del componente cambia (useState, useReducer)
  2. El contexto utilizado por el componente cambia
  3. Un componente padre se vuelve a renderizar

Este último punto es crucial: por defecto, cuando un componente se renderiza, todos sus componentes hijos también se renderizan, independientemente de si sus props cambiaron o no.

// ❌ Cada vez que Counter incrementa, 
// ExpensiveComponent se vuelve a renderizar aunque title nunca cambie
function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Incrementar: {count}
      </button>
      <ExpensiveComponent title="Siempre el mismo título" />
    </div>
  );
}

Esta es precisamente la razón por la que necesitamos herramientas como la memoización, que veremos en las próximas secciones.

Herramientas para visualizar renders innecesarios

Antes de optimizar, necesitamos identificar dónde están ocurriendo los re-renders innecesarios. Varias herramientas pueden ayudarnos:

  • React DevTools Profiler: Herramienta oficial que permite grabar y analizar renders, mostrando qué componentes se renderizaron y por qué
  • why-did-you-render: Biblioteca que notifica en la consola cuando ocurren re-renders potencialmente innecesarios
  • Highlight Updates: Característica en React DevTools que colorea componentes cuando se actualizan
// Configuración básica de why-did-you-render
import React from 'react';

if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender.whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}
🔍 Detalle técnico
El modo estricto de React (<React.StrictMode>) intencionalmente realiza renders adicionales en desarrollo para ayudar a identificar efectos secundarios impuros. Estos renders dobles no ocurren en producción.

Memoización efectiva con React.memo

La memoización es una técnica que evita cálculos o renderizados innecesarios al "recordar" resultados previos. React.memo es un higher-order component que memoriza el resultado del renderizado de un componente, evitando re-renders cuando sus props no cambian.

Cuándo usar (y no usar) React.memo

React.memo no es una herramienta para aplicar indiscriminadamente a todos los componentes. Usarlo correctamente requiere entender sus beneficios y costos.

Casos ideales para usar React.memo:

  • Componentes funcionales puros que renderan los mismos resultados dadas las mismas props
  • Componentes que se renderizan frecuentemente con las mismas props
  • Componentes con lógica de renderizado costosa
  • Componentes que reciben props simples (primitivas)

Cuándo evitar React.memo:

  • Componentes que casi siempre reciben props diferentes
  • Componentes muy simples donde el costo de comparación puede exceder el beneficio
  • Componentes que dependen de datos contextuales que cambian frecuentemente
// ✅ Buen candidato para React.memo
const PriceDisplay = React.memo(function PriceDisplay({ price, currency }) {
  // Lógica de formato compleja o componente visual pesado
  return <div className="price-tag">{currency}{price.toFixed(2)}</div>;
});

// ❌ Mal candidato para React.memo
const SimpleLabel = React.memo(function SimpleLabel({ text }) {
  // Componente demasiado simple, la memoización agrega más sobrecarga que beneficio
  return <span>{text}</span>;
});

Implementación correcta con props complejas

Cuando un componente recibe objetos o funciones como props, React.memo puede fallar en prevenir re-renders debido a que en JavaScript las comparaciones de igualdad (===) para estos tipos funcionan por referencia, no por valor.

Para solucionar este problema, debemos estabilizar estas referencias usando hooks como useMemo y useCallback (que veremos en detalle más adelante) o implementar una función de comparación personalizada.

// Problema: Nuevas referencias en cada render
function ProductList({ category }) {
  // ❌ Se crea un nuevo objeto en cada render
  const filters = { category, inStock: true };
  
  // ❌ Se crea una nueva función en cada render
  const onItemClick = (item) => {
    console.log('Item clicked:', item);
  };
  
  return <MemoizedList filters={filters} onItemClick={onItemClick} />;
}

// Solución: Estabilizar referencias
function ProductList({ category }) {
  // ✅ Objeto memoizado que solo cambia cuando category cambia
  const filters = useMemo(() => ({ 
    category, 
    inStock: true 
  }), [category]);
  
  // ✅ Función memoizada
  const onItemClick = useCallback((item) => {
    console.log('Item clicked:', item);
  }, []);
  
  return <MemoizedList filters={filters} onItemClick={onItemClick} />;
}

Customización con funciones de comparación

Para casos más complejos, podemos proporcionar una función de comparación personalizada como segundo argumento a React.memo. Esta función recibe las props anteriores y nuevas, y debe retornar true si son equivalentes (no requiere re-render) o false si han cambiado significativamente.

function arePropsEqual(prevProps, nextProps) {
  // Solo re-renderizar si los IDs de los elementos cambiaron
  // Ignorar cambios en propiedades visuales menos importantes
  return (
    prevProps.items.length === nextProps.items.length &&
    prevProps.items.every((item, index) => 
      item.id === nextProps.items[index].id
    )
  );
}

const MemoizedItemList = React.memo(ItemList, arePropsEqual);
⚠️ Advertencia
Las funciones de comparación personalizadas agregan complejidad y pueden introducir bugs sutiles si no consideran todos los casos. Úsalas con cautela y asegúrate de comprender completamente qué props son críticas para el renderizado.

Optimización de cálculos con useMemo

Mientras React.memo optimiza el renderizado completo de un componente, useMemo es un hook que permite memoizar valores calculados específicos dentro de un componente.

Identificando cálculos costosos

No todos los cálculos necesitan memoización. Antes de aplicar useMemo, identifica operaciones verdaderamente costosas:

  • Transformaciones de datos complejas
  • Filtrado o clasificación de grandes arrays
  • Cálculos matemáticos intensivos
  • Derivación de estructuras de datos complejas
function ProductCatalog({ products, searchTerm, category }) {
  // ❌ Operación costosa en cada render
  const filteredProducts = products
    .filter(product => 
      product.category === category && 
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => a.price - b.price);
  
  // Resto del componente...
}

En el ejemplo anterior, el filtrado y clasificación se realizan en cada render, incluso cuando products, searchTerm y category no han cambiado.

Estrategias para dependencias efectivas

La clave para usar useMemo correctamente está en el array de dependencias. Solo los valores que influyen en el resultado del cálculo deben incluirse como dependencias.

function ProductCatalog({ products, searchTerm, category }) {
  // ✅ Cálculo memoizado con dependencias precisas
  const filteredProducts = useMemo(() => {
    console.log('Recalculando productos filtrados');
    return products
      .filter(product => 
        product.category === category && 
        product.name.toLowerCase().includes(searchTerm.toLowerCase())
      )
      .sort((a, b) => a.price - b.price);
  }, [products, searchTerm, category]); // Solo recalcula cuando estas props cambian
  
  // El componente puede renderizarse por otras razones sin repetir el filtrado
}

Un error común es omitir dependencias o incluir dependencias innecesarias. React proporciona un linter (con la regla exhaustive-deps) que ayuda a identificar dependencias faltantes o superfluas.

Patrones comunes y anti-patrones

Patrón: Memoización en cascada

La memoización es especialmente poderosa cuando se aplica en cascada, donde los valores memoizados dependen de otros valores memoizados.

function Dashboard({ sales, users }) {
  // Primer nivel de memoización
  const processedSales = useMemo(() => processSales(sales), [sales]);
  
  // Segundo nivel: depende del resultado del primer useMemo
  const salesMetrics = useMemo(() => 
    calculateMetrics(processedSales), 
    [processedSales]
  );
  
  // Tercer nivel: combina resultados de memoizaciones anteriores
  const insights = useMemo(() => 
    deriveInsights(salesMetrics, users), 
    [salesMetrics, users]
  );
  
  // Renderizado usando el resultado final memoizado
}

Anti-patrón: Sobre-memoización

function Counter() {
  const [count, setCount] = useState(0);
  
  // ❌ Memoización innecesaria para operaciones simples
  const doubledCount = useMemo(() => count * 2, [count]);
  const countText = useMemo(() => `Contador: ${count}`, [count]);
  
  // ❌ Useless memoization (el objeto se recrea en cada render)
  const someObject = useMemo(() => ({ random: Math.random() }), [count]);
  
  return <div>{countText} (Doble: {doubledCount})</div>;
}
💡 Pro tip
La memoización tiene su propio costo. Para operaciones muy simples, el costo de memoizar puede superar el beneficio. Usa useMemo cuando el cálculo es realmente costoso o cuando necesitas estabilizar referencias.

Estabilización de referencias con useCallback

useCallback es complementario a useMemo, pero especializado en funciones. Mientras que useMemo memoiza el resultado de un cálculo, useCallback memoiza la función en sí misma.

El problema de las referencias inestables

En JavaScript, cuando declaras una función dentro de un componente, se crea una nueva instancia de esa función en cada render. Para componentes memoizados con React.memo, esto puede provocar re-renders innecesarios.

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // ❌ Nueva función en cada render
  const handleClick = () => {
    console.log('Button clicked');
  };
  
  // Este componente se re-renderizará en cada incremento de count
  // aunque handleClick haga exactamente lo mismo
  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        Incrementar ({count})
      </button>
      <MemoizedChild onButtonClick={handleClick} />
    </>
  );
}

Implementación efectiva de useCallback

useCallback resuelve este problema memoizando la definición de la función:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // ✅ Función memoizada que solo cambia cuando sus dependencias cambian
  const handleClick = useCallback(() => {
    console.log('Button clicked');
  }, []); // Sin dependencias, la función nunca cambia
  
  // MemoizedChild solo se renderizará una vez, no en cada incremento
  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        Incrementar ({count})
      </button>
      <MemoizedChild onButtonClick={handleClick} />
    </>
  );
}

Si la función necesita acceder a valores del estado o props, estos deben incluirse en el array de dependencias:

function UserActions({ userId, updateUser }) {
  // La función se recrea solo cuando userId o updateUser cambian
  const handleUpdate = useCallback((data) => {
    console.log(`Updating user ${userId}`);
    updateUser(userId, data);
  }, [userId, updateUser]);
  
  return <UserForm onSubmit={handleUpdate} />;
}

Integrando useCallback en arquitecturas reales

En aplicaciones complejas, las funciones suelen pasar a través de múltiples niveles de componentes. En estos casos, useCallback es esencial para prevenir cascadas de re-renders.

Patrón: Funciones de manipulación de estado memoizadas

function TodoList() {
  const [todos, setTodos] = useState([]);
  
  // Funciones memoizadas para manipulación de estado
  const addTodo = useCallback((text) => {
    setTodos(prevTodos => [...prevTodos, { id: Date.now(), text, completed: false }]);
  }, []);
  
  const toggleTodo = useCallback((id) => {
    setTodos(prevTodos => prevTodos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []);
  
  const deleteTodo = useCallback((id) => {
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  }, []);
  
  // Estas funciones pueden pasarse a componentes memoizados sin causar re-renders
  return (
    <>
      <AddTodoForm onAdd={addTodo} />
      <MemoizedTodoItems 
        todos={todos} 
        onToggle={toggleTodo} 
        onDelete={deleteTodo} 
      />
    </>
  );
}
🔍 Detalle técnico
Nota el uso de la forma funcional de setTodos (con prevTodos => ...). Esta técnica permite que las funciones no tengan dependencia en el estado actual, reduciendo la necesidad de recrear funciones cuando el estado cambia.

Patrón: Funciones de evento con información contextual

Las funciones de manejo de eventos a menudo necesitan información del elemento que desencadenó el evento. En lugar de crear nuevas funciones para cada elemento, podemos usar una función memoizada combinada con parámetros:

function ProductGrid({ products }) {
  // ✅ Una sola función memoizada en lugar de una por producto
  const handleProductClick = useCallback((productId) => {
    trackProductView(productId);
    navigateToProduct(productId);
  }, []);
  
  return (
    <div className="grid">
      {products.map(product => (
        <MemoizedProductCard
          key={product.id}
          product={product}
          // Pasamos un callback que invoca la función memoizada con el ID
          onClick={() => handleProductClick(product.id)}
        />
      ))}
    </div>
  );
}
⚠️ Advertencia
El ejemplo anterior crea una nueva función () => handleProductClick(product.id) para cada producto, potencialmente negando los beneficios de la memoización. Para aplicaciones con requisitos de rendimiento extremos, considera utilizar un patrón donde pasas tanto la función como el ID por separado.

Virtualización para listas de alto rendimiento

Renderizar grandes listas de elementos es uno de los desafíos más comunes en aplicaciones web modernas. A medida que la cantidad de elementos aumenta, el rendimiento puede degradarse significativamente.

El problema de las listas largas

Cuando renderizamos una lista con muchos elementos, enfrentamos dos problemas principales:

  1. Costo de renderizado inicial: Crear todos los nodos DOM para una lista larga puede bloquear el hilo principal y hacer que la aplicación se sienta lenta.
  2. Sobrecarga del DOM: Mantener muchos elementos en el DOM, aunque no sean visibles, consume memoria y afecta el rendimiento general del navegador.

Por ejemplo, una lista de 10,000 elementos (aunque sea simple) puede causar problemas de rendimiento evidentes:

// ❌ Renderiza 10,000 elementos simultáneamente
function LargeList({ items }) {
  return (
    <div className="list-container">
      {items.map(item => (
        <div key={item.id} className="list-item">
          <img src={item.image} alt={item.name} />
          <h3>{item.name}</h3>
          <p>{item.description}</p>
        </div>
      ))}
    </div>
  );
}

React-window y react-virtualized

La virtualización resuelve este problema renderizando únicamente los elementos que son visibles (o pronto lo serán) en la ventana del navegador. Las bibliotecas más populares para implementar virtualización en React son:

  • react-window: Más ligera y enfocada en la virtualización básica
  • react-virtualized: Más completa, con características adicionales

Implementación básica con react-window

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  // Define una función de renderizado para cada elemento
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <img src={items[index].image} alt={items[index].name} />
      <h3>{items[index].name}</h3>
      <p>{items[index].description}</p>
    </div>
  );
  
  return (
    <FixedSizeList
      height={500} // Altura del contenedor visible
      width="100%" // Ancho del contenedor
      itemCount={items.length} // Número total de elementos
      itemSize={120} // Altura de cada elemento en píxeles
    >
      {Row}
    </FixedSizeList>
  );
}

Este enfoque solo renderiza los elementos que son visibles en la ventana de 500px, independientemente de cuántos elementos tenga la lista.

Implementación avanzada con elementos de altura variable

En muchas aplicaciones reales, los elementos de una lista pueden tener alturas diferentes (por ejemplo, comentarios o publicaciones con contenido variable). Para estos casos, react-window proporciona VariableSizeList:

import { VariableSizeList } from 'react-window';

function VirtualizedVariableList({ items }) {
  // Referencia al componente de lista para métodos imperativos
  const listRef = useRef();
  
  // Función que determina la altura de cada elemento
  const getItemSize = index => {
    // Esto podría basarse en el contenido del elemento
    // Por ejemplo, texto más largo = elemento más alto
    const baseHeight = 80;
    const contentLength = items[index].description.length;
    return baseHeight + Math.floor(contentLength / 100) * 20;
  };
  
  // Función de renderizado para cada elemento
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <h3>{items[index].name}</h3>
      <p>{items[index].description}</p>
    </div>
  );
  
  return (
    <>
      <button onClick={() => listRef.current.scrollToItem(0)}>
        Volver al inicio
      </button>
      <VariableSizeList
        ref={listRef}
        height={600}
        width="100%"
        itemCount={items.length}
        itemSize={getItemSize}
      >
        {Row}
      </VariableSizeList>
    </>
  );
}
🔍 Detalle técnico
En escenarios avanzados donde no es posible predecir la altura de los elementos (como con imágenes que cargan dinámicamente), puedes implementar técnicas de medición dinámica. Existen bibliotecas como react-virtualized-auto-sizer que pueden ayudar con este caso de uso.

Optimizaciones adicionales para listas virtualizadas:

  1. Windowing con buffer: Renderiza algunos elementos adicionales por encima y por debajo de la ventana visible para prevenir flashes durante el scroll rápido.
  2. Memoización de elementos: Combina virtualización con memoización para evitar recrear elementos que no han cambiado.
  3. Paginación virtual: Para datos que se cargan desde una API, implementa paginación lazy que solicite datos según sea necesario.
const MemoizedRow = React.memo(({ data, index, style }) => {
  const item = data[index];
  return (
    <div style={style} className="list-item">
      <h3>{item.name}</h3>
      <p>{item.description}</p>
    </div>
  );
});

function OptimizedVirtualList({ items }) {
  // Memoiza los datos para prevenir recreación de itemData en cada render
  const itemData = useMemo(() => items, [items]);
  
  return (
    <FixedSizeList
      height={500}
      width="100%"
      itemCount={items.length}
      itemSize={120}
      itemData={itemData} // Pasar datos memoizados
      overscanCount={5} // Renderizar 5 elementos adicionales arriba/abajo
    >
      {MemoizedRow}
    </FixedSizeList>
  );
}
💡 Pro tip
La virtualización no solo mejora el rendimiento—también puede reducir significativamente el uso de memoria del navegador, lo que es particularmente importante en dispositivos móviles o de gama baja.

Técnicas de Code Splitting

A medida que las aplicaciones React crecen, también lo hace el tamaño del bundle JavaScript. Code splitting permite dividir el código en paquetes más pequeños que se cargan bajo demanda, mejorando significativamente los tiempos de carga inicial.

React.lazy y Suspense para componentes

React proporciona una API incorporada para carga diferida de componentes a través de React.lazy y Suspense. Esta combinación permite importar componentes dinámicamente cuando son necesarios, en lugar de incluirlos en el bundle principal.

import React, { Suspense, lazy } from 'react';

// En lugar de importarlo directamente
// import ExpensiveComponent from './ExpensiveComponent';

// Lo importamos dinámicamente cuando sea necesario
const ExpensiveComponent = lazy(() => import('./ExpensiveComponent'));

function MyApp() {
  return (
    <div>
      <h1>Mi Aplicación</h1>
      
      {/* El componente Solo se cargará cuando sea renderizado */}
      <Suspense fallback={<div>Cargando...</div>}>
        <ExpensiveComponent />
      </Suspense>
    </div>
  );
}

En este ejemplo, ExpensiveComponent solo se cargará cuando MyApp lo renderice, reduciendo el tiempo de carga inicial de la aplicación.

Estrategias de división: por ruta, por característica, por condición

Existen varias estrategias para implementar code splitting, cada una apropiada para diferentes escenarios:

1. Splitting por ruta

La estrategia más común es cargar componentes bajo demanda según la ruta actual. Esto funciona especialmente bien con bibliotecas de enrutamiento como react-router:

import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Cada página se carga solo cuando se navega a su ruta
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="loading-screen">Cargando...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<UserProfile />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

2. Splitting por característica

Otra estrategia es cargar componentes basados en la interacción del usuario o características específicas que no todos los usuarios utilizarán:

function ProductPage({ productId }) {
  const [showReviews, setShowReviews] = useState(false);
  
  // El componente de reseñas solo se cargará si el usuario
  // decide mostrar las reseñas
  const ProductReviews = lazy(() => import('./ProductReviews'));
  
  return (
    <div className="product-page">
      <ProductDetails id={productId} />
      
      <button onClick={() => setShowReviews(!showReviews)}>
        {showReviews ? 'Ocultar reseñas' : 'Mostrar reseñas'}
      </button>
      
      {showReviews && (
        <Suspense fallback={<p>Cargando reseñas...</p>}>
          <ProductReviews productId={productId} />
        </Suspense>
      )}
    </div>
  );
}

3. Splitting por condición

También podemos cargar diferentes implementaciones basadas en condiciones como características del dispositivo, preferencias del usuario o flags de características:

// Componente que carga diferentes versiones según la plataforma
function ImageEditor({ image }) {
  // Determinamos qué versión cargar basado en la capacidad del dispositivo
  const EditorComponent = lazy(() => {
    if (window.matchMedia('(max-width: 768px)').matches) {
      return import('./MobileImageEditor');
    } else {
      return import('./DesktopImageEditor');
    }
  });
  
  return (
    <div className="image-editor-container">
      <Suspense fallback={<div>Preparando editor...</div>}>
        <EditorComponent image={image} />
      </Suspense>
    </div>
  );
}

Configuraciones avanzadas con bundlers

Para aprovechar al máximo el code splitting, es importante entender cómo configurar correctamente tu bundler (como Webpack, Rollup o Vite).

Nombrar chunks dinámicos

Por defecto, los chunks cargados dinámicamente reciben nombres generados automáticamente. Nombrarlos explícitamente ayuda con el debugging y permite estrategias de carga más avanzadas:

// Con Webpack: comentario mágico para nombrar el chunk
const Dashboard = lazy(() => import(
  /* webpackChunkName: "dashboard" */ 
  './pages/Dashboard'
));

// Con Vite: usa la función de import con un comentario similar
const Settings = lazy(() => import(
  /* @vite-ignore */
  './pages/Settings'
));