Skip to content
Fancy Pixel Fancy Pixel
Torna al blog

React + Flux con backend Rails API - Parte 3

Ultima parte: lista delle story, dettaglio, creazione di una nuova story autenticata. Chiusura del cerchio sul flusso Flux end-to-end.

Andrea Mazzini 7 min di lettura

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

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

Nella parte 1 abbiamo costruito un’API Rails per un piccolo clone di Medium, chiamato appropriatamente Small. Nella parte 2 abbiamo visto il setup di un’app React con architettura Flux e abbiamo costruito il workflow di autenticazione. Chiudiamo questa serie fornendo la lista delle story e una pagina di creazione, protetta da autenticazione. Una nota di avvertimento: questa è un’implementazione rapida e talvolta naive: lo scopo principale è mostrarti come iniziare con React e Flux, ed è per questo che non abbiamo investito troppo nel gestire eventuali errori o nel mostrare indicatori di caricamento.

Listare le story

Tornando alle nostre route, ho definito una route di default, montata sotto /stories, gestita da un componente chiamato StoriesPage. Questo componente rispetterà l’architettura Flux: il suo unico scopo nella vita sarà mostrare i dati recuperati da uno store e ri-renderizzare quando qualcosa cambia. Prima di tuffarci nel componente, creiamo lo store:

// ./scripts/stores/StoryStore.rect.jsx
var SmallAppDispatcher = require('../dispatcher/SmallAppDispatcher.js');
var SmallConstants = require('../constants/SmallConstants.js');
var EventEmitter = require('events').EventEmitter;
var assign = require('object-assign');
var WebAPIUtils = require('../utils/WebAPIUtils.js');

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

var _stories = [];
var _errors = [];
var _story = { title: "", body: "", user: { username: "" } };

var StoryStore = 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);
  },

  getAllStories: function() {
    return _stories;
  },

  getStory: function() {
    return _story;
  },

  getErrors: function() {
    return _errors;
  }

});

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

  switch(action.type) {

    case ActionTypes.RECEIVE_STORIES:
      _stories = action.json.stories;
      StoryStore.emitChange();
      break;

    case ActionTypes.RECEIVE_CREATED_STORY:
      if (action.json) {
        _stories.unshift(action.json.story);
        _errors = [];
      }
      if (action.errors) {
        _errors = action.errors;
      }
      StoryStore.emitChange();
      break;

    case ActionTypes.RECEIVE_STORY:
      if (action.json) {
        _story = action.json.story;
        _errors = [];
      }
      if (action.errors) {
        _errors = action.errors;
      }
      StoryStore.emitChange();
      break;
  }

  return true;
});

module.exports = StoryStore;

A questo punto dovrebbe esserti familiare: definiamo lo state privato, le property per accedervi dall’esterno e registriamo le callback. Quando una nuova action RECEIVE_STORIES viene dispatchata, prendiamo il contenuto del payload, lo salviamo nello state dello store ed emettiamo un change. La RECEIVE_STORIES viene generata quando WebAPIUtils riceve la response XHR dal server, esattamente come per il login. Ma quale componente fa partire questa request AJAX? È una view action, iniziata dall’utente quando richiede la route /stories. Ecco il componente di questo route handler:

// ./scripts/components/StoriesPage.react.jsx
var React = require('react');
var WebAPIUtils = require('../../utils/WebAPIUtils.js');
var StoryStore = require('../../stores/StoryStore.react.jsx');
var ErrorNotice = require('../../components/common/ErrorNotice.react.jsx');
var StoryActionCreators = require('../../actions/StoryActionCreators.react.jsx');
var Router = require('react-router');
var Link = Router.Link;
var timeago = require('timeago');

var StoriesPage = React.createClass({

  getInitialState: function() {
    return {
      stories: StoryStore.getAllStories(),
      errors: []
    };
  },

  componentDidMount: function() {
    StoryStore.addChangeListener(this._onChange);
    StoryActionCreators.loadStories();
  },

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

  _onChange: function() {
    this.setState({
      stories: StoryStore.getAllStories(),
      errors: StoryStore.getErrors()
    });
  },

  render: function() {
    var errors = (this.state.errors.length > 0) ? <ErrorNotice errors={this.state.errors}/> : <div></div>;
    return (
      <div>
        {errors}
        <div className="row">
          <StoriesList stories={this.state.stories} />
        </div>
      </div>
    );
  }
});

