ezakto code

Los contenidos de este blog están desactualizados.

Pero estoy pensando en actualizarlos y escribir más.

Si te interesa, dejame tu email (no spam, no newsletters). Si hay suficiente interés me pondré manos a la obra y te lo haré saber!


Tutorial React: Implementando flux

27 de junio 2015

Reestructurando la aplicación para usar la arquitectura flux

Para implementar el botón de "eliminar" de cada nota, siguiendo la estructura que venimos armando, sería necesario crear un método delete() en el componente Note, que ejecute un callback onDelete() pasado vía props. Si quisieramos mantener la organización que tenemos hasta ahora, este callback debería ser declarado en App, pasado vía props a Grid y de nuevo pasado vía props a Note. Si bien es factible, se hace un poco molesto de seguir. Es un ejemplo de problemas de escalabilidad de una aplicación. A medida que la aplicación crece, estas situaciones se van sumando y cada patch que apliquemos trae leves o graves complicaciones eventualmente. Una buena forma de evitar esto es planear la totalidad de la aplicación desde un inicio (algo muy difícil de hacer) o utilizar una organización o arquitectura que nos permita crecer sin generar problemas.

Flux

Flux es una arquitectura propuesta por Facebook para organizar aplicaciones que usen React. Aplica la regla estricta del flujo de datos unidireccional a través toda la aplicación, tanto en las vistas como en el modelado de datos. Si bien esto no es nada nuevo para los programadores con más experiencia, Facebook lo presenta de una forma simple, escalable y que funciona muy bien con el frontend de las single-page applications como la nuestra.

La idea básica de flux se basa en cuatro componentes básicos de la estructura de la aplicación:

Vamos a ilustrar brevemente como funciona el flujo de datos en flux.

flux1

A través de las views, el usuario genera actions que envían información al dispatcher, que distribuye el mensaje a todas las stores, donde se realizan todas las operaciones lógicas necesarias y luego notifican del cambio a las views para que se actualicen para reflejar los cambios. Luego, el ciclo se repite.

Es importante notar que cuando la aplicación crece y se añaden diferentes stores, el dispatcher enviará los mensajes a todas ellas indiferentemente. Es trabajo de las stores reaccionar o ignorar los mensajes dependiendo de su contenido.

flux2

Para ser notificadas de los cambios realizados en las stores, las views escucharán un evento tradicional. Cada view escuchará los cambios de las stores que contengan información que les importe.

flux3

Hay que destacar que la única forma de modificar la información contenida en las stores es a través de actions. Y sólo expondrá metodos getters (accesores), y de igual manera, las stores no deben generar actions, sino esperar a que sean generadas por el usuario (o el servidor).

flux4

Cuando deseamos persistir los datos de nuestra aplicación en una base de datos externa (en nuestro caso, en localStorage), la comunicación con el servidor nace y muere en los action creators. El resultado de estas interacciones es enviado por medio de actions.

Entendiendo entonces como trabaja flux, podremos reestructurar nuevamente la aplicación de forma tal que las notas sean almacenadas en un store, y que agregar o eliminar notas consista en generar actions.

Manos a la obra.

Creación de nuevos directorios

Para mantener la estructura de archivos organizada, agruparemos cada eje de flux en directorios. Creamos, entonces: react/actions, react/dispatcher, react/stores. Podemos renombrar react/components a react/views para mantener analogía, pero no es obligatorio. En actions crearemos un archivo, NoteActions.js, que contendrá los action creators (generadores de acciones) que correspondan a la administración de notas. En dispatcher creamos AppDispatcher.js. Ya que el dispatcher es siempre uno sólo. En stores crearemos NoteStore.js que claramente albergará la colección de notas y sus operaciones mutadoras.

La estructura del proyecto deberá quedar así:

