Skip to content

Latest commit

 

History

History
583 lines (421 loc) · 28.6 KB

05_react_and_flux.md

File metadata and controls

583 lines (421 loc) · 28.6 KB

React and Flux

You can get pretty far by keeping everything in components. Eventually, that will become painful. Flux application architecture helps to bring clarity to our React applications.

Flux will allow us to separate data and application state from our views. This helps us to keep them clean and the application maintainable. Flux was designed with large teams in mind. As a result, you might find it quite verbose. This comes with great advantages, though, as it can be straightforward to work with.

Introduction to Flux

Unidirectional Flux dataflow

So far, we've been dealing only with views. Flux architecture introduces a couple of new concepts to the mix. These are actions, dispatcher, and stores. Flux implements unidirectional flow in contrast to popular frameworks, such as Angular or Ember. Even though two-directional bindings can be convenient, they come with a cost. It can be hard to deduce what's going on and why.

Actions and Stores

Flux isn't entirely simple to understand as there are many concepts to worry about. In our case, we will model NoteActions and NoteStore. NoteActions provide concrete operations we can perform over our data. For instance, we can have NoteActions.create({task: 'Learn React'}).

Dispatcher

When we trigger the action, the dispatcher will get notified. The dispatcher will be able to deal with possible dependencies between stores. It is possible that a certain action needs to happen before another. The dispatcher allows us to achieve this.

At the simplest level, actions can just pass the message to the dispatcher as is. They can also trigger asynchronous queries and hit the dispatcher based on the result eventually. This allows us to deal with received data and possible errors.

Once the dispatcher has dealt with the action, stores that are listening to it get triggered. In our case, NoteStore gets notified. As a result, it will be able to update its internal state. After doing this it will notify possible listeners of the new state.

Flux Dataflow

This completes the basic unidirectional, yet linear, process flow of Flux. Usually, though, the unidirectional process has a cyclical flow and it doesn't necessarily end. The following diagram illustrates a more common flow. It is the same idea again, but with the addition of a returning cycle. Eventually, the components depending on our store data become refreshed through this looping process.

This sounds like a lot of steps for achieving something simple as creating a new Note. The approach does come with its benefits. Given the flow is always in a single direction, it is easy to trace and debug. If there's something wrong, it's somewhere within the cycle.

Flux dataflow with cycle

Advantages of Flux

Even though this sounds a little complicated, the arrangement gives our application flexibility. We can, for instance, implement API communication, caching, and i18n outside of our views. This way they stay clean of logic while keeping the application easier to understand.

Implementing Flux architecture in your application will actually increase the amount of code somewhat. It is important to understand: minimizing the amount of code written isn't the goal of Flux. It has been designed to allow productivity across larger teams. You could say, "explicit is better than implicit".

Which Flux Implementation to Use?

The library situation is constantly changing. There is no single right way to interpret the architecture. You will find implementations that fit different tastes. voronianski/flux-comparison provides a nice comparison between some of the more popular ones.

When choosing a library, it comes down to your own personal preferences. You will have to consider factors, such as API, features, documentation, and support. Starting with one of the more popular alternatives can be a good idea. As you begin to understand the architecture, you are able to make choices that serve you better.

T> Redux has taken the core ideas of Flux and pushed them into a tiny form (2 kB). Despite this, it's quite powerful approach and worth checking out.

Porting to Alt

Alt

In this chapter, we'll be using a library known as Alt. It is a flexible, full-featured implementation that has been designed with universal (isomorphic) rendering in mind.

In Alt, you'll deal with actions and stores. The dispatcher is hidden, but you will still have access to it if needed. Compared to other implementations Alt hides a lot of boilerplate. There are special features to allow you to save and restore the application state. This is handy for implementing persistency and universal rendering.

Setting Up an Alt Instance

Everything in Alt begins from an Alt instance. It keeps track of actions and stores and keeps communication going on. To get started, we should add Alt to our project. We'll also install alt-utils as it contains some special functionality we'll need later on. object-assign is installed in order to ponyfill Object.assign.

npm i alt alt-utils object-assign --save

T> Compared to polyfills, ponyfills do not overwrite native methods. With a ponyfills you lose out on API. On the plus side, their behavior is clear to understand. There is less magic going on. Both approaches have their merits.

To keep things simple, we'll be treating all Alt components as a singleton. With this pattern, we reuse the same instance within the whole application. To achieve this we can push it to a module of its own and then refer to that from everywhere. Set it up as follows:

app/libs/alt.js

import Alt from 'alt';
//import chromeDebug from 'alt-utils/lib/chromeDebug';

const alt = new Alt();
//chromeDebug(alt);

