acdlite / flummox

Minimal, isomorphic Flux.
http://acdlite.github.io/flummox
1.69k stars 114 forks source link

What is the benefit of isomorphism without server-side rendering? #187

Closed greaber closed 9 years ago

greaber commented 9 years ago

Flummox promises isomorphism "for free", but it looks like it doesn't provide any special support for the trickiest part of isomorphism, namely injecting data from the database into the html on the server. The docs seem to suggest that doing this is an optional extra part of being isomorphic, but all the benefits I have heard about for isomorphic apps (namely, seo and fast perceived load time) depend on this data loading. Am I overlooking a key benefit of isomorphism? Or does flummox provide more than I think? (I admit I have not yet spent much time investigating it.)

AndrewRayCode commented 9 years ago

http://acdlite.github.io/flummox

Again, because Flummox does not rely on singletons, you get isomorphism for free: just create a new Flux instance on each request! Here’s a very basic example how that might look using Express and React:

jordaaash commented 9 years ago

Flummox is agnostic about your stores, your actions, or how any of them get data. See this from react-router on how to provision routes using a static fetchData method for example.

Here's a simplified but relatively complete example of the general structure and server side flow I follow in my own isomorphic apps:

application (shared)

var UserRoute = React.createClass({
    statics:                   {
        willRender:         function (flux, state) {
            // async action that will return a promise which, when resolved, populates the UserStore
            return flux.getActions('user').fetch(state.params.username);
        }
    },
    propTypes:                 {
        flux:   P.instanceOf(Flux).isRequired
    },
    render:                    function () {
        return (<User { ...this.props }/>);
    },
    componentDidMount:         function () {
        var props = this.props;
        var flux  = props.flux;
        var user  = flux.getStore('user').getUser();
        if (user == null || user.username !== props.params.username) {
            this.constructor.willRender(flux, props);
        }
    },
    componentWillReceiveProps: function (props) {
        if (props.params.username !== this.props.params.username) {
            this.constructor.willRender(props.flux, props);
        }
    }
});

client

document.addEventListener('DOMContentLoaded', function (event) {
    var flux = new Flux;
    // populate Flux instance with data from the server; see below
    flux.deserialize(window.flux);
    var router = Router.create({
        routes:   require('../application/routes.jsx'),
        location: Router.HistoryLocation
    });
    router.run(function (Root, state) {
        var props   = Object.assign({ flux: flux }, state);
        var element = React.createElement(Root, props);
        // don't render directly to document.body
        var body    = document.getElementById('body');
        React.render(element, body);
    });
});

template

<!DOCTYPE html>
<html lang="en">
    <body>
        <div id="body"><%- body %></div>
        <script>
            (function (window) {
                // FLUX
                window.flux = <%- flux %>;
            })(this);
        </script>
    </body>
</html>

server

var flux   = new Flux;
var router = Router.create({
    routes:   require('../application/routes.jsx'),
    // request path from express, koa, etc.
    location: request.path
});
router.run(function (Root, state) {
    Promise.all(
        state.routes.map(function (route) {
            // call willRender on all routes to get async data
            return route.handler.willRender && route.handler.willRender(flux, state);
        }).filter(Boolean)
    ).then(function () {
        var props   = Object.assign({ flux: flux }, state);
        var element = React.createElement(Root, props);
        var body    = React.renderToString(element);
        var template = ejs.compile('../templates/index.html');
        // response body from express, koa, etc.
        response.body = template({
            body: body,
            flux: flux.serialize()
        });
    })
});

There's really nothing magical to it. You get data by calling Flummox action methods inside willRender, which populate the stores before the server side rendering is done. The same willRender method is called on the client side in componentDidMount and componentWillReceiveProps if the route data has changed. You serialize and deserialize your flux instance so the state is passed from the client to the server; this is what satisfies the conditions in the componentDidMount method so your data only gets fetched once.

greaber commented 9 years ago

Thanks a bunch, this is super helpful! I hope you don't mind if I ask a few followup questions.

When you call willRender from componentDidMount or componentWillReceiveProps, it just fires off an async request to the server, so the next render will probably not have the new data, right? Do you need an onChange event handler to rerender when the store actually changes?

Passing the flux store through props is hygienic, but it leads to a certain amount of boilerplate. Is it possible/advisable to avoid this boilerplate somehow? On the client it is easy to use a global variable like you did for the serialized store, but on the server there is the complication that node doesn't really give you an easy way to scope something like that to a request. I guess this is the "singleton stores" issue I have seen discussed online.

How do you recommend loading the javascript on the client? It looks like the template might be missing a script tag to load the application code. Is the best practice to use async for that and to put the script tag at the beginning? Or would it be reasonable to inline it right after the tag and make it synchronous instead of a response to DOMContentLoaded? And as for the serialized state, all the examples I have seen inline it as you do, but I don't understand why it would be bad to load that as an external resource if it is good to lode the application code that way.

merk commented 9 years ago

I assume in @jordansexton's example, the async action will trigger an onChange event in the UserStore which the User component is subscribed to. There is no need for the UserRoute to update itself when the User is loaded in this case.

You can retrieve the Flux instance from context as long as you define the contextTypes in the component that requires it. Without the contextType definition, this.context wont be populated. This removes the need of passing flux down the chain of components.

class Component extends React.Component {
  someFunction() {
    const flux = this.context.flux;
    //...
  }
}
Component.contextTypes = {
  flux: React.PropTypes.object.isRequired
};
jordaaash commented 9 years ago

@greaber

