Features

Key features that make up the main value proposition for Lore

Infinite Scrolling

Mobile friendly style of pagination that aggregates fetched data into a single scrollable list.

Visualization

This video demonstrates what infinite scrolling looks like. Screenshots are from the Simply Social prototype that Invision provides you when you sign up for an account.

Usage

Infinite Scrolling is a variation on traditional pagination, with two key differences:

  1. Instead of replacing the data, we append to the data, so the number of results displayed on screen is always increasing.
  2. Instead of asking the user which page of data they'd like to view, we always show them the first page, and then load each progressively page in order. So we load page 1, then page 2, then page 3, etc.

Despite those differences, the infrastructure to support Infinite Scrolling is identical to Pagination, since the way data is requested from the API and ultimately cached in the Redux store is the same. The difference lies only in thevisual experience, making Infinite Scrolling a View concern; something that is managed in the Component.

Because of that, it's important that you first understand how Pagination works before tackling Infinite Scrolling, as we'll be piggy-backing on that example.

Let's start from the part where we are displaying the first page of posts:

connect(function(getState, props) {
    return {
      posts: getState('post.find', {
        pagination: {
          page: 1
        }
      })
    };
  })(
  createReactClass({

    propTypes: {
      posts: React.PropTypes.object.isRequired
    },

    render: function() {
      var posts = this.props.posts;

      if (posts.state === PayloadStates.FETCHING) {
        return (
          <div>Loading posts...</div>
        );
      }

      return (
        <div>
          <ul>
            {posts.data.map((post) => {
              return (
                <li key={post.id || post.cid}>
                  {post.data.title}
                </li>
              );
            })}
          </ul>
          { /* todo: render load more button */}
        </div>
      );
    }
  })
);

Now that we're displaying our first page of data, we need to add a button the user can click to load the next page:

connect(function(getState, props) {
    return {
      posts: getState('post.find', {
        pagination: {
          page: 1
        }
      })
    };
  })(
  createReactClass({

    propTypes: {
      posts: React.PropTypes.object.isRequired
    },

    onLoadMore: function() {
      lore.actions.post.find({}, {
        page: 2
      });
    },

    render: function() {
      var posts = this.props.posts;

      if (posts.state === PayloadStates.FETCHING) {
        return (
          <div>Loading posts...</div>
        );
      }

      return (
        <div>
          <ul>
            {posts.data.map((post) => {
              return (
                <li key={post.id || post.cid}>
                  {post.data.title}
                </li>
              );
            })}
          </ul>
          <button onClick={this.onLoadMore}>
            Load More
          </button>
        </div>
      );
    }
  })
);

When the user clicks the Load More button, we're going to invoke the post.find action and fetch the second page of posts. Note that while this will make a network request and retrieve the second page, and the component will rerender, we're not rendering the new data yet.

To do that, we first need to create an array in this components state to store all our pages of post data. We'll do that in the getInitialState method, and we'll populate it with the first page:

connect(function(getState, props) {
    return {
      posts: getState('post.find', {
        pagination: {
          page: 1
        }
      })
    };
  })(
  createReactClass({

    propTypes: {
      posts: React.PropTypes.object.isRequired
    },

    getInitialState: function() {
      return {
        pages: [
          this.props.posts
        ]
      };
    },

    onLoadMore: function() {
      lore.actions.post.find({}, {
        page: 2
      });
    },

    render: function() {
      var posts = this.props.posts;

      if (posts.state === PayloadStates.FETCHING) {
        return (
          <div>Loading posts...</div>
        );
      }

      return (
        <div>
          <ul>
            {posts.data.map((post) => {
              return (
                <li key={post.id || post.cid}>
                  {post.data.title}
                </li>
              );
            })}
          </ul>
          <button onClick={this.onLoadMore}>
            Load More
          </button>
        </div>
      );
    }
  })
);

Next we're going to update our render method so that it iterates through each page of posts and combine them into a single array of list items for display:

connect(function(getState, props) {
    return {
      posts: getState('post.find', {
        pagination: {
          page: 1
        }
      })
    };
  })(
  createReactClass({

    propTypes: {
      posts: React.PropTypes.object.isRequired
    },

    getInitialState: function() {
      return {
        pages: [
          this.props.posts
        ]
      };
    },

    onLoadMore: function() {
      lore.actions.post.find({}, {
        page: 2
      });
    },

    render: function() {
      var pages = this.state.pages;
      var firstPage = pages[0];

      if (firstPage.state === PayloadStates.FETCHING) {
        return (
          <div>Loading posts...</div>
        );
      }

      var allPosts = _.flatten(pages.map(function(posts) {
        return posts.data.map((post) => {
          return (
            <li key={post.id || post.cid}>
              {post.data.title}
            </li>
          );
        });
      }));

      return (
        <div>
          <ul>
            {allPosts}
          </ul>
          <button onClick={this.onLoadMore}>
            Load More
          </button>
        </div>
      );
    }
  })
);