export default alt;

Webpack caches the modules so the next time you import Alt, it will return the same instance again.

T> There is a Chrome plugin known as alt-devtool. After it is installed, you can connect to Alt by uncommenting the related lines above. You can use it to debug the state of your stores, search, and travel in time.

Defining CRUD API for Notes

Next, we'll need to define a basic API for operating over the Note data. To keep this simple, we can CRUD (Create, Read, Update, Delete) it. Given Read is implicit, we won't be needing that. We can model the rest as actions, though. Alt provides a shorthand known as generateActions. We can use it like this:

app/actions/NoteActions.js

import alt from '../libs/alt';

export default alt.generateActions('create', 'update', 'delete');

Defining a Store for Notes

A store is a single source of truth for a part of your application state. In this case, we need one to maintain the state of the notes. We will connect all the actions we defined above using the bindActions function.

We have the logic we need for our store already at App. We will move that logic to NoteStore.

Setting Up a Skeleton

As a first step, we can set up a skeleton for our store. We can fill in the methods we need after that. Alt uses standard ES6 classes, so it's the same syntax as we saw earlier with React components. Here's a starting point:

app/stores/NoteStore.js

import uuid from 'node-uuid';
import alt from '../libs/alt';
import NoteActions from '../actions/NoteActions';

class NoteStore {
  constructor() {
    this.bindActions(NoteActions);

    this.notes = [];
  }
  create(note) {

  }
  update(updatedNote) {

  }
  delete(id) {

  }
}

export default alt.createStore(NoteStore, 'NoteStore');

We call bindActions to map each action to a method by name. We trigger the appropriate logic at each method based on that. Finally, we connect the store with Alt using alt.createStore.

Note that assigning a label to a store (NoteStore in this case) isn't required. It is a good practice as it protects the code against minification and possible collisions. These labels become important when we persist the data.

Implementing create

Compared to the earlier logic, create will generate an id for a Note automatically. This is a detail that can be hidden within the store:

app/stores/NoteStore.js

import uuid from 'node-uuid';
leanpub-start-insert
import assign from 'object-assign';
leanpub-end-insert
import alt from '../libs/alt';
import NoteActions from '../actions/NoteActions';

class NoteStore {
  constructor() {
    ...
  }
  create(note) {
leanpub-start-insert
    const notes = this.notes;

    note.id = uuid.v4();

    this.setState({
      notes: notes.concat(note)
    });
leanpub-end-insert
  }
  ...
}

export default alt.createStore(NoteStore, 'NoteStore');

To keep the implementation clean, we are using this.setState. It is a feature of Alt that allows us to signify that we are going to alter the store state. Alt will signal the change to possible listeners.

Implementing update

update follows the earlier logic apart from some renaming. Most importantly we commit the new state through this.setState:

app/stores/NoteStore.js

...

class NoteStore {
  ...
  update(updatedNote) {
leanpub-start-insert
    const notes = this.notes.map((note) => {
      if(note.id === updatedNote.id) {
        return assign({}, note, updatedNote);
      }

      return note;
    });

    // This is same as `this.setState({notes: notes})`
    this.setState({notes});
leanpub-end-insert
  }
  delete(id) {

  }
}

export default alt.createStore(NoteStore, 'NoteStore');

We have one final operation left, delete.

T> {notes} is known as a an ES6 feature known as property shorthand. This is equivalent to {notes: notes}.

Implementing delete

delete is straightforward. Seek and destroy, as earlier, and remember to commit the change:

app/stores/NoteStore.js

...

class NoteStore {
  ...
  delete(id) {
leanpub-start-insert
    this.setState({
      notes: this.notes.filter((note) => note.id !== id)
    });
leanpub-end-insert
  }
}

export default alt.createStore(NoteStore, 'NoteStore');

Instead of slicing and concatenating data, it would be possible to operate directly on it. For example a mutable variant, such as this.notes.splice(targetId, 1) would work. We could also use a shorthand, such as [...notes.slice(0, noteIndex), ...notes.slice(noteIndex + 1)]. The exact solution depends on your preferences. I prefer to avoid mutable solutions (i.e., splice) myself.

It is recommended that you use setState with Alt to keep things clean and easy to understand. Manipulating this.notes directly would work, but that would miss the intent and could become problematic in larger scale as mutation is difficult to debug. setState provides a nice analogue to the way React works so it's worth using.

We have almost integrated Flux with our application now. We have a set of actions that provide an API for manipulating Notes data. We also have a store for actual data manipulation. We are missing one final bit, integration with our view. It will have to listen to the store and be able to trigger actions to complete the cycle.