/
├── css/              
│   └── style.css
├── js/
│   └── main.js (creado con browserify)
├── react/
│   ├── actions/
│   │   └── NoteActions.js
│   ├── dispatcher/
│   │   └── AppDispatcher.js
│   ├── stores/
│   │   └── NoteStore.js
│   ├── components/ o views/
│   │   ├── App.js
│   │   ├── Form.js
│   │   ├── Grid.js
│   │   └── Note.js
│   └── app.js
├── index.html
└── package.json

Si bien la estructura es bastante fácil de implementar, vamos a ahorrarnos una parte incluyendo el Dispatcher "oficial" de Facebook. En la consola ejecutamos:

npm install --save flux

La otra parte necesaria es un manejador de eventos básico para que las stores notifiquen sus cambios. Gracias a que usamos browserify para manejar los módulos, tenemos el trabajo hecho, ya que éste pone a nuestra disposición varias de las librerías disponibles nativamente en node.js, entre ellas, events. Así que estamos listos.

Nota: La arquitectura flux es en términos generales muy simple y puede ser implementada de mil formas distintas y con ayuda de diferentes librerías si se quiere. Nosotros aplicaremos una versión simple de las tantas posibles, suficiente para nuestras necesidades.

Comezamos con los nuevos archivos.

AppDispatcher.js

El eslabón más característico de flux es su Dispatcher único. Consiste en una vía para recibir mensajes transmitidos por actions, opcionalmente procesarlos, y retransmitirlos a todas las stores, obtenidas por un mecanismo clásico de suscripción. Es decir, las stores al crearse deben suscribirse al dispatcher para poder acceder a sus emisiones.

El Dispatcher que usaremos, provisto por el paquete flux, es un objeto provisto de varios métodos públicos y privados más que necesarios para nuestra aplicación. A nosotros particularmente nos interesan sus métodos register() para suscribir las stores, y dispatch() encargado de emitir mensajes a las mismas. Sólo es necesario importar la librería, instanciar el objeto y exponerlo.

var Dispatcher = require('flux').Dispatcher;

var AppDispatcher = new Dispatcher();

module.exports = AppDispatcher;
NoteActions.js

Los actions creators son objetos planos que exponen funciones que disparan acciones a través del método dispatch() del Dispatcher. Por lo pronto nuestra aplicación requiere de tres actions en particular, una para cargar las notas al inciar la aplicación, una para guardar notas y otra para eliminar notas. Recordemos que es aquí donde interactuaremos con nuestra base de datos, localStorage.

Es necesario importar dispatcher/AppDispatcher.js para usar su método dispatch().

var AppDispatcher = require('../dispatcher/AppDispatcher');

// Esta función se encargará de cargar y decodificar la base de datos localStorage
// cada vez que queramos modificar algo. El código es muy similar al que veníamos usando
function loadDatabase() {
    var notes = window.localStorage.getItem('notes');

    if (notes === null) {
        notes = [];
    } else {
        notes = JSON.parse(notes);
    }

    return notes;
}

// Esta otra función realizará el proceso inverso, codificar la base de datos
// para almacenarla una vez que hayamos realizado las operaciones
function saveDatabase(notes) {
    window.localStorage.setItem('notes', JSON.stringify(notes));
}

var NoteActions = {

    // Este método realizará la carga inicial de notas
    readNotes: function() {
        var notes = loadDatabase();

        // Enviamos un objeto plano como mensaje a las stores
        AppDispatcher.dispatch({
            type: 'READ', // esta propiedad servirá para identificar el mensaje en la store y actuar acorde
            notes: notes
        });
    },

    // Usaremos el nombre 'create' en vez 'save' para concordar con "CRUD"
    createNote: function(title, text) {
        // Creamos una id única rápida
        var id = new Date().getTime();

        // Construimos el objeto a almacenar
        var note = {
            id: id,
            title: title,
            text: text
        };

        // Abrimos la base de datos
        var notes = loadDatabase();

        // Insertamos la nota nueva
        notes.unshift(note);

        // Guardamos
        saveDatabase(notes);

        // Enviamos como mensaje la nota que hemos creado
        AppDispatcher.dispatch({
            type: 'CREATE',
            note: note
        });
    },

    // Finalmente para eliminar una nota por su id
    deleteNote: function(id) {
        // Abrimos db
        var notes = loadDatabase();

        // Recorremos el array en busca de la nota
        for (var i = 0, l = notes.length; i < l; i++) {
            // Si la encontramos, la eliminamos, sincronizamos y salimos del ciclo
            if (notes[i].id === id) {
                notes.splice(i, 1);

                saveDatabase(notes);

                break;
            }
        }

        // Enviamos al Dispatcher la id de la nota eliminada
        AppDispatcher.dispatch({
            type: 'DELETE',
            id: id
        });
    }

}

