DEV Community

Cover image for Infinite Scroll con Rxjs
Leonardo Alipazaga
Leonardo Alipazaga

Posted on

Infinite Scroll con Rxjs

Hace poco empecé a estudiar la famosa librería rxjs y me pareció realmente sorprendente su gran potencial para resolver features que a menudo como developer's nos enfrentamos. El scroll infinito es uno de esos features. En este post les explicaré como hacer un scroll infinito paso a paso usando rxjs.

Entonces, ¿Que necesitamos?

Particularmente me agrada jsfiddle por su ligereza, sin embargo dejo a su libre elección el editor de texto con el que más se sientan a gusto (VSCode, SublimeText, CodePen, repl.it, etc.) . Psdta: tienen que tener instalado la librería rxjs.

Agregando algo de HTML y CSS

No voy a dedicarle mucho tiempo al css ni html por no ser el punto central del post, ustedes pueden agregarle los estilos y dejarlo bien chido. En este caso solo agregaré un contenedor en el HTML

Almacenar nodo contenedor e importar Rxjs

Lo primero que haremos es importar la librería Rxjs y almacenar el nodo contenedor. Nada díficil verdad.

const Observable = Rx.Observable;
const container = document.getElementById('container');
Enter fullscreen mode Exit fullscreen mode

Ahora si viene lo bueno, el paso a paso.

Lo que nos interesa a nosotros es el deslizamiento que hace el usuario al scrollear así que necesitamos escuchar ese evento, scroll. Con rxjs es bastante simple.

Observable
  .fromEvent(container, 'scroll')
Enter fullscreen mode Exit fullscreen mode

Genial, ahora es momento de "pensar" y decidir que valores necesitamos para consumir el servicio cada vez que el usuario scrollee. Para ello existen dos criterios.

  1. El servicio debe ser consumido solo si el usuario se ha deslizado hacia abajo. Es decir, la posición actual debe ser mayor a la posición anterior. Genial
  2. Ahora, no podemos consumir el servicio hasta que llegue a un punto determinado, un límite.

Para lograr estos criterios necesitamos tres propiedades que se encuentran en el objeto que el evento scroll nos retorna. clientHeight, scrollHeight, scrollTop.
Así que brevemente describiré que valor representa cada una de estas propiedades.

  • clientHeight: Altura del contenedor sin incluir la parte scrolleable. Altura inicial (fijo).
  • scrollTop: Posición de la barra en el eje Y.
  • scrollHeight: Altura total del contenedor incluyendo la parte scrolleable. Dinámica a medida que aumenta elementos hijos.
Observable
  .fromEvent(container, 'scroll')
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
Enter fullscreen mode Exit fullscreen mode

Perfecto, ¿Para que nos sirve cada propiedad?

Matemáticas

La diferencia entre la posición actual y anterior nos brindará información si el usuario se deslizo hacía abajo.

function isScrollDown(beforePosition, currentPosition) {
  beforePosition.scrollTop < currentPosition.scrollTop;
}
Enter fullscreen mode Exit fullscreen mode

Mientras que la razón entre la posición de la barra y la diferencia de alturas (scrollHeight y clienteHeight) nos dirá si ha pasado el límite (nosotros definiremos el límite).

function setThreshold(threshold) {
  return function hasPassedThreshold(currentPosition) {
    return currentPosition.scrollTop * 100 /
      (currentPosition.scrollHeight -
       currentPosition.clientHeight) > threshold;
  }
}
Enter fullscreen mode Exit fullscreen mode

Con las dos criterios que definimos podemos empezar a filtrar las posiciones que nos interesa.

Observable
  .fromEvent(container, 'scroll')
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
  .pairwise() // emite el valor anterior y el actual en un array. 
  .filter(positions => isScrollDown(positions[0], positions[1]) && 
  setThreshold(80)(positions[1]))
Enter fullscreen mode Exit fullscreen mode

Loader

Agregue un loader simple al final del container.

const toogleLoading = (function (container) {
  const loading = document.createElement('p');
  loading.classList.add('bold', 'text-center');
  loading.innerText = 'Loading...';
  return function toogleLoading(showLoader) {
  showLoader ? container.appendChild(loading) : loading.remove();
}
})(container);
Enter fullscreen mode Exit fullscreen mode

Ahora, mostramos el loader cada vez la posición del scrollbar retorne true según los criterios establecidos. Para ello usamos el operador do.