var StoryItem = React.createClass({
  render: function() {
    return (
      <li className="story">
        <div className="story__title">
          <Link to="story" params={ {storyId: this.props.story.id} }>
            {this.props.story.title}
          </Link>
        </div>
        <div className="story__body">{this.props.story['abstract']}...</div>
        <span className="story__user">{this.props.story.user.username}</span>
        <span className="story__date"> - {timeago(this.props.story.created_at)}</span>
      </li>
      );
  }
});

var StoriesList = React.createClass({
  render: function() {
    return (
      <ul className="large-8 medium-10 small-12 small-centered columns">
        {this.props.stories.map(function(story, index){
          return <StoryItem story={story} key={"story-" + index}/>
        })}
      </ul>
    );
  }
});

module.exports = StoriesPage;

In questo componente ho sfruttato la funzione componentDidMount per far partire la request asincrona alla nostra API. Le story sono poi recuperate dallo StoryStore appena visto e tenute nello state del componente. Nella funzione render ho colto l’occasione per mostrarti un modo più React-y di scrivere una view, con componenti modulari e riusabili. Questi componenti potrebbero anche vivere in file separati (o addirittura in progetti separati), ma per leggibilità li ho messi nello stesso file. Come vedi la pagina renderizza un solo componente, StoriesList, passando la lista delle story come props. StoriesList usa .map per renderizzare un componente StoryItem per ogni item dell’array. Nota come ho dovuto specificare una key univoca per l’item (puoi usare un index come ho fatto io o anche l’id della story): questo aiuta React a tenere traccia di quale elemento è cambiato. L’ultimo elemento della catena è il componente StoryItem, che renderizza le props fornite dal parent. Potresti notare i nomi strani delle classi CSS: abbi pazienza, sto provando ad abituarmi alla metodologia BEM.

Caricare una story

Se tutto è andato bene dovremmo vedere una lista di story, qualcosa tipo:

Stories list

Cosa succede quando clicco una story? Tornando alla StoriesPage abbiamo questo pezzo di codice:

  <div className="story__title">
    <Link to="story" params={ {storyId: this.props.story.id} }>
      {this.props.story.title}
    </Link>
  </div>

e nel router abbiamo configurato la route così:

  <Route name="story" path="/stories/:storyId" handler={StoryPage} />

Questo significa che quando selezioniamo un item dalla lista, il router sostituirà il componente corrente (o handler) con il componente StoryPage, passando un query parameter chiamato storyId. Il nostro StoryPage sarà quindi in grado di prendere quell’id e creare una nuova action, richiedendo il download dell’item selezionato. Come al solito la request sarà gestita dal WebAPIUtils, la response sarà incapsulata in una nuova action, gestita dallo store, che emette il change, terminando con StoryPage che fa refresh del contenuto. Se hai seguito fin qui ormai dovresti avere questo pattern in memoria. Se è così, hai capito l’architettura Flux. Giusto per il gusto, ecco la StoryPage:

var React = require('react');
var WebAPIUtils = require('../../utils/WebAPIUtils.js');
var StoryStore = require('../../stores/StoryStore.react.jsx');
var StoryActionCreators = require('../../actions/StoryActionCreators.react.jsx');
var State = require('react-router').State;

var StoryPage = React.createClass({

  mixins: [ State ],

  getInitialState: function() {
    return {
      story: StoryStore.getStory(),
      errors: []
    };
  },

  componentDidMount: function() {
    StoryStore.addChangeListener(this._onChange);
    StoryActionCreators.loadStory(this.getParams().storyId);
  },

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

  _onChange: function() {
    this.setState({
      story: StoryStore.getStory(),
      errors: StoryStore.getErrors()
    });
  },

  render: function() {
    return (
      <div className="row">
        <div className="story__title">{this.state.story.title}</div>
        <div className="story__body">{this.state.story.body}</div>
        <div className="story__user">{this.state.story.user.username}</div>
      </div>
     );
  }

});

module.exports = StoryPage;

Creare una nuova story

