Skip to content
Fancy Pixel Fancy Pixel
Torna al blog

React + Flux con backend Rails API - Parte 2

Architettura Flux nel dettaglio: dispatcher, action creators, store, view. Set up del frontend React e implementazione completa del flusso di autenticazione.

Andrea Mazzini 14 min di lettura

Versione italiana dell’articolo originale del 29 gennaio 2015; traduzione a cura del team.

Questa è la seconda parte di “React + Flux backed by Rails API”: se non l’hai fatto, dai un’occhiata alla Parte 1.

Nella parte 1 abbiamo costruito la nostra API Rails, settato l’autenticazione e definito una risorsa per il nostro piccolo clone di Medium. È ora di arrivare al cuore di questo post: il frontend costruito in React con architettura Flux.

Setup del frontend

L’idea dietro lo split tra backend e frontend è trattare la web UI come un cittadino di prima classe, che vive nella sua cartella, nel suo repo, senza dipendenze dal backend. Il backend può essere sostituito facilmente, finché le spec dell’API restano consistenti. Quindi creiamo una nuova app da zero. Possiamo usare strumenti automatici come Yeoman, ma non sono riuscito a trovare la soluzione che soddisfasse tutti i miei requisiti.

Strumenti

Userò NPM di node per scaricare i tool principali, Gulp per i task di build e watch, Bower per le risorse. Prima di tuffarci nei dettagli, ti avviso: sono terribile con Gulp, quindi mi sono raccattato i task in giro per il web. Prendi quindi con le pinze il mio gulpfile, ho intenzione di sistemare tutti gli orrori appena posso. Potresti anche notare che non ho usato ES6, anche se avrei voluto. Ho incontrato un paio di problemi lavorando con ES6 e, dato che il tempo scarseggia, sono passato a JavaScript vanilla. Trovi il package.json e il gulpfile.js nel sample repo; per farla breve useremo react (ovviamente), react-router, superagent per le chiamate AJAX e flux. Come dicevo, Flux è solo un’architettura: cosa sto importando davvero nel mio package.json? A quanto pare Facebook ha rilasciato una piccola libreria chiamata flux che contiene fondamentalmente il codice per un Flux Dispatcher (di più dopo), che taglierà la quantità di boilerplate necessaria per partire.

Architettura Flux

Se hai già dato un’occhiata a Flux, dovresti conoscere questo diagramma:

Flux architecture

Potrebbe non essere facile da capire all’inizio, ma acquista sempre più senso man mano che implementi tutti quei blocchetti colorati. Provo a fare un po’ di luce.

Il blocco più a sinistra è la nostra Web API: l’abbiamo costruita nella parte precedente, quindi siamo a posto. La nostra API sarà chiamata dai “Web API Utils”, che sono semplicemente un file JS che fa richieste AJAX. Prima o poi questo componente JS riceverà una callback AJAX e dovrà aggiornare la nostra app frontend. Lo fa usando le Action. Un’action è semplicemente una struttura dati che dice al sistema cosa è successo e qual è il payload associato a quell’action. Ci sono due tipi di action: quelle iniziate da un server (es. una callback AJAX) e quelle iniziate dalle view (es. l’utente clicca un bottone). La differenza tra le due è essenzialmente semantica. Le action vengono create tramite Action Creator, che sono in realtà funzioni di utility che costruiscono l’action e la consegnano al sistema o, per essere più precisi, al dispatcher. Il dispatcher è un singolo oggetto (uno per app) che, come suggerisce il nome, dispatcha le action a chi ha registrato interesse in esse. Vedilo come un meccanismo pub-sub, semplice e basta. Gli oggetti che registrano interesse in queste action sono chiamati Store. Gli store contengono la logica e lo state dell’applicazione. Sono simili a un model, ma gestiscono lo state di tutti gli oggetti, non di un singolo record. Gli store sono quelli che offrono lo state che sarà presentato dalle view React. Le view React dovrebbero tenere il minor state possibile: dovrebbero prendere lo state dei dati da uno store e passarlo ai loro figli come props.