T> The current implementation is naïve in that it doesn't validate parameters in any way. It would be a very good idea to validate the object shape to avoid incidents during development. Flow based gradual typing provides one way to do this. Alternatively you could write nice tests. That's a good idea regardless.

Gluing It All Together

Gluing this all together is a little complicated as there are multiple concerns to take care of. Dealing with actions is going to be easy. For instance, to create a Note, we would need to trigger NoteActions.create({task: 'New task'}). That would cause the associated store to change and, as a result, all the components listening to it.

Our NoteStore provides two methods in particular that are going to be useful. These are NoteStore.listen and NoteStore.unlisten. They will allow views to subscribe to the state changes.

As you might remember from the earlier chapters, React provides a set of lifecycle hooks. We can subscribe to NoteStore within our view at componentDidMount and componentWillUnmount. By unsubscribing, we avoid possible memory leaks.

Based on these ideas we can connect App with NoteStore and NoteActions:

app/components/App.jsx

leanpub-start-delete
import uuid from 'node-uuid';
leanpub-end-delete
import React from 'react';
import Notes from './Notes.jsx';
leanpub-start-insert
import NoteActions from '../actions/NoteActions';
import NoteStore from '../stores/NoteStore';
leanpub-end-insert

export default class App extends React.Component {
  constructor(props) {
    super(props);

leanpub-start-delete
    this.state = {
      notes: [
        {
          id: uuid.v4(),
          task: 'Learn Webpack'
        },
        {
          id: uuid.v4(),
          task: 'Learn React'
        },
        {
          id: uuid.v4(),
          task: 'Do laundry'
        }
      ]
    };
leanpub-end-delete
leanpub-start-insert
    this.state = NoteStore.getState();
leanpub-end-insert
  }
leanpub-start-insert
  componentDidMount() {
    NoteStore.listen(this.storeChanged);
  }
  componentWillUnmount() {
    NoteStore.unlisten(this.storeChanged);
  }
  storeChanged = (state) => {
    // Without a property initializer `this` wouldn't
    // point at the right context because it defaults to
    // `undefined` in strict mode.
    this.setState(state);
  };
leanpub-end-insert
  render() {
    const notes = this.state.notes;

    return (
      <div>
        <button className="add-note" onClick={this.addNote}>+</button>
        <Notes notes={notes}
          onEdit={this.editNote}
          onDelete={this.deleteNote} />
      </div>
    );
  }
leanpub-start-delete
  deleteNote = (id) => {
    this.setState({
      notes: this.state.notes.filter((note) => note.id !== id)
    });
  };
leanpub-end-delete
leanpub-start-insert
  deleteNote(id) {
    NoteActions.delete(id);
  }
leanpub-end-insert
leanpub-start-delete
  addNote = () => {
    this.setState({
      notes: this.state.notes.concat([{
        id: uuid.v4(),
        task: 'New task'
      }])
    });
  };
leanpub-end-delete
leanpub-start-insert
  addNote() {
    NoteActions.create({task: 'New task'});
  }
leanpub-end-insert
leanpub-start-delete
  editNote = (id, task) => {
    const notes = this.state.notes.map((note) => {
      if(note.id === id  && task) {
        note.task = task;
      }

      return note;
    });

    this.setState({notes});
  };
leanpub-end-delete
leanpub-start-insert
  editNote(id, task) {
    NoteActions.update({id, task});
  }
leanpub-end-insert
}

The application should work just like before now. As we alter NoteStore through actions, this leads to a cascade that causes our App state to update through setState. This in turn will cause the component to render. That's Flux's unidirectional flow in practice.

We actually have more code now than before, but that's okay. App is a little neater and it's going to be easier to develop as we'll soon see. Most importantly we have managed to implement the Flux architecture for our application.

What's the Point?

Even though integrating Alt took a lot of effort, it was not all in vain. Consider the following questions:

  1. Suppose we wanted to persist the notes within localStorage. Where would you implement that? It would be natural to plug that into our NoteStore. Alternatively we could do something more generic as we'll be doing next.
  2. What if we had many components relying on the data? We would just consume NoteStore and display it, however we want.
  3. What if we had many, separate Note lists for different types of tasks? We could set up another store for tracking these lists. That store could refer to actual Notes by id. We'll do something like this in the next chapter, as we generalize the approach.

This is what makes Flux a strong architecture when used with React. It isn't hard to find answers to questions like these. Even though there is more code, it is easier to reason about. Given we are dealing with a unidirectional flow we have something that is simple to debug and test.

Implementing Persistency over localStorage

We will modify our implementation of NoteStore to persist the data on change. This way we don't lose our data after a refresh. One way to achieve this is to use localStorage. It is a well supported feature that allows you to persist data to the browser.

