Features
Key features that make up the main value proposition for Lore
Key features that make up the main value proposition for Lore
Mobile friendly style of pagination that aggregates fetched data into a single scrollable list.
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.
Infinite Scrolling is a variation on traditional pagination, with two key differences:
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 componentWillReceiveProps
method 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.