Paging 3 con Room: manteniendo fluida una lista de transacciones creciente en Android en 2026
Cómo paginar una lista de transacciones respaldada por Room con Jetpack Paging 3 para que años de datos se mantengan fluidos en Compose — sin RemoteMediator, sin red, solo una base de datos local bien hecha.
La lista de transacciones de una app de presupuesto se ve bien en una demo con treinta filas. Deja de verse bien dos años después, cuando un usuario real tiene cuatro mil y desplazarse por la pantalla de historial empieza a entrecortarse. La solución no es un LIMIT más grande, y tampoco es “simplemente añade un spinner de carga al final” — es Jetpack Paging 3, conectado directamente a Room sin capa de red alguna. Así es como lo construí para Granyn, y los dos errores que me costaron un día cada uno antes de hacerlo bien.
Por qué LazyColumn por sí solo no es el problema
La versión ingenua funciona como toda lista en todo tutorial: una consulta de Room, SELECT * FROM transactions ORDER BY date DESC, mapeada a un Flow<List<Transaction>>, recolectada en un LazyColumn. El layout perezoso de Compose es genuinamente perezoso respecto a composición y renderizado — no recompone filas fuera de pantalla. Así que el entrecortamiento no es un problema de Compose. Está más arriba: Room tiene que materializar cada una de esas cuatro mil filas en objetos, en cada emisión, cada vez que una sola transacción cambia en cualquier parte de la tabla. Añade un gasto y toda la lista se reconstruye. Ese es el costo real, y ninguna optimización de renderizado de listas lo toca.
Lo que Paging 3 realmente aporta aquí
Paging 3 suele explicarse a través de su caso de uso con red — un RemoteMediator que obtiene páginas de una API y las cachea localmente. No es de eso de lo que se trata esto. Para una app local-first, todo el pipeline vive en Room: sin mediador, sin estado de red, sin lógica de reintento. Room genera un PagingSource por ti a partir de una @Query que devuelve PagingSource<Int, Transaction> en lugar de Flow<List<Transaction>>:
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY date DESC")
fun pagingSource(): PagingSource<Int, Transaction>
}
Ese único cambio de tipo es la mayor parte de la integración. Room ya sabe cómo invalidar un PagingSource cuando la tabla subyacente cambia — el mismo mecanismo de observación que impulsa las consultas Flow — así que las páginas se refrescan automáticamente en cada escritura sin que tengas que conectar nada extra.
Construyendo el stream
El PagingSource se envuelve en un Pager, que posee la configuración de paginación — cuántas filas por página, cuánto anticipar la precarga:
class TransactionRepository(private val dao: TransactionDao) {
fun pagedTransactions(): Flow<PagingData<Transaction>> =
Pager(
config = PagingConfig(
pageSize = 40,
prefetchDistance = 20,
enablePlaceholders = true,
),
pagingSourceFactory = { dao.pagingSource() },
).flow
}
Vale la pena señalar enablePlaceholders = true: como Room puede ejecutar SELECT COUNT(*) de forma barata en una tabla local, Paging 3 puede dimensionar una barra de desplazamiento con precisión y mostrar filas placeholder para contenido que aún no ha cargado — un desplazamiento rápido hasta el final de un historial de cuatro mil filas no necesita cargar cuatro mil filas primero. Es un lujo que la paginación respaldada por red no tiene, y es la razón principal por la que la paginación local se siente instantánea de una forma en que los feeds de scroll infinito normalmente no lo hacen.
El lado de Compose
collectAsLazyPagingItems() convierte el Flow<PagingData<Transaction>> en algo que un LazyColumn puede indexar directamente:
@Composable
fun TransactionList(viewModel: TransactionViewModel) {
val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()
LazyColumn {
items(
count = transactions.itemCount,
key = transactions.itemKey { it.id },
) { index ->
val transaction = transactions[index]
if (transaction != null) {
TransactionRow(transaction)
} else {
TransactionRowPlaceholder()
}
}
}
}
itemKey importa más aquí que en una lista normal: sin una clave estable, Compose identifica las filas por posición en la lista, e insertar una transacción nueva arriba del todo — algo que ocurre constantemente en una app de presupuesto, ya que las entradas nuevas se ordenan arriba por fecha — desplaza la identidad de cada fila y desencadena una ola de recomposición innecesaria.
El error que me costó un día: el filtrado
La pantalla de historial también admite filtrar por categoría. Mi primer instinto fue mantener un único Pager vivo y filtrar el stream de PagingData río abajo con .filter { } en cada emisión de PagingData. Eso está mal, y es una trampa lo bastante común como para nombrarla directamente: filtrar después del hecho sigue paginando sobre la consulta sin filtrar, así que una categoría con cinco coincidencias de cuatro mil filas puede paginar por la mayor parte de la tabla antes de mostrar cinco resultados, y el conteo de placeholders es incorrecto porque refleja el número de filas de toda la tabla, no el de la tabla filtrada.
La solución es hacer que el filtro forme parte de la consulta, no un paso de posprocesamiento, y reconstruir el Pager cuando el filtro cambia:
@Query("""
SELECT * FROM transactions
WHERE (:categoryId IS NULL OR category_id = :categoryId)
ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
.flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
.cachedIn(viewModelScope)
flatMapLatest cancela el Pager anterior y arranca uno nuevo ajustado al nuevo filtro, de modo que los placeholders, los conteos y la precarga son todos correctos para la consulta que el usuario está viendo realmente. cachedIn(viewModelScope) es lo que sobrevive a los cambios de configuración — sin él, rotar la pantalla reinicia la paginación desde cero.
La conclusión
Paging 3 sobre Room no necesita nada de la maquinaria de red con la que normalmente se enseña — sin RemoteMediator, sin UI de reintento de LoadState, sin caché offline que reconciliar. Lo que queda es genuinamente simple: un PagingSource a partir de una consulta, un Pager con configuración de precarga, y collectAsLazyPagingItems() en el lado de Compose. Lo único que vale la pena hacer bien desde el principio es mantener los filtros dentro de la consulta en lugar de río abajo de ella — todo lo demás se deriva de eso. Una lista de transacciones que se mantiene fluida con cuatro mil filas no es una característica de rendimiento que añades después; es la diferencia entre una app que funciona en la demo y una que sigue funcionando dos años después de uso real.
// Lecturas relacionadas
Más del diario
Room TypeConverters en 2026: cómo guardar enums, fechas y listas sin corromper tu esquema
Una guía práctica sobre los TypeConverters de Room en Android — enums, Instant/LocalDate y listas — además de los errores que convierten un converter en un bug silencioso de corrupción de datos.
R8 y ProGuard para Kotlin, Room y Compose: el fallo que solo ocurre en release
Por qué una app Android con Room + Compose puede funcionar perfectamente en debug y fallar en producción, y las reglas keep de R8/ProGuard que lo evitan a tiempo.
Índices de base de datos Room en 2026: encontrar la consulta que realmente va lenta y arreglarla
Una guía práctica para indexar una base de datos Room/SQLite en Android — leer EXPLAIN QUERY PLAN, añadir @Index sin adivinar, y los errores que anulan un índice en silencio.