Understanding localStorage

localStorage has a sibling known as sessionStorage. Whereas sessionStorage loses its data when the browser is closed, localStorage retains its data. They both share the same API as discussed below:

  • storage.getItem(k) - Returns the stored string value for the given key.
  • storage.removeItem(k) - Removes the data matching the key.
  • storage.setItem(k, v) - Stores the given value using the given key.
  • storage.clear() - Empties the storage contents.

Note that it is convenient to operate on the API using your browser developer tools. For instance, in Chrome you can see the state of the storages through the Resources tab. Console tab allows you to perform direct operations on the data. You can even use storage.key and storage.key = 'value' shorthands for quick modifications.

localStorage and sessionStorage can use up to 10 MB of data combined. Even though they are well supported, there are certain corner cases with interesting failures. These include running out of memory in Internet Explorer (fails silently) and failing altogether in Safari's private mode. It is possible to work around these glitches, though.

T> You can support Safari in private mode by trying to write into localStorage first. If that fails, you can use Safari's in-memory store instead, or just let the user know about the situation. See Stack Overflow for details.

Implementing a Wrapper for localStorage

To keep things simple and manageable, we can implement a little wrapper for storage. It will wrap all of these complexities. The API expects strings.

As objects are convenient, we'll use JSON.parse and JSON.stringify for serialization. We need just storage.get(k) and storage.set(k, v) as seen in the implementation below:

app/libs/storage.js

export default {
  get: function(k) {
    try {
      return JSON.parse(localStorage.getItem(k));
    }
    catch(e) {
      return null;
    }
  },
  set: function(k, v) {
    localStorage.setItem(k, JSON.stringify(v));
  }
};

The implementation could be generalized further. You could convert it into a factory ((storage) => {...}) and make it possible to swap the storage. Now we are stuck with localStorage unless we change the code.

T> We're operating with localStorage directly to keep the implementation simple. An alternative would be to use localForage to hide all the complexity. You could even integrate it behind our interface.

Persisting Application Using FinalStore

Besides this little utility, we'll need to adapt our application to use it. Alt provides a built-in store called FinalStore which is perfect for this purpose. We can persist the entire state of our application using FinalStore, bootstrapping, and snapshotting. FinalStore is a store that listens to all existing stores. Every time some store changes, FinalStore will know about it. This makes it ideal for persistency.

We can take a snapshot of the entire app state and push it to localStorage every time FinalStore changes. That solves one part of the problem. Bootstrapping solves the remaining part as alt.bootstrap allows us to set state of the all stores. The method doesn't emit events. To make our stores populate with the right state, we will need to call it before the components are rendered. In our case, we'll fetch the data from localStorage and invoke it to populate our stores.

T> An alternative way would be to take a snapshot only when the window gets closed. There's a Window level beforeunload hook that could be used. The problem with this approach is that it is brittle. What if something unexpected happens and the hook doesn't get triggered for some reason? You'll lose data.

In order to integrate this idea to our application, we will need to implement a little module to manage it. We take the possible initial data into account there and trigger the new logic.

app/libs/persist.js does the hard part. It will set up a FinalStore, deal with bootstrapping (restore data) and snapshotting (save data). I have included an escape hatch in the form of the debug flag. If it is set, the data won't get saved to localStorage. The reasoning is that by doing this, you can set the flag (localStorage.setItem('debug', 'true')), hit localStorage.clear() and refresh the browser to get a clean slate. The implementation below illustrates these ideas:

app/libs/persist.js

import makeFinalStore from 'alt-utils/lib/makeFinalStore';

export default function(alt, storage, storeName) {
  const finalStore = makeFinalStore(alt);

  try {
    alt.bootstrap(storage.get(storeName));
  }
  catch(e) {
    console.error('Failed to bootstrap data', e);
  }

  finalStore.listen(() => {
    if(!storage.get('debug')) {
      storage.set(storeName, alt.takeSnapshot());
    }
  });
}

Finally, we need to trigger the persistency logic at initialization. We will need to pass the relevant data to it (Alt instance, storage, storage name) and off we go.

app/index.jsx

...
leanpub-start-insert
import alt from './libs/alt';
import storage from './libs/storage';
import persist from './libs/persist';
leanpub-end-insert

leanpub-start-insert
persist(alt, storage, 'app');
leanpub-end-insert

ReactDOM.render(<App />, document.getElementById('app'));

If you try refreshing the browser now, the application should retain its state. The solution should scale with minimal effort if we add more stores to the system. Integrating a real back-end wouldn't be a problem. There are hooks in place for that now.

You could, for instance, pass the initial payload as a part of your HTML (universal rendering), load it up, and then persist the data to the back-end. You have a great deal of control over how to do this, and you can use localStorage as a backup if you want.

Universal rendering is a powerful technique that allows you to use React to improve the performance of your application while gaining SEO benefits. Rather than leaving all rendering to the front-end, we perform a part of it at the back-end side. We render the initial application markup at back-end and provide it to the user. React will pick that up. This can also include data that can be loaded to your application without having to perform extra queries.

W> Our persist implementation isn't without its flaws. It is easy to end up in a situation where localStorage contains invalid data due to changes made to the data model. This brings you to the world of database schemas and migrations. There are no easy solutions. Regardless, this is something to keep in mind when developing something more sophisticated. The lesson here is that the more you inject state to your application, the more complicated it gets.

Using the AltContainer

The AltContainer wrapper allows us to simplify connection logic greatly and cut down the amount of logic needed. To get started, install it using:

npm i alt-container --save

The implementation below illustrates how to bind it all together. Note how much code we can remove!

app/components/App.jsx

leanpub-start-insert
import AltContainer from 'alt-container';
leanpub-end-insert
import React from 'react';
import Notes from './Notes.jsx';
import NoteActions from '../actions/NoteActions';
import NoteStore from '../stores/NoteStore';

export default class App extends React.Component {
leanpub-start-delete
  constructor(props) {
    super(props);

    this.state = NoteStore.getState();
  }
  componentDidMount() {
    NoteStore.listen(this.storeChanged);
  }
  componentWillUnmount() {
    NoteStore.unlisten(this.storeChanged);
  }
  storeChanged = (state) => {
    // Without a property initializer `this` wouldn't
    // point at the right context (defaults to `undefined` in strict mode).
    this.setState(state);
  };
leanpub-end-delete
  render() {
leanpub-start-delete
    const notes = this.state.notes;
leanpub-end-delete

    return (
      <div>
        <button className="add-note" onClick={this.addNote}>+</button>
leanpub-start-delete
        <Notes notes={notes}
          onEdit={this.editNote}
          onDelete={this.deleteNote} />
leanpub-end-delete
leanpub-start-insert
        <AltContainer
          stores={[NoteStore]}
          inject={{
            notes: () => NoteStore.getState().notes
          }}
        >
          <Notes onEdit={this.editNote} onDelete={this.deleteNote} />
        </AltContainer>
leanpub-end-insert
      </div>
    );
  }
  ...
}

The AltContainer allows us to bind data to its immediate children. In this case, it injects the notes property in to Notes. The pattern allows us to set up arbitrary connections to multiple stores and manage them. You can find another possible approach at the appendix about decorators.

Integrating the AltContainer tied this component to Alt. If you wanted something forward-looking, you could push it into a component of your own. That facade would hide Alt and allow you to replace it with something else later on.

Dispatching in Alt

Even though you can get far without ever using Flux dispatcher, it can be useful to know something about it. Alt provides two ways to use it. If you want to log everything that goes through your alt instance, you can use a snippet, such as alt.dispatcher.register(console.log.bind(console)). Alternatively you could trigger this.dispatcher.register(...) at a store constructor. These mechanisms allow you to implement effective logging.

Alternative Implementations

Even though we ended up using Alt in our implementation, it's not the only option. In order to benchmark various architectures, I've implemented the same application using different techniques. I've compared them briefly below:

  • Redux is a Flux inspired architecture that was designed with hot loading as its primary constraint. Redux operates based on a single state tree. The state of the tree is manipulated using pure functions known as reducers. Even though there's some boilerplate code, Redux forces you to dig into functional programming. The implementation is quite close to the Alt based one. - Redux demo
  • Compared to Redux, Cerebral had a different starting point. It was developed to provide insight on how the application changes its state. Cerebral provides more opinionated way to develop, and as a result, comes with more batteries included. - Cerebral demo
  • Mobservable allows you to make your data structures observable. The structures can then be connected with React components so that whenever the structures update, so do the React components. Given real references between structures can be used, the Kanban implementation is surprisingly simple. - Mobservable demo

Relay?

Compared to Flux, Facebook's Relay improves on the data fetching department. It allows you to push data requirements to the view level. It can be used standalone or with Flux depending on your needs.

Given it's still largely untested technology, we won't be covering it in this book yet. Relay comes with special requirements of its own (GraphQL compatible API). Only time will tell how it gets adopted by the community.

Conclusion

In this chapter, you saw how to port our simple application to use Flux architecture. In the process we learned about basic concepts of Flux. Now we are ready to start adding more functionality to our application.