Infinite Scroll con Rxjs
Tags: rxjs, javascript, angular
Published: 2019-09-18 05:29:29

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
{% jsfiddle https://jsfiddle.net/jestea/v582hg6e/3/ html,css %}

### 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');
```
### 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')
```
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
  }))
```

*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;
}
```
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;
  }
}
```
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]))
```
### 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);
```
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
```
### 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](https://codeburst.io/rxjs-show-spinner-for-a-minimum-amount-of-time-807ac6b23227) 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)
```
#### 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)
```
### 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>')
  });

```

### Código completo
Cualquier duda, pregunta o feedback pueden dejar sus comentarios. No se olviden de **aprender** y **compartir** :heart:. Hasta la próxima.
{% jsfiddle https://jsfiddle.net/jestea/v582hg6e/4/ %}