module.exports = NoteActions;

Es importante entender que estas funciones no alteran la lista de notas en la memoria de la aplicación, sino en la base de datos "física". A la aplicación sólo se le envían mensajes con información sobre operaciones, que serán realizadas en la store.

NoteStore.js

Antes que nada, necesitamos crear un contenedor de memoria donde almacenar las notas. Luego, nuestra store consistirá en tres partes: primero debemos construir un callback que será ejecutado cada vez que la store reciba un mensaje del Dispatcher. Luego, debemos exponer un objeto con funciones aptas para obtener información pública de las notas. Finalmente tenemos que implementar el sistema para poder emitir eventos cuando un cambio ocurra, y para que las views se suscriban a dichos eventos.

Primero lo primero, el contenedor en memoria que debería reflejar el estado de la base de datos:

// Store
var _notes = [];

Simple, no?

Ahora construimos el callback que se ejecutará cada vez que la store reciba un mensaje. Como la store recibirá cualquier mensaje que el Dispatcher transmita, es necesario comprobar el contenido del mensaje antes que nada para saber si es necesario realizar alguna operación o simplemente ignorarlo. Para esto agregamos la propiedad type a los mensajes emitidos en NoteActions, y podemos basarnos en el mismo para decidir qué hacer, por ejemplo con una estructura switch:

// El callback será siempre ejecutado pasandole el mensaje como primer argumento
function callback(payload) {
    // Basándonos en la propiedad type del mensaje, podemos inferir qué datos
    // contiene el mensaje y qué debemos hacer con ellos
    switch (payload.type) {
        case 'READ':
            _notes.push.apply(_notes, payload.notes);
            break;
        case 'CREATE':
            _notes.unshift(payload.note);
            break;
        case 'DELETE':
            for (var i = 0, l = _notes.length; i < l; i++) {
                if (_notes[i].id === payload.id) {
                    _notes.splice(i, 1);
                    break;
                }
            }
            break;
    }

    // Es necesario devolver true para que el Dispatcher sepa que las operaciones han terminado
    return true;
}

Si el callback recibe un mensaje cuya propiedad type sea diferente a las especificadas (o inexistente), simplemente lo ignorará.

Estas funciones no alteran la base de datos, alteran los datos cargados en la memoria del cliente.

Ahora, un poco más abajo, creamos la API de la store, con algunos métodos accesores básicos:

var NoteStore = {

    // Obtener una nota por su id
    get: function(id) {
        // Recorremos el array en busca de la nota
        for (var i = 0, l = _notes.length; i < l; i++) {
            // Si la encontramos, la devolvemos
            if (_notes[i].id === id) {
                return _notes[i];
            }
        }
        return false;
    },

    // Obtener todas las notas
    getAll: function() {
        // Usamos slice para devolver el propio array, sino una copia
        return _notes.slice();
    }

};

module.exports = NoteStore;

Ya casi listos. Ahora es necesario implementar en nuestra store el sistema de eventos. Al principio del archivo, incluimos tanto el Dispatcher como la clase EventEmitter del módulo events:

var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;

Ahora modificamos un poco la estructura NoteStore. Lo primero será implementar los métodos de EventEmitter. Para esto, en vez de declarar NoteStore como un objeto plano, lo declaramos como una instancia de EventEmitter (usando new) y luego le acoplamos los eventos get() ygetAll():