The _.flatten() method is used to transform an array-of-arrays into a single array. In this example, if takes the mapped array of list items arrays like [[

  • ], [
  • ]]
    and flattens them into a single array of list items like [
  • ,
  • ]
    .

    So now we're rendering all pages of data, but have a subtle problem; we're not updating the data in that array when the store changes, which means our application will always say "Loading posts..." because we never update the first page once the data comes back to reflect that the request is RESOLVED. To fix that, we're going to use the componentWillReceivePropsmethod to update the pages array everytime the component rerenders. We'll do this by looking up each page of data in the Redux store and recreating the pages array using the most up-to-date data.

    connect(function(getState, props) {
        return {
          posts: getState('post.find', {
            pagination: {
              page: 1
            }
          })
        };
      })(
      createReactClass({
    
        propTypes: {
          posts: React.PropTypes.object.isRequired
        },
    
        contextTypes: {
          store: React.PropTypes.object.isRequired
        },
    
        getInitialState: function() {
          return {
            pages: [
              this.props.posts
            ]
          };
        },
    
        componentWillReceiveProps: function(nextProps) {
          var storeState = this.context.store.getState();
          var pages = this.state.pages;
    
          var nextPages = pages.map(function(posts) {
            var query = JSON.stringify(posts.query);
            return storeState.post.find[query];
          });
    
          this.setState({
            pages: nextPages
          });
        },
    
        onLoadMore: function() {
          lore.actions.post.find({}, {
            page: 2
          });
        },
    
        render: function() {
          var pages = this.state.pages;
          var firstPage = pages[0];
    
          if (firstPage.state === PayloadStates.FETCHING) {
            return (
              <div>Loading posts...</div>
            );
          }
    
          var allPosts = _.flatten(pages.map(function(posts) {
            return posts.data.map((post) => {
              return (
                <li key={post.id || post.cid}>
                  {post.data.title}
                </li>
              );
            });
          }));
    
          return (
            <div>
              <ul>
                {allPosts}
              </ul>
              <button onClick={this.onLoadMore}>
                Load More
              </button>
            </div>
          );
        }
      })
    );
    

    With that change in place, our component will rerender whenever the store updates, and our pages array will always contain the most up-to-date information, which means our component will always display an accurate representation of the data. But we still have a problem; while we are fetching the second page of data, because we never add it to ourpages array, it will never show up in the UI. So let's fix that.

    To address the issue, we're going to need to modify the onLoadMore method so that it pushes the second page into thepages array:

    connect(function(getState, props) {
        return {
          posts: getState('post.find', {
            pagination: {
              page: 1
            }
          })
        };
      })(
      createReactClass({
    
        propTypes: {
          posts: React.PropTypes.object.isRequired
        },
    
        contextTypes: {
          store: React.PropTypes.object.isRequired
        },
    
        getInitialState: function() {
          return {
            pages: [
              this.props.posts
            ]
          };
        },
    
        componentWillReceiveProps: function(nextProps) {
          var storeState = this.context.store.getState();
          var pages = this.state.pages;
    
          var nextPages = pages.map(function(posts) {
            var query = JSON.stringify(posts.query);
            return storeState.post.find[query];
          });
    
          this.setState({
            pages: nextPages
          });
        },
    
        onLoadMore: function() {
          var pages = this.state.pages;
    
          var action = lore.actions.post.find({}, {
            page: 2
          });
    
          var posts = action.payload;
          pages.push(posts);
    
          this.setState({
            pages: pages
          });
        },
    
        render: function() {
          var pages = this.state.pages;
          var firstPage = pages[0];
    
          if (firstPage.state === PayloadStates.FETCHING) {
            return (
              <div>Loading posts...</div>
            );
          }
    
          var allPosts = _.flatten(pages.map(function(posts) {
            return posts.data.map((post) => {
              return (
                <li key={post.id || post.cid}>
                  {post.data.title}
                </li>
              );
            });
          }));
    
          return (
            <div>
              <ul>
                {allPosts}
              </ul>
              <button onClick={this.onLoadMore}>
                Load More
              </button>
            </div>
          );
        }
      })
    );
    

    Whenever you invoke a built-in action in Lore (an action provided by the framework) it always returns the action it dispatched to the Redux store. In this case, we're extracting the payload from the action so that we have a copy of the data that represents the section page of requests. That data will look like this:

    posts = {
      state: 'FETCHING',
      data: [],
      query: { pagination: { page: 2 } }
    }
    

    Because our pages array now contains a second page of posts, and that posts data has a state of FETCHING and a query property describing the second page, our component will have all the information it needs to know that weare fetching the second page. So we could improve the experience by disabling the Load More button while a new page is being fetched.

    We won't do that here, but for a working example that does (including an example of error handling) see theinfinite-scrolling example on GitHub.