Performance y manejo eficiente de datos en aplicaciones

Performance y manejo eficiente de datos en aplicaciones

Una aplicación puede funcionar correctamente y aun así sentirse lenta. En aplicaciones móviles, especialmente con React Native, los problemas de rendimiento suelen aparecer cuando llegan demasiados datos, se hacen demasiadas peticiones o se producen renders innecesarios.

La idea central es simple: no debemos cargar, procesar ni renderizar más información de la necesidad.

Concurrencia de requests

Cuando necesitamos obtener varios recursos independientes, hacer las peticiones una detrás de otra aumenta innecesariamente el tiempo total.

El patrón secuencial:

const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();

Aquí cada request espera a la anterior. Con Promise.all en su lugar:

const [users, posts, comments] = await Promise.all([
  fetchUsers(),
  fetchPosts(),
  fetchComments(),
]);

Las operaciones se ejecutan concurrentemente. Si cada request tarda aproximadamente 500 ms, tres requests secuenciales pueden acercarse a 1500 ms, mientras que las concurrentes pueden acercarse al tiempo de la más lenta.

Hay que distinguir entre operaciones independientes y dependientes. Si una petición necesita el resultado de otra, no debemos forzar concurrencia artificialmente.

Paginación

Traer miles de registros de una sola vez consume memoria, ancho de banda y tiempo de procesamiento.

En lugar de:

GET /products

podemos utilizar:

GET /products?page=1&limit=20

Una respuesta típica puede ser:

{
  "data": [],
  "page": 1,
  "limit": 20,
  "hasNextPage": true
}

En React Native, la paginación suele combinarse con FlatList:

const loadNextPage = async () => {
  if (loading || !hasNextPage) return;

  setLoading(true);

  const response = await fetchProducts(page + 1);

  setProducts((prev) => [...prev, ...response.data]);
  setPage((prev) => prev + 1);
  setHasNextPage(response.hasNextPage);

  setLoading(false);
};

La regla importante es no solicitar páginas simultáneas por accidente. Debemos controlar estados como loading, page y hasNextPage.

Batching

Batching significa agrupar varias operaciones pequeñas en una sola operación.

En lugar de:

await createItem(item1);
await createItem(item2);
await createItem(item3);

podemos enviar:

await createItems([item1, item2, item3]);

Esto reduce el overhead de red y procesamiento. El batching es especialmente útil para la sincronización de datos, las inserciones masivas, el envío de eventos, las operaciones contra APIs y la actualización de múltiples registros.

No significa que siempre debamos enviar cantidades enormes. Un batch debe tener un tamaño razonable para evitar requests demasiado grandes.

Caché

Si un dato cambia poco, no tiene sentido solicitarlo continuamente. Un enfoque simple:

const cache = new Map();

async function getUsers() {
  if (cache.has("users")) {
    return cache.get("users");
  }

  const users = await fetchUsers();
  cache.set("users", users);

  return users;
}

La caché evita trabajo repetido, pero introduce una pregunta fundamental: ¿cuándo deja de ser válido el dato?

Por eso una caché real suele necesitar TTL o tiempo de expiración, invalidación, actualización manual, estrategia de almacenamiento y manejo de errores. Una caché incorrecta puede ser peor que no tener caché si muestra información obsoleta.

Virtualización

Renderizar 5000 elementos aunque solamente 10 sean visibles es desperdiciar recursos. La virtualización mantiene activas principalmente las filas necesarias para el área visible y sus alrededores.

En React Native, FlatList utiliza virtualización:

<FlatList
  data={products}
  keyExtractor={(item) => String(item.id)}
  renderItem={({ item }) => <ProductCard product={item} />}
/>

Esto es preferible a mapear manualmente sobre un View cuando tenemos listas grandes:

<View>
  {products.map((product) => (
    <ProductCard key={product.id} product={product} />
  ))}
</View>

FlatList