When you call willRender from componentDidMount or componentWillReceiveProps, it just fires off an async request to the server, so the next render will probably not have the new data, right?

var user = flux.getStore('user').getUser();
if (user == null || user.username !== props.params.username) {
    this.constructor.willRender(flux, props);
}

It won't fire an async request to the server if the username hasn't changed because it expects the server rendered already.

Passing the flux store through props is hygienic, but it leads to a certain amount of boilerplate. Is it possible/advisable to avoid this boilerplate somehow?

This only occurs at the top level route, wherein I use FluxComponent to pass it through the context. Nested routes don't need it. I use it here in the example for simplicity, but @merk is correct. I use connect to link the component to the applicable store(s).

How do you recommend loading the javascript on the client?

This is stripped out of the template again for simplicity. I build my template with webpack using html-webpack-plugin with the EJS template variables preserved. A script tag referencing application.js?[hash] is inserted into the head at this point.

jordaaash commented 9 years ago

@greaber

And as for the serialized state, all the examples I have seen inline it as you do, but I don't understand why it would be bad to load that as an external resource if it is good to lode the application code that way.

How would you do this? The serialized state depends on, well, serializing the state of your Flux instance. This is not available "as an external resource" because it's not static and is (and must be) created in the rendering context.

jordaaash commented 9 years ago

@greaber

I may not have explained this well, but you're not just serializing the state of static Flux stores. It's important to note that willRender(flux, state) will call an action, fetch in this case. This action will cause the store to change. In addition, you may have actions in your components on componentWillMount which will also cause the stores to change. That's why I serialize the Flux instance after not only willRender but also renderToString.

In short, you can't just load Flux state into the application like any other file because even if we assume it's completely deterministic over its inputs (and it's probably not if it requires persistent data at a specific moment in time), it's still based on dynamic application state (route params, query string, user agent headers, cookies, session data, etc.) available on the server.

greaber commented 9 years ago

Thanks, @merk!

@jordansexton, thanks, I did understand that the stores are not static; I was thinking they could be served as an external resource (from you app, not a CDN or something), but maybe there is no point to doing this as it would be more complicated and possibly less performant. I didn't understand the subtlety about actions in componentWillMount that can cause the stores to change. What is a good example of when you would do this? You would have to limit yourself to synchronous actions, right?

I have been comparing Flummox with React Nexus. In general, React Nexus seems really interesting, and I'd love to hear what other people think about it. But I am very reluctant to use it because it seems to only be used by the people who wrote it.

One possible advantage of React Nexus is that it walks the whole component hierarchy to find willRender-like static functions to run, whereas the approach outlined in this thread can only pick up willRender functions in RouteHandlers, if I understand right. (Does react-router do component hierarchy walking internally to construct states.route, btw?) I don't have enough experience to judge how big of a deal this difference is, however.

Also, it is easy to walk the component hierarchy without using React Nexus, though you have to use undocumented stuff. Does this seem like a bad idea?

jordaaash commented 9 years ago

@greaber

Yes, you'd have to limit yourself yourself to synchronous actions in componentWillMount. But this is easy when you have your async calls in willRender because the props you need from there will already be in the stores.

An example would be isomorphic dynamic page titles that depend on async loaded data. I have a PageActions class including a setTitle method and a corresponding PageStore. When the title changes, the store sets document.title; this is wrapped in a if (typeof window !== 'undefined') so it only runs on the client. On the server side, I use flux.getStore('page').getTitle() in the template call (after the renderToString call) to set the <%- title %> variable in the template.

Let's say in the UserRoute example above that the User component is connected to the UserStore and has the following method:

componentWillMount: function () {
    this.flux.getActions('page').setTitle('Application | Users | ' + this.props.user.fullName);
}

This results in some boilerplate, so I have a mixin and a higher order Page component that does this succinctly throughout my app. It does the same things for meta description.

I'm not in a position to comment on React Nexus. I don't use it, and since this thread is ostensibly about isomorphic Flux as implemented in Flummox, it would make more sense to ask about it in the issues for that project.

jordaaash commented 9 years ago

@greaber

I did understand that the stores are not static; I was thinking they could be served as an external resource (from you app, not a CDN or something), but maybe there is no point to doing this as it would be more complicated and possibly less performant.

It would definitely be less performant since the browser would be making another (blocking) script request for data you could inline that needs to be there onDOMContentLoaded. Not to mention the fact that you'd either have to write the data to a file and then manage that file, or somehow store the data in memory for the subsequent script request and then render it out as a script file when the route is hit.

I can't think of a reason to do any of these things, or any reason to persist the serialized data at all since you can't do so in a granular fashion. If you need to speed up static stores, you could serialize those separately and cache them, but you're probably better off just worrying about caching things that are actually slow like the async requests.

greaber commented 9 years ago

@jordansexton I am closing this issue since you have answered my main questions and I have decided to give flummox a try. The approach you suggest to setting dynamic titles seems fine, but just to test my understanding, is it right that some alternatives would be:

  1. Don't rely on props to compute the page title. Don't put the page title in a store. Have the User component update document.title directly, though only on the client. Compute the page title the same way on the server and the client (and factor the computation into a helper function to avoid repeating yourself).
  2. Don't put the page title in a store. Have the User component update document.title directly, even on the server. But on the server, document.title is somehow aliased to the template variable. (Or perhaps the User component calls a setDocumentTitle function that is defined differently defined on the client and server.) I'm not sure how easy it is to swap in different definitions of things like this depending on whether you are on the client or server, however.

?