Carga asíncrona de datos en ReactJS a través de React Router

Actualización Noviembre 2015: esta técnica está diseñada para ser utilizada hasta la versión 0.13.x de React Router. Si estás usando la versión 1.0, échale un vistazo a los cambios para ver cómo adaptarla.

Hace poco explicaba cómo utilizar un mixin para cargar datos de forma asíncrona con ReactJS y veíamos que era una solución que nos podía ahorrar bastante código. En este post vamos a ver otra alternativa para realizar la carga de datos externos en nuestros componentes de ReactJS, esta vez aprovechando react-router.

Si no lo conoces, puedes ver este mini tutorial de React Router para hacerte una idea de cómo funciona. Simplificando mucho, React Router nos permite definir una estructura de rutas y navegar entre ellas; siendo su característica más interesante que las rutas pueden estar anidadas.

Esto implica que para generar la página correspondiente a una ruta pueden verse involucrados varios componentes de ReactJS organizados jerárquicamente, y cada uno de ellos genera una parte de la página. Comparándolo con ASP.NET MVC u otros sistemas similares, podrías verlo como una mezcla entre el sistema de routing y el de master pages o layouts. Tienes un ejemplo completo en tutorial de React Router que mencionaba antes.

Apoyándonos en React Router

Para poner en funcionamiento el mecanismo de routing usamos el siguiente código:

Router.run(routes, function(Root, state) {
  React.render(<Root/>, document.body);
});

Con este código al Router le pasamos un objeto routes con la definición de las rutas y una función que se usará cada vez que haya que realizar la navegación a una nueva ruta. Esta función recibe el componente que debemos renderizar, Root, y un objeto state que usaremos más adelante. Normalmente esta función se limita a montar a montar el componente sobre el nodo html que contiene la aplicación de ReactJS.

El plan es aprovechar esta función para poder realizar la carga de datos, escribiendo un código parecido a éste:

Router.run(routes, function(Root, state) {
  fetchData().then(function(data) {
    React.render(<Root data={data}/>, document.body);
  }):
});

Es una idea sencilla: cuando vamos a navegar a una ruta, antes de mostrarla, obtenemos los datos que necesita y cuando los tengamos disponibles mostramos la ruta inyectando esa información a través de this.props.data.

OJO: Puesto que las rutas pueden estar anidadas, esta información que le pasamos al componente Root deberá ser traspasada al resto de las rutas:

var App = React.createClass({
  render: function() {
    return <div>
	         <TitleBar/>
			 <NavBar/>
			 <div className="content">
			   // Esta parte es importante: hay que propagar la información
		       <RouteHandler data={this.props.data}/>
			 </div>
	       </div>;
  }
});

Parece un buen plan, pero tenemos un pequeño inconveniente: necesitamos saber cómo obtener la información específica de cada ruta. En el código que hemos puesto siempre llamamos a fetchData, pero dependiendo de la ruta a la que navegemos, necesitamos cargar una información y otra. Para ello necesitamos saber cuál es el componente al que se está navegando realmente y, a partir de esa información, obtener la función que nos permite cargar su información.

Implementando una solución

Vayamos por partes.

En realidad, no estamos navegando a un único componente, sino a una lista de componentes, ya que como decíamos antes, las rutas pueden estar anidadas. La información de los componentes a los que estamos navegando la podemos obtener del objeto state que recibe la función de navegación. En state.routes se almacena un array con las rutas a las que estamos navegando, y cada elemento de ese array contiene, entre otras cosas, el handler (el componente de ReactJS) asociado a esa ruta.

Con esto tenemos el (o los componentes) implicado en la navegación. Para asociar una función de carga de datos a cada componente hay muchas opciones, pero una de las más idiomáticas en ReactJS es utilizar la propiedad statics al definir el componente:

var UserList = React.createClass({
  statics: {
    fetchData: function(params, query) {
      return server.getUsers(params.department, query.sortedBy);
    }
  }
  render: function() {
    // Esto queremos que nos los inyecte el router a partir de
    // la información obtenida en fecthData
    var users = this.props.data;
    return <ul>...</ul>;
  }
});

En los componentes definiremos una función estática que llamaremos fetchData y recibirá el objeto params y el objeto query asociado a la ruta actual para poder cargar datos que dependan de la ruta en la que nos encontramos. Esta función deberá devolver una promesa que, al resolverse, contendrá la información que espera recibir nuestro componente en this.props.data.

Teniendo esto, podemos modificar nuestro Router.run:

Router.run(routes, function(Root, state) {
  var routes = state.routes,
      params = state.params,
      query = state.query,
      route = routes[routes.length -  1],
      futureData = route.handler.fecthData
                     ? route.handler.fecthData(params, query)
                     : $.when();

  futureData.then(function(data) {
    React.render(<Root data={data}/>, document.body);
  }):
});

El código no es muy complicado, pero hay un par de cosas que aclarar.

Sólo estamos utlizando el fetchData de la última parte de la ruta. Esto es una clara limitación, porque podríamos tener varios componentes dentro de la jerarquía de rutas que necesitan obtener datos externos.

En la documentación de React Router hay un ejemplo de cómo resolverlo, pero cuando cambiamos entre subrutas de un componente, se vuelven a pedir al servidor todos los datos de todos los componentes involucrados en la ruta, lo que suponen más llamadas al servidor de las necesarias. Para un escenario así ésta no es la mejor solución y es preferible gestionar la carga de forma independiente de la ruta, por ejemplo con el mixin de carga asíncrona de datos que vimos hace un par de posts.

También podría darse el caso de que el componente con la función fetchData no siempre sea el último de la jerarquía de rutas. Si ese es tu caso, la solución es sencilla, en lugar de usar el último puedes buscar el primer fetchData que encuentres en la jerarquía y quedarte con él.

Por otra parte, en caso de que no tengamos un fetchData para cargar datos, utilizamos directamente una promesa resuelta, que si usamos jQuery podemos crear con $.when(), para mantener todo el código homogéneo.

Los problemas de la navegación asíncrona

Independientemente de la limitación anterior, el código presenta un problema difícil de apreciar a simple vista. Al hacer la carga de datos de forma asíncrona antes de navegar a la ruta seleccionada, estamos convirtiendo la navegación también en algo asíncrono, lo que hace que se produzca una situación extraña cuando el usuario intenta navegar a una ruta mientras todavía no se ha completado la navegación anterior.

Imagina que se intenta navegar a la ruta /contacts, pero el servidor tarda en devolver los datos de los contactos. Mientras estamos esperando, el usuario se aburre y pulsa un enlace a /products. Los productos se cargan rápido y mostramos una lista de productos, pero unos segundos más tarde se completa la llamada al servidor para obtener contactos y mandamos al sorprendido usuario a una pantalla con los contactos.

Para evitar esto, cuando se completa la recuperación de la información, es decir, cuando se resuelve la promesa, necesitamos saber si esta ruta sigue siendo a la que queremos llegar o el usuario ha cambiado de opinión. Una forma de hacerlo es controlando cuál es la última promesa que hemos usado para cargar datos, y descartar la información si la promesa que se acaba de resolver no es esa. Suena lioso, pero utilizando closures es bastante fácil:

// Mantenemos la promesa asociada a la última ruta a la que hemos 
// intentado navegar.
var currentFuture;

Router.run(routes, function(Root, state) {
  var routes = state.routes,
      params = state.params,
      query = state.query,
      route = routes[routes.length -  1],
      futureData = route.handler.fecthData
		             ? route.handler.fetchData(params, query)
		             : $.when();

  currentFuture = futureData;

  futureData.then(function(data) {
    if (futureData !== currentFuture) {
		// Descartamos la navegación porque se ha navegado
		// a otra ruta mientras cargábamos ésta
		return;
	}
    React.render(<Root data={data}/>, document.body);
  });
});

En currentFuture mantenemos la última promesa utilizada para cargar datos, y cuando se resuelve una promesa, comprobamos si sigue siendo válida o ya hemos navegado a otro sitio, ignorando su resultado en ese caso.

Resumen

Si nuestra aplicación utiliza React Router, la solución descrita en este post es una buena alternativa para cargar datos externos en nuestros componentes.

Una ventaja de esta solución es que los datos llegan a través de las propiedades del componente en lugar de tener que almacenarlos en el estado. Esto simplifica el renderizado del componente, porque no tenemos que estar distinguiendo en el método render entre el caso de tener que mostrar la información y el caso de estar todavía cargándola. El código resultante es más limpio y sencillo de entender.

La desventaja más evidente es que perdemos flexibilidad a la hora de cargar información en los componentes. Sólo podemos recibir información al realizar la navegación entre rutas y, además, tenemos las limitaciones que veíamos antes cuando hay varios componentes en una ruta que necesitan cargar información. Para esos escenarios, la solución basada en mixins ofrece un mayor control sobre la forma de cargar la información y puede resultar más apropiada.