FlatList es una de las herramientas fundamentales para listas grandes en React Native. Podemos optimizarla con propiedades como:

<FlatList
  data={products}
  keyExtractor={(item) => String(item.id)}
  renderItem={renderProduct}
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={5}
  removeClippedSubviews
/>

Estas propiedades controlan cuánto contenido se renderiza inicialmente y cuánto se mantiene alrededor del área visible. No existe una configuración universalmente perfecta: los valores deben ajustarse según el tamaño y la complejidad de cada fila.

Optimización de renders

Uno de los errores comunes es hacer que componentes que no cambiaron vuelvan a renderizarse. Podemos utilizar React.memo:

const ProductCard = React.memo(function ProductCard({ product }) {
  return (
    <View>
      <Text>{product.name}</Text>
      <Text>{product.price}</Text>
    </View>
  );
});

También debemos cuidar las funciones y objetos creados durante cada render. Por ejemplo, esta función de render:

const renderProduct = ({ item }) => <ProductCard product={item} />;

puede mantenerse estable con useCallback cuando sea necesario:

const renderProduct = useCallback(
  ({ item }) => <ProductCard product={item} />,
  []
);

Sin embargo, memorizar todo indiscriminadamente no garantiza mejor rendimiento. La optimización debe hacerse cuando existe un costo real de renderizado.

Evitar actualizaciones innecesarias

Este patrón puede provocar trabajo innecesario:

setProducts([...products, newProduct]);

No está mal por sí mismo, pero si la actualización ocurre muchas veces puede generar múltiples renders. Cuando necesitamos realizar varias modificaciones relacionadas, conviene agruparlas o diseñar correctamente el flujo de estado.

También debemos evitar pasar objetos nuevos a componentes memorizados sin necesidad. Este JSX crea un nuevo objeto en cada render:

<ProductCard style={{ margin: 10 }} />

Cuando sea apropiado, definimos los estilos fuera del render:

const styles = StyleSheet.create({
  card: {
    margin: 10,
  },
});

y usamos:

<ProductCard style={styles.card} />

Una arquitectura eficiente

Las técnicas anteriores funcionan mejor juntas. Un flujo típico para una lista grande sería:

  1. Solicitar solamente la primera página.
  2. Guardar los datos en memoria o caché.
  3. Renderizar con FlatList.
  4. Cargar la siguiente página cuando sea necesario.
  5. Evitar requests duplicados.
  6. Agrupar operaciones cuando sea conveniente.
  7. Memorizar componentes costosos.
  8. Invalidar la caché cuando los datos cambien.
  9. Medir antes y después de optimizar.

Ejemplo conceptual:

async function loadProducts() {
  if (loading || !hasNextPage) return;

  setLoading(true);

  try {
    const result = await fetchProducts({
      page: page + 1,
      limit: 20,
    });

    setProducts((prev) => [...prev, ...result.data]);
    setPage((prev) => prev + 1);
    setHasNextPage(result.hasNextPage);
  } finally {
    setLoading(false);
  }
}

Y la UI:

<FlatList
  data={products}
  renderItem={renderProduct}
  keyExtractor={(item) => String(item.id)}
  onEndReached={loadProducts}
  onEndReachedThreshold={0.5}
/>

Conclusión

El rendimiento no consiste simplemente en hacer que el código sea más rápido. Consiste en reducir trabajo innecesario.

Las estrategias fundamentales son: concurrencia para ejecutar operaciones independientes al mismo tiempo, paginación para traer solamente los datos necesarios, batching para agrupar operaciones pequeñas, caché para evitar solicitudes repetidas, virtualización para renderizar principalmente lo visible, FlatList para manejar listas grandes eficientemente y optimización de renders para evitar renders innecesarios.

La mejor optimización suele surgir de una regla sencilla: primero medir dónde está el costo y después reducir exactamente ese costo, en lugar de aplicar optimizaciones indiscriminadamente.

© Arturo Juárez Monroy