var NoteStore = |||new EventEmitter();|||

|||NoteStore.get = function(id) {|||
    // Recorremos el array en busca de la nota
    for (var i = 0, l = _notes.length; i < l; i++) {
        // Si la encontramos, la devolvemos
        if (_notes[i].id === id) {
            return _notes[i];
        }
    }
    return false;
|||};|||

// Obtener todas las notas
|||NoteStore.getAll = function() {|||
    // Usamos slice para devolver el propio array, sino una copia
    return _notes.slice();
|||};|||

module.exports = NoteStore;

Nota: También se podría haber extendido el objeto con el prototype de EventEmitter. Da igual.

Ahora que NoteSote es una instancia de EventEmitter, tiene acceso a los métodos on(), removeListener() y emit() típicos, y los usaremos para suscribir un componente a los cambios. Último añadido al callback(). Cada vez que el callback se ejecute y haya realizado alguna operación, debería emitir un evento, llamemosle change:

// El callback será siempre ejecutado pasandole el mensaje como primer argumento
function callback(payload) {
    // Basándonos en la propiedad type del mensaje, podemos inferir qué datos
    // contiene el mensaje y qué debemos hacer con ellos
    switch (payload.type) {
        case 'READ':
            _notes.push.apply(_notes, payload.notes);
            break;
        case 'CREATE':
            _notes.unshift(payload.note);
            break;
        case 'DELETE':
            for (var i = 0, l = _notes.length; i < l; i++) {
                if (_notes[i].id === payload.id) {
                    _notes.splice(i, 1);
                    break;
                }
            }
            break;

        // Si se ignora el mensaje, directamente termina
        |||default: return true;|||
    }

    // Si no se ignora el mensaje, emitimos el evento
    |||NoteStore.emit('change');|||

    // Es necesario devolver true para que el Dispatcher sepa que las operaciones han terminado
    return true;
}

Y por último, registramos nuestra store en el Dispatcher para poder empezar a recibir mensajes. Antes del module.exports, agregamos la siguiente línea:

AppDispatcher.register(callback);

Flux implementado! Ahora sólo es necesario hacer uso de las actions en los componentes para poner los mecanimos a funcionar. Una vez más, alteraremos los componentes.

App.js

La carga y sincronización con localStorage ahora es manejada por NoteActions, por lo que ya no es necesario el código que tenemos actualmente en getInitialState(). Lo reemplazaremos por lo siguiente:

    getInitialState: function() {
        return {
            notes: []
        };
    },

Esto indica que al montarse, inicialmente no se mostrarán notas (es un array vacío). Ahora tenemos que indicar que una vez montada la aplicación, cargue las notas desde la base de datos. Incluimos los action creators al inicio del archivo:

var React = require('react');
|||var NoteActions = require('../actions/NoteActions');|||
var Form = require('./Form');
var Grid = require('./Grid');

Y agregamos el método especial componentDidMount() al componente:

    componentDidMount: function() {
        NoteActions.readNotes();
    },

El componente componente ahora solicitará la carga de notas de la base de datos al iniciar la aplicación, pero falta un toque más. Cuando la carga de la db termine, la store se actualizará. Y cada vez que se realice una operación, la store se actualizará.

Cada vez que la store se actualice y emitirá un evento change, y este componente debería refrescar su estado cuando esto ocurra. Para esto haremos uso del método on() de la store. Asignaremos este listener una vez que el componente sea montado también, es decir en el mismo método componentDidMount(), antes de solicitar la carga:

    componentDidMount: function() {
        NoteStore.on('change', function(){
            this.setState({
                notes: NoteStore.getAll()
            });
        }.bind(this));

        NoteActions.readNotes();
    },

Y, para poder acceder a dicho método sin errores, debemos importar también la store al inicio del archivo:

var React = require('react');
var NoteActions = require('../actions/NoteActions');
|||var NoteStore = require('../stores/NoteStore');|||
var Form = require('./Form');
var Grid = require('./Grid');

Cada vez que un cambio se produzca en la store, el state de App se actualizará e invocará su método render(), pasando la nueva lista de notas via props a Grid, que se redibujará instantáneamente.

El método onSave() ya no es necesario por la misma que razón de antes, la lógica es manejada en NoteActions. Eliminamos tanto el método como su referencia en render. El componente finalmente quedará así:

var App = React.createClass({

    getInitialState: function() {
        return {
            notes: []
        };
    },

    componentDidMount: function() {
        NoteStore.on('change', function(){
            this.setState({
                notes: NoteStore.getAll()
            });
        }.bind(this));

        NoteActions.readNotes();
    },

    render: function() {
        return (
            <div id="wrapper">
                <Form />
                <Grid notes={this.state.notes} />
            </div>
        );
    }

});
Form.js

El formulario tiene como objetivo crear notas, por lo cual es lógico que haga uso de NoteActions para enviar ordenes. Importamos el archivo al inicio del archivo, después de react:

var React = require('react');
|||var NoteActions = require('../actions/NoteActions');|||

Y modificamos el método save(). En vez de llamar la función pasada por props (que ya no existe), vamos creamos una acción. Reemplazamos this.props.save(note) por NoteActions.createNote(note.title, note.text);

    save: function(e) {
        e.preventDefault();

        // Obtenemos los valores del formulario
        var note = {
            title: React.findDOMNode(this.refs.title).value,
            text: React.findDOMNode(this.refs.text).value
        };

        // Solicitamos la creación de la nota
        |||NoteActions.createNote(note.title, note.text);|||

        // Vaciamos el formulario
        React.findDOMNode(this.refs.title).value = '';
        React.findDOMNode(this.refs.text).value = '';

        // Y finalmente lo cerramos
        this.close();
    },

Esto generará un action, que pasará por el Dispatcher y será retransmitido a las stores (por ahora solamente a NoteStore) con type = 'CREATE'. La store agregará la nota a la lista, con una id única y emitirá el evento change.

Grid.js

Ahora que nuestras notas tienen una id única, es necesario incluirla en las props al momento de montarlas. Lo hacemos dentro del ciclo en el método render(). Además, es buena idea reemplazar el uso de idx como key por la id:

        var notes = this.props.notes.map(function(note, idx){
            return (
                <Note |||id={note.id}||| title={note.title} text={note.text} |||key={note.id}||| />
            );
        });
Note.js

Finalmente, y por lo que implementamos toda esta cuestión, agregaremos la habilidad de eliminar una nota.

Incluimos NoteActions en el archivo:

var React = require('react');
|||var NoteActions = require('../actions/NoteActions');|||

Creamos un método remove():

    remove: function() {
        NoteActions.deleteNote(this.props.id);
    },

Este método invoca el método deleteNote() pasandole su propia id como argumento, obtenida a partir de las props. Ahora falta añadir el disparador que inicie todo el mecanismo. Añadimos el atributo onClick() al botón de borrado en render():

    render: function() {
        return (
            <div className="note">
                <div className="note-text">
                    <strong>{this.props.title}</strong>
                    <p>{this.props.text}</p>
                </div>
                <div className="note-toolbar">
                    <a className="note-btn-delete" |||onClick={this.remove}||| />
                </div>
            </div>
        );
    }

Listo! Compilamos con browserify y probamos. La aplicación funcionará virtualmente igual (con el añadido de poder eliminar notas), pero en el fondo funciona in the flux way.

Nota: Ya que la estructura que guardamos en localStorage ha cambiado (agregamos la propiedad id a las notas), es buena idea eliminar todas las notas que hayamos tenido guardadas previamente. En el navegador y con la aplicación abierta, abrimos la consola web (F12 en chrome o firefox con firebug, ctrl+mayus+k en firefox sin firebug) y ejecutamos window.localStorage.removeItem('notes');