Observable
  .fromEvent(container, 'scroll')
  .takeWhile(res => nextUrl)
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
  .pairwise()
  .filter(positions => isScrollDown(positions[0], positions[1]) && setThreshold(80)(positions[1]))
  .do(() => toogleLoading(true)) // show loader
Enter fullscreen mode Exit fullscreen mode

Consumiendo el servicio

El consumo del servicio debe ir acompañado de la visualización del loader. A lo que voy es que un servicio puede ser rápido o bastante lento. Por el lado front debemos mostrar al usuario que efectivamente se esta cargando datos, y eso lo hacemos a través de un loader. Sin embargo, cuando la respuesta del servicio es rápida el loader se muestra solo un instante y no se ve nada bien. Para mayor información encontré este gran post que trata sobre como agregar un loader con un tiempo mínimo.

Observable
  .fromEvent(container, 'scroll')
  .takeWhile(res => nextUrl)
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
  .pairwise()
  .filter(positions => isScrollDown(positions[0], positions[1]) && setThreshold(80)(positions[1]))
  .do(() => toogleLoading(true)) // show loader
  .switchMap(() => Observable.combineLatest(Observable.timer(1000), Observable.ajax({
    url: nextUrl,
    method: 'GET'
  })))
  .map(combine => combine[1])
  .catch(console.error)
Enter fullscreen mode Exit fullscreen mode

Más lento cerebrito

  • switchMap, nos permite subscribirnos a los nuevos observables que son emitidos desde el inner observable (en este caso el combineLatest). Cuando llega un nuevo observable el anterior es cancelado.
  • combineLatest, emite el ultimo valor de cada uno de los observables. Los valores emitidos por cada observable son almacenados en un arreglo.
  • timer, emite numeros en secuencia según el tiempo indicado
  • ajax, crea un ajax request siguiendo el concepto de los observables
  • map, convierte cada valor emitido según el project function que se pasa como parametro
  • catch, maneja los posibles errores que puedan darse

Manejando el response

Usamos el operador do en caso que quisieramos ejecutar un side effect (cambiar el valor de alguna variable o ejecutar alguna función). El response del servicio nos devuelve un objeto extenso el cual contiene la siguiente url a consultar además de un arreglo con todos los pokemones. En este caso utilizamos el operador do para actualizar nuestro endpoint. Por otro lado, utilizamos el operador map para solo obtener la propiedad results del objeto response.

Observable
  .fromEvent(container, 'scroll')
  .takeWhile(res => nextUrl)
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
  .pairwise()
  .filter(positions => isScrollDown(positions[0], positions[1]) && setThreshold(80)(positions[1]))
  .do(() => toogleLoading(true)) // show loader
  .switchMap(() => Observable.combineLatest(Observable.timer(1000), Observable.ajax({
    url: nextUrl,
    method: 'GET'
  })))
  .map(combine => combine[1])
  .catch(console.error)
  .do(res => (nextUrl = res.response.next))
  .map(res => res.response.results)
Enter fullscreen mode Exit fullscreen mode

Suscribirnos

Finalmente tenemos que suscribirnos a nuestro observable scroll. Y en nuestro caso de success debemos de dejar de mostrar el loading así como agregar todos los pokemones en nuestro container.

Observable
  .fromEvent(container, 'scroll')
  .takeWhile(res => nextUrl)
  .map(e => ({
    scrollTop: e.target.scrollTop,
    scrollHeight: e.target.scrollHeight,
    clientHeight: e.target.clientHeight
  }))
  .pairwise()
  .filter(positions => isScrollDown(positions[0], positions[1]) && setThreshold(80)(positions[1]))
  .do(() => toogleLoading(true)) // show loader
  .switchMap(() => Observable.combineLatest(Observable.timer(1000), Observable.ajax({
    url: nextUrl,
    method: 'GET'
  })))
  .map(combine => combine[1])
  .catch(console.error)
  .do(res => (nextUrl = res.response.next))
  .map(res => res.response.results)
  .subscribe(pokemons => {
    toogleLoading(false);
    container.innerHTML += pokemons.map(pokemon =>
                                                pokemon.name).join('<br>')
  });

Enter fullscreen mode Exit fullscreen mode

Código completo

Cualquier duda, pregunta o feedback pueden dejar sus comentarios. No se olviden de aprender y compartir ❤️. Hasta la próxima.

Top comments (0)