È più o meno tutto. Sembra parecchio contorto all’inizio, ma un esempio può chiarire le nebbie. Consideriamo il processo di login:

  • L’utente inserisce username e password e clicca Login
  • La view React gestisce l’evento click, prende il contenuto dei field e crea un’action tramite un action creator, con il tag LOGIN_REQUEST e un payload con le credenziali dell’utente
  • L’Action creator crea l’action LOGIN_REQUEST con il payload attaccato e avvisa il Dispatcher
  • L’Action creator chiama anche i Web API utils, passando il payload
  • I Web API Utils eseguono la chiamata AJAX
  • L’API Rails risponde autenticando l’utente e fornendo la response JSON
  • I Web API Utils ricevono il JSON e creano una nuova action, chiamata LOGIN_RESPONSE, con il nuovo JSON come payload
  • Il dispatcher è notificato e forward l’action allo/agli store interessato/i in una LOGIN_RESPONSE
  • Lo store (es. un SessionStore) viene notificato ed estrae il payload dall’action
  • Lo store aggiorna il suo state (username, auth token e login state messo a true)
  • Lo store emette i suoi change
  • Le view React sono notificate dei change e possono essere refreshate
  • Le view React possono prendere lo state dallo store e, se serve, passarlo ai loro figli

Tutto qui. Sembra un sacco di lavoro per un semplice login, ma il segreto che fa funzionare Flux è che questo pattern può essere applicato a ogni action eseguita dall’utente o dal server. Mantiene i componenti principali disaccoppiati, è più facile da mantenere e, soprattutto, tutto è ordinato — per una volta.

Ok, era un bel boccone, vediamo un po’ di codice.

Struttura del progetto

Partiamo dalla struttura del progetto.

|-actions
|-components
  |-common
  |-session
  |-stories
|-constants
|-dispatcher
|-stores
|-utils
app.jsx
routes.jsx

app.jsx sarà il nostro mount point: renderizza l’app nel nostro template HTML, niente di particolare:

// app.jsx
var React = require('react');
var router = require('./stores/RouteStore.react.jsx').getRouter();
window.React = React;

router.run(function (Handler, state) {
  React.render(<Handler/>, document.getElementById('content'));
});

Questo è il nostro primo assaggio di React e JSX. JSX è un’estensione di JS che ci permette di scrivere nodi con una sintassi simile a XML. È opzionale, ma pulisce la sintassi e può essere gestito con facilità anche dai designer.

Route

router.jsx contiene tutte le nostre route che saranno usate per istanziare react-router:

// routes.jsx
var React = require('react');
var Router = require('react-router');
var Route = Router.Route;
var DefaultRoute = Router.DefaultRoute;

var SmallApp = require('./components/SmallApp.react.jsx');
var LoginPage = require('./components/session/LoginPage.react.jsx');
var StoriesPage = require('./components/stories/StoriesPage.react.jsx');
var StoryPage = require('./components/stories/StoryPage.react.jsx');
var StoryNew = require('./components/stories/StoryNew.react.jsx');
var SignupPage = require('./components/session/SignupPage.react.jsx');

module.exports = (
  <Route name="app" path="/" handler={SmallApp}>
    <DefaultRoute handler={StoriesPage} />
    <Route name="login" path="/login" handler={LoginPage}/>
    <Route name="signup" path="/signup" handler={SignupPage}/>
    <Route name="stories" path="/stories" handler={StoriesPage}/>
    <Route name="story" path="/stories/:storyId" handler={StoryPage} />
    <Route name="new-story" path="/story/new" handler={StoryNew}/>
  </Route>
);

Le route sono espresse in sintassi JSX: possiamo specificare un nome (che sarà usato per fare transizioni e creare link), un handler (il componente React che sarà montato quando la route è visitata) e un path opzionale (che l’utente vedrà nella barra dell’indirizzo). Come vedi possiamo anche montare route dentro un’altra route, in stile RESTful.

Dispatcher

Il dispatcher è il cuore dell’app, è l’hub centrale per i nostri messaggi (action). È anche un componente abbastanza facile da implementare, è perlopiù codice boilerplate:

// ./dispatcher/SmallAppDispatcher.js
var SmallConstants = require('../constants/SmallConstants.js');
var Dispatcher = require('flux').Dispatcher;
var assign = require('object-assign');

var PayloadSources = SmallConstants.PayloadSources;

var SmallAppDispatcher = assign(new Dispatcher(), {

  handleServerAction: function(action) {
    var payload = {
      source: PayloadSources.SERVER_ACTION,
      action: action
    };
    this.dispatch(payload);
  },

  handleViewAction: function(action) {
    var payload = {
      source: PayloadSources.VIEW_ACTION,
      action: action
    };
    this.dispatch(payload);
  }
});

module.exports = SmallAppDispatcher;

Stiamo definendo due metodi principali usati per dispatchare un messaggio. Ne usiamo due al posto di uno solo per semantica: uno gestirà il dispatch delle action iniziate dal server, l’altro quelle iniziate dalle view. Prima di andare alla ciccia dell’implementazione, diamo un’occhiata al file Constants:

// constants/SmallConstants.js
var keyMirror = require('keymirror');

var APIRoot = "http://localhost:3002";

module.exports = {

  APIEndpoints: {
    LOGIN:          APIRoot + "/v1/login",
    REGISTRATION:   APIRoot + "/v1/users",
    STORIES:        APIRoot + "/v1/stories"
  },

  PayloadSources: keyMirror({
    SERVER_ACTION: null,
    VIEW_ACTION: null
  }),

  ActionTypes: keyMirror({
    // Session
    LOGIN_REQUEST: null,
    LOGIN_RESPONSE: null,

    // Routes
    REDIRECT: null,

    LOAD_STORIES: null,
    RECEIVE_STORIES: null,
    LOAD_STORY: null,
    RECEIVE_STORY: null,
    CREATE_STORY: null,
    RECEIVE_CREATED_STORY: null
  })

};

È un file di utility che contiene le costanti che useremo nel progetto, principalmente l’endpoint dell’API e i tipi di action che possiamo eseguire nell’app. Ora parliamo del processo di autenticazione.

Autenticazione

Come spiegato nell’esempio Flux sopra, il data flow sarà iniziato dall’utente: visiterà la pagina di login, compilerà un form con le sue credenziali e cliccherà submit. Gestiremo il submit come una VIEW_ACTION: questo significa che la nostra view chiamerà un metodo del nostro action creator per la session. Diamoci un’occhiata:

// ./scripts/actions/SessionActionCreators.react.jsx
var SmallAppDispatcher = require('../dispatcher/SmallAppDispatcher.js');
var SmallConstants = require('../constants/SmallConstants.js');
var WebAPIUtils = require('../utils/WebAPIUtils.js');

var ActionTypes = SmallConstants.ActionTypes;

module.exports = {

  signup: function(email, password, passwordConfirmation) {
    SmallAppDispatcher.handleViewAction({
      type: ActionTypes.SIGNUP_REQUEST,
      email: email,
      password: password,
      passwordConfirmation: passwordConfirmation
    });
    WebAPIUtils.signup(email, password, passwordConfirmation);
  },

  login: function(email, password) {
    SmallAppDispatcher.handleViewAction({
      type: ActionTypes.LOGIN_REQUEST,
      email: email,
      password: password
    });
    WebAPIUtils.login(email, password);
  },

  logout: function() {
    SmallAppDispatcher.handleViewAction({
      type: ActionTypes.LOGOUT
    });
  }

};

Questo copre tutte le action iniziate dall’utente nel contesto della session. Il login action creator, come vedi, crea una nuova ViewAction, allegando un payload con email e password dell’utente, e poi chiama il metodo WebAPIUtils.login. Se altri componenti avessero registrato interesse a ricevere l’action LOGIN_REQUEST, il dispatcher la consegnerebbe loro proprio ora. Il metodo login del nostro WebAPIUtils è questo:

// ./scripts/utils/WebAPIUtils.js
var ServerActionCreators = require('../actions/ServerActionCreators.react.jsx');
var request = require('superagent');

module.exports = {

  login: function(email, password) {
    request.post('http://localhost:3002/v1/login')
      .send({ username: email, password: password, grant_type: 'password' })
      .set('Accept', 'application/json')
      .end(function(error, res){
        if (res) {
          if (res.error) {
            var errorMsgs = _getErrors(res);
            ServerActionCreators.receiveLogin(null, errorMsgs);
          } else {
            json = JSON.parse(res.text);
            ServerActionCreators.receiveLogin(json, null);
          }
        }
      });
  },
  // ...
};

Un pattern comune dovrebbe iniziare a essere evidente: nessuna classe sta modificando direttamente lo state di un’altra, stanno solo creando nuove action. Questo è in poche parole il modo Flux di gestire i dati. Per tenere le cose ordinate, le action per i risultati del login sono create in un action creator separato:

// ./scripts/actions/ServerActionCreators.react.jsx
var SmallAppDispatcher = require('../dispatcher/SmallAppDispatcher.js');
var SmallConstants = require('../constants/SmallConstants.js');

var ActionTypes = SmallConstants.ActionTypes;

module.exports = {

  receiveLogin: function(json, errors) {
    SmallAppDispatcher.handleServerAction({
      type: ActionTypes.LOGIN_RESPONSE,
      json: json,
      errors: errors
    });
  },

 // ...
};

Questo copre le server e view action per il processo di login. Ma chi gestisce il risultato? Parliamo degli store.

SessionStore

Gli store sono un mix tra model e controller: gestiscono i dati, il main state dell’applicazione, alimentando i record alle view e recuperando i dati da un server. Stiamo per vedere il SessionStore, che tiene traccia dell’utente corrente (e contiene il suo access token, usato nelle chiamate all’API) e ascolta l’action LOGIN_RESPONSE.

// ./scripts/stores/SessionStore.react.jsx
var SmallAppDispatcher = require('../dispatcher/SmallAppDispatcher.js');
var SmallConstants = require('../constants/SmallConstants.js');
var EventEmitter = require('events').EventEmitter;
var assign = require('object-assign');

var ActionTypes = SmallConstants.ActionTypes;
var CHANGE_EVENT = 'change';

// Load an access token from the session storage, you might want to implement
// a 'remember me' using localSgorage
var _accessToken = sessionStorage.getItem('accessToken')
var _email = sessionStorage.getItem('email')
var _errors = [];

var SessionStore = assign({}, EventEmitter.prototype, {

  emitChange: function() {
    this.emit(CHANGE_EVENT);
  },

  addChangeListener: function(callback) {
    this.on(CHANGE_EVENT, callback);
  },

  removeChangeListener: function(callback) {
    this.removeListener(CHANGE_EVENT, callback);
  },

  isLoggedIn: function() {
    return _accessToken ? true : false;
  },

  getAccessToken: function() {
    return _accessToken;
  },

  getEmail: function() {
    return _email;
  },

  getErrors: function() {
    return _errors;
  }

});

SessionStore.dispatchToken = SmallAppDispatcher.register(function(payload) {
  var action = payload.action;

  switch(action.type) {

    case ActionTypes.LOGIN_RESPONSE:
      if (action.json && action.json.access_token) {
        _accessToken = action.json.access_token;
        _email = action.json.email;
        // Token will always live in the session, so that the API can grab it with no hassle
        sessionStorage.setItem('accessToken', _accessToken);
        sessionStorage.setItem('email', _email);
      }
      if (action.errors) {
        _errors = action.errors;
      }
      SessionStore.emitChange();
      break;

    case ActionTypes.LOGOUT:
      _accessToken = null;
      _email = null;
      sessionStorage.removeItem('accessToken');
      sessionStorage.removeItem('email');
      SessionStore.emitChange();
      break;

    default:
  }

  return true;
});

module.exports = SessionStore;

Sembra un sacco di codice, ma la maggior parte è boilerplate: la parte interessante è nella funzione .register. Quando lo store riceve l’action LOGIN_RESPONSE spacchetta il payload e controlla se il login è andato a buon fine o meno. Aggiorna poi il suo state (accessibile via le property pubbliche dichiarate in cima al file) e notifica un change a chiunque possa essere in ascolto (per questo importiamo EventEmitter di node e facciamo merge della classe con esso). Ok, abbiamo la capacità di inviare una view action, riceviamo il risultato e lo memorizziamo, ottimo. Ora ci serve usare questo store da qualche parte e mostrare un po’ di UI.

Application