Manca un ultimo punto da coprire: l’input dell’utente. Lo abbiamo già fatto nel processo di autenticazione, ma senza scendere nei dettagli. Diamo un’occhiata al componente StoryNew:

// ./scripts/components/stories/StoryNew.react.jsx
var React = require('react');
var SmallAppDispatcher = require('../../dispatcher/SmallAppDispatcher.js');
var SmallConstants = require('../../constants/SmallConstants.js');
var WebAPIUtils = require('../../utils/WebAPIUtils.js');
var SessionStore = require('../../stores/SessionStore.react.jsx');
var StoryActionCreators = require('../../actions/StoryActionCreators.react.jsx');
var RouteActionCreators = require('../../actions/RouteActionCreators.react.jsx');

var StoryNew = React.createClass({

  componentDidMount: function() {
    if (!SessionStore.isLoggedIn()) {
      RouteActionCreators.redirect('app');
    }
  },

  _onSubmit: function(e) {
    e.preventDefault();
    var title = this.refs.title.getDOMNode().value;
    var body = this.refs.body.getDOMNode().value;
    StoryActionCreators.createStory(title, body);
  },

  render: function() {
    return (
      <div className="row">
        <form onSubmit={this._onSubmit} className="new-story">
          <div className="new-story__title">
            <input type="text" placeholder="Title" name="title" ref="title" />
          </div>
          <div className="new-story__body">
            <textarea rows="10" placeholder="Your story..." name="body" ref="body" />
          </div>
          <div className="new-story__submit">
            <button type="submit">Create</button>
          </div>
         </form>
       </div>
     );
  }

});

module.exports = StoryNew;

Nella funzione render abbiamo definito un form HTML con un text input e una textarea. Nota la property ref di questi tag: il suo valore è il binding che useremo per recuperare il loro contenuto quando l’utente fa submit del form, usando la funzione getDOMNode(). Il submit fa di nuovo partire la catena Flux, creando una view action. C’è una cosa da notare qui: postare una nuova story è un’action eseguita da un utente autenticato, quindi il metodo nel WebAPIUtils sarà così:

  createStory: function(title, body) {
    request.post('http://localhost:3002/v1/stories')
      .set('Accept', 'application/json')
      .set('Authorization', sessionStorage.getItem('accessToken'))
      .send({ story: { title: title, body: body } })
      .end(function(error, res){
        if (res) {
          if (res.error) {
            var errorMsgs = _getErrors(res);
            ServerActionCreators.receiveCreatedStory(null, errorMsgs);
          } else {
            json = JSON.parse(res.text);
            ServerActionCreators.receiveCreatedStory(json, null);
          }
        }
      });
  }

Nota come stiamo passando l’access token dell’utente nell’header della request. Tutto qui: la nostra API Rails autenticherà l’utente grazie a quell’header e l’action sarà eseguita.

Conclusioni

Questo conclude la nostra mini applicazione. Come detto, è un’implementazione naive, non pensata per la produzione: non ho coperto granché la gestione degli errori, il feedback all’utente durante il caricamento o il testing. Trovi tutto il source qui:

Rails API

React + Flux frontend

Flux vs. Fluxxor vs. Reflux vs…

Flux non è l’unica implementazione di questa architettura: ci sono diverse librerie che la implementano, alcune legate a React, altre no, alcune non usano nemmeno tutta l’architettura. È solo questione di preferenze: dai un’occhiata a questa discussione su GitHub per saperne di più. Personalmente ho scelto Flux vanilla per capire meglio tutte le componenti sottostanti; una volta che lo capisci, c’è sempre tempo per togliere il grasso superfluo.

Il futuro di React

Mentre scrivevo questo si è tenuta la React.js conf; trovi un riassunto degli annunci qui. La cosa più entusiasmante è stata probabilmente React Native. Essendo iOS developer nel mio cuore ho sempre guardato con sospetto i framework web con capacità native come Phonegap o RubyMotion, ma se guardi questa presentazione è difficile non emozionarsi davanti a React Native. La cosa che mi colpisce di più è che hanno colto il concetto chiave: dimentica “Write once run anywhere”, abbraccia “Learn once, write anywhere”. React è qui per restare e sta già influenzando ogni altro framework JS in giro. Sono entusiasta di costruire applicazioni future con esso e di vedere come si espanderà nel futuro.