Avere uno store e uno state porta una domanda spinosa: chi dovrebbe ascoltare i suoi change e chi dovrebbe usare il suo state? Seguendo la filosofia React dovremmo trovare il componente in cima all’albero delle view, senza però gonfiare il componente stesso. Per quanto riguarda la session, penso che il posto migliore sia la root della nostra app. La root è il primo componente montato dalle route e, se dai un’occhiata alle nostre route, è il componente chiamato SmallApp:

  // ./scripts/components/SmallApp.react.jsx
  var React = require('react');
  var RouteHandler = require('react-router').RouteHandler;
  var Header = require('../components/Header.react.jsx');
  var SessionStore = require('../stores/SessionStore.react.jsx');
  var RouteStore = require('../stores/RouteStore.react.jsx');

  function getStateFromStores() {
    return {
      isLoggedIn: SessionStore.isLoggedIn(),
      email: SessionStore.getEmail()
    };
  }

  var SmallApp = React.createClass({

    getInitialState: function() {
      return getStateFromStores();
    },

    componentDidMount: function() {
      SessionStore.addChangeListener(this._onChange);
    },

    componentWillUnmount: function() {
      SessionStore.removeChangeListener(this._onChange);
    },

    _onChange: function() {
      this.setState(getStateFromStores());
    },

    render: function() {
      return (
        <div className="app">
          <Header
            isLoggedIn={this.state.isLoggedIn}
            email={this.state.email} />
          <RouteHandler/>
        </div>
      );
    }

  });

  module.exports = SmallApp;

Questo è un componente molto semplice che funge da layout root. Se dai un’occhiata più da vicino alla render, vedi che renderizza solo un componente React chiamato Header e poi monta il contenuto fornito dal Router. L’Header ha però un paio di property (props React per essere precisi) e le riempiamo con lo state di SmallApp. Quelle props saranno accessibili dentro il componente Header. Lo state di SmallApp è ottenuto interrogando il SessionStore in due modi:

  • tramite la funzione getInitialState, invocata quando il componente è inizializzato
  • tramite la funzione _onChange, chiamata quando il SessionStore emette un nuovo change.

Quest’ultimo behavior è possibile perché il componente SmallApp ha registrato la sua callback nella funzione componentDidMount, che viene invocata, l’hai indovinato, quando il componente è montato in pagina.

Piccolo riepilogo: l’utente avvia una view action, i WebAPIUtils chiamano il server, il server risponde, una nuova action è generata, il dispatcher la forward al SessionStore, che aggiorna il suo status ed emette un change event, catturato dal componente SmallApp. SmallApp forwarda il suo state al figlio: il componente Header. Wew! Chiudiamo il cerchio scrivendo il nostro Header:

// ./scripts/components/Header.react.jsx
var React = require('react');
var Router = require('react-router');
var Link = Router.Link;
var ReactPropTypes = React.PropTypes;
var SessionActionCreators = require('../actions/SessionActionCreators.react.jsx');

var Header = React.createClass({

  propTypes: {
    isLoggedIn: ReactPropTypes.bool,
    email: ReactPropTypes.string
  },
  logout: function(e) {
    e.preventDefault();
    SessionActionCreators.logout();
  },
  render: function() {
    var rightNav = this.props.isLoggedIn ? (
      <ul className="right">
        <li className="has-dropdown">
          <a href="#">{this.props.email}</a>
          <ul className="dropdown">
            <li><a href='#' onClick={this.logout}>Logout</a></li>
          </ul>
        </li>
      </ul>
    ) : (
      <ul className="right">
        <li><Link to="login">Login</Link></li>
        <li><Link to="signup">Sign up</Link></li>
      </ul>
    );

    var leftNav = this.props.isLoggedIn ? (
      <ul className="left">
        <li><Link to="new-story">New story</Link></li>
      </ul>
    ) : (
      <div></div>
    );

    return (
      <nav className="top-bar" data-topbar role="navigation">
        <ul className="title-area">
          <li className="name">
            <h1><a href="#"><strong>S</strong></a></h1>
          </li>
          <li className="toggle-topbar menu-icon"><a href="#"><span>Menu</span></a></li>
        </ul>

        <section className="top-bar-section">
          {rightNav}
          {leftNav}
        </section>
      </nav>
    );
  }
});

module.exports = Header;

Come vedi stiamo finalmente definendo il nostro markup. All’interno di questo markup potresti notare un paio di chiamate dentro graffe che referenziano this.props. Questo oggetto è popolato con le property dichiarate nel componente precedente: ecco come un parent può inoltrare informazioni giù lungo la catena dei figli. Niente più two-way binding, i dati fluiscono dalla root alle foglie. React offre anche la possibilità di validare le props, specificando i propTypes. Ogni volta che passi una prop a un componente, React controlla il data type e solleva un warning JS nella console dell’inspector. È un comodo strumento di debug che migliora la riusabilità di un singolo componente. È evidente come stiamo definendo le view in modo dichiarativo. Una volta definite, non gestiamo lo state con una raffica di chiamate jQuery spaghetti: teniamo presente che la view verrà refreshata più avanti.

Sembra contorto all’inizio, vero? L’architettura Flux diventa fantastica una volta che ti rendi conto che ogni interazione segue lo stesso principio, e a quel punto tutto scatta nel tuo cervello. Hai notato la funzione logout nell’Header? Non fa riferimento al SessionStore, l’Header non sa nemmeno che esiste, segue solo un pattern: “quando succede qualcosa, crea un’action”. Il codice è disaccoppiato, le responsabilità sono separate, possiamo ottenere modularità e riusabilità. Stavolta per davvero. Brillante.

LoginPage

Abbiamo coperto la server action, ma dobbiamo ancora permettere all’utente di eseguire l’azione di login. Sistemiamo. Avrai indovinato: creeremo un componente React che farà partire un’action quando l’utente fa submit del form.

// ./scripts/component/session/LoginPage.react.jsx
var React = require('react');
var SessionActionCreators = require('../../actions/SessionActionCreators.react.jsx');
var SessionStore = require('../../stores/SessionStore.react.jsx');
var ErrorNotice = require('../../components/common/ErrorNotice.react.jsx');

var LoginPage = React.createClass({
  getInitialState: function() {
    return { errors: [] };
  },

  componentDidMount: function() {
    SessionStore.addChangeListener(this._onChange);
  },

  componentWillUnmount: function() {
    SessionStore.removeChangeListener(this._onChange);
  },

  _onChange: function() {
    this.setState({ errors: SessionStore.getErrors() });
  },

  _onSubmit: function(e) {
    e.preventDefault();
    this.setState({ errors: [] });
    var email = this.refs.email.getDOMNode().value;
    var password = this.refs.password.getDOMNode().value;
    SessionActionCreators.login(email, password);
  },

  render: function() {
    var errors = (this.state.errors.length > 0) ? <ErrorNotice errors={this.state.errors}/> : <div></div>;
    return (
      <div>
        {errors}
        <div className="row">
          <div className="card card--login small-10 medium-6 large-4 columns small-centered">
            <form onSubmit={this._onSubmit}>
              <div className="card--login__field">
                <label name="email">Email</label>
                <input type="text" name="email" ref="email" />
              </div>
              <div className="card--login__field">
                <label name="password">Password</label>
                <input type="password" name="password" ref="password" />
              </div>
              <button type="submit" className="card--login__submit">Login</button>
            </form>
          </div>
        </div>
      </div>
    );
  }
});

module.exports = LoginPage;

Come vedi abbiamo la nostra dichiarazione boilerplate della change callback all’inizio del file, è piuttosto comune. Potresti anche notare che questo componente ha un proprio state, a differenza dell’Header. È perché ho sentito che l’errore di login (recuperato dal SessionStore) appartiene alla pagina stessa, dato che sarà renderizzato lì, e non c’è bisogno di dargli uno scope più ampio integrandolo come state nel componente principale SmallApp. Tornando al processo di login: la funzione render definisce il markup del form di login e, al submit, il componente recupera email e password e crea una nuova action con essi. Tutto qui.

Prossima puntata

Potrebbe essere un buon momento per fare un’altra pausa: nella prossima parte vediamo come listare e postare una nuova story nella nostra app, e poi tireremo le somme. Ci vediamo presto.

Parte 3