A lazy scrolling list component for react-web
backing onto a Relay Connection, with paginated fetching,
while offering good performance and memory characteristics.
Two things to manage
Data fetching through providing pagination parameters to query, manipulation of the relay store
Component rendering, manipulation of the virtual DOM
Is there some particular list component which handles this well?
Is there an established pattern for implementing this common mechanism?
This pattern is pretty much the representative scenario for connections. Here's a hypothetical <PostsIndex> component that shows a list of posts with a "load more" button. If you don't want to explicitly change the UI when in the isLoading state you could delete the constructor and the setVariables callback. Adding viewport based infinite scrolling would not be hard either; you'd just need to wire a scroll listener up to you setVariables call.
class PostsIndex extends React.Component {
constructor(props) {
super(props);
this.state = {isLoading: false};
}
_handleLoadMore = () => {
this.props.relay.setVariables({
count: this.props.relay.variables.count + 10,
}, ({ready, done, error, aborted}) => {
this.setState({isLoading: !ready && !(done || error || aborted)});
});
}
render() {
return (
<div>
{
this.props.viewer.posts.edges.map(({node}) => (
<Post key={node.id} post={node} />
))
}
{
this.props.viewer.posts.pageInfo.hasNextPage ?
<LoadMoreButton
isLoading={this.state.isLoading}
onLoadMore={this._handleLoadMore}
/> :
null
}
</div>
);
}
}
export default Relay.createContainer(PostsIndex, {
initialVariables: {
count: 10,
},
fragments: {
viewer: () => Relay.QL`
fragment on User {
posts(first: $count) {
edges {
node {
id
${Post.getFragment('post')}
}
}
pageInfo {
hasNextPage
}
}
}
`,
},
});
Related
The problem:
I want to have simple boolean flag that will be true when modal is opened and false when it is closed. And I want to update other components reactively depends on that flag
I hope there is a way to do it with relay only (Apollo has a solution for that). I don't want to connect redux of mobx or something like that (It is just simple boolean flag!).
What I already have:
It is possible to use commitLocalUpdate in order to modify your state.
Indeed I was able to create and modify my new flag like that:
class ModalComponent extends PureComponent {
componentDidMount() {
// Here I either create or update value if it exists
commitLocalUpdate(environment, (store) => {
if (!store.get('isModalOpened')) {
store.create('isModalOpened', 'Boolean').setValue(true);
} else {
store.get('isModalOpened').setValue(true);
}
});
}
componentWillUnmount() {
// Here I mark flag as false
commitLocalUpdate(environment, (store) => {
store.get('isModalOpened').setValue(false);
});
}
render() {
// This is just react component so you have full picture
return ReactDOM.createPortal(
<div
className={ styles.modalContainer }
>
dummy modal
</div>,
document.getElementById('modal'),
);
}
}
The challenge:
How to update other components reactively depends on that flag?
I can't fetch my flag like this:
const MyComponent = (props) => {
return (
<QueryRenderer
environment={ environment }
query={ graphql`
query MyComponentQuery {
isModalOpened
}`
} //PROBLEM IS HERE GraphQLParser: Unknown field `isModalOpened` on type `Query`
render={ ({ error, props: data, retry }) => {
return (
<div>
{data.isModalOpened}
<div/>
);
} }
/>);
};
Because Relay compiler throws me an error: GraphQLParser: Unknown field 'isModalOpened' on type 'Query'.
And the last problem:
How to avoid server request?
That information is stored on client side so there is no need for request.
I know there a few maybe similar questions like that and that. But they doesn't ask most difficult part of reactive update and answers are outdated.
If you need to store just one flag as you said, I recommend you to use React Context instead of Relay. You could do next:
Add Context to App component:
const ModalContext = React.createContext('modal');
export class App extends React.Component {
constructor() {
super();
this.state = {
isModalOpened: false
}
}
toggleModal = (value) => {
this.setState({
isModalOpened: value
})
};
getModalContextValue() {
return {
isModalOpened: this.state.isModalOpened,
toggleModal: this.toggleModal
}
}
render() {
return (
<ModalContext.Provider value={this.getModalContextValue()}>
//your child components
</ModalContext.Provider>
)
}
}
Get value from context everywhere you want:
const MyComponent = (props) => {
const { isModalOpened } = useContext(ModalContext);
return (
<div>
{isModalOpened}
</div>
);
};
If you will use this solution you will get rid of using additional libraries such as Relay and server requests.
I have an app with redux and router where on the first load, all users are loaded. To this end, I've implemented a main component that loads the user when the component is mounted:
class Content extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
this.props.load();
}
render() {
return this.props.children;
}
}
The afterwards, if the user chooses to load the details of one user, the details are also obtained through the same lifehook:
class Details extends Component {
componentDidMount() {
this.props.getByUrl(this.props.match.params.url);
}
render() {
const { user: userObject } = this.props;
const { user } = userObject;
if (user) {
return (
<>
<Link to="/">Go back</Link>
<h1>{user.name}</h1>
</>
);
}
return (
<>
<Link to="/">Go back</Link>
<div>Fetching...</div>
</>
);
}
Now this works well if the user lands on the main page. However, if you get directly to the link (i.e. https://43r1592l0.codesandbox.io/gal-gadot) it doesn't because the users aren't loaded yet.
I made a simple example to demonstrate my issues. https://codesandbox.io/s/43r1592l0 if you click a link, it works. If you get directly to the link (https://43r1592l0.codesandbox.io/gal-gadot) it doesn't.
How would I solve this issue?
Summary of our chat on reactiflux:
To answer your question: how would you solve this? -> High Order Components
your question comes down to "re-using the fetching all users before loading a component" part.
Let's say you want to show a Component after your users are loaded, otherwise you show the loading div: (Simple version)
import {connect} from 'react-redux'
const withUser = connect(
state => ({
users: state.users // <-- change this to get the users from the state
}),
dispatch => ({
loadUsers: () => dispatch({type: 'LOAD_USERS'}) // <-- change this to the proper dispatch
})
)
now you can re-use withUsers for both your components, which will look like:
class Content extends Component {
componentDidMount() {
if (! this.props.users || ! this.props.users.length) {
this.props.loadUsers()
}
}
// ... etc
}
const ContentWithUsers = withUsers(Content) // <-- you will use that class
class Details extends Component {
componentDidMount() {
if (! this.props.users || ! this.props.users.length) {
this.props.loadUsers()
}
}
}
const DetailsWithUsers = withUsers(Details) // <-- same thing applies
we now created a re-usable HOC from connect. you can wrap your components with withUsers and you can then re-use it but as you can see, you are also re-writing the componentDidMount() part twice
let's take the actual load if we haven't loaded it part out of your Component and put it in a wrapper
const withUsers = WrappedComponent => { // notice the WrappedComponent
class WithUsersHOC extends Component {
componentDidMount () {
if (!this.props.users || !this.props.users.length) {
this.props.loadUsers()
}
}
render () {
if (! this.props.users) { // let's show a simple loading div while we haven't loaded yet
return (<div>Loading...</div>)
}
return (<WrappedComponent {...this.props} />) // We render the actual component here
}
}
// the connect from the "simple version" re-used
return connect(
state => ({
users: state.users
}),
dispatch => ({
loadUsers: () => dispatch({ type: 'LOAD_USERS' })
})
)(WithUsersHOC)
}
Now you can just do:
class Content extends Component {
render() {
// ......
}
}
const ContentWithUsers = withUsers(Content)
No need to implement loading the users anymore, since WithUsersHOC takes care of that
You can now wrap both Content and Details with the same HOC (High Order Component)
Until the Users are loaded, it won't show the actual component yet.
Once the users are loaded, your components render correctly.
Need another page where you need to load the users before displaying? Wrap it in your HOC as well
now, one more thing to inspire a bit more re-usability
What if you don't want your withLoading component to just be able to handle the users?
const withLoading = compareFunction = Component =>
class extends React.Component {
render() {
if (! compareFunction(this.props)) {
return <Component {...this.props} />;
}
else return <div>Loading...</div>;
}
};
now you can re-use it:
const withUsersLoading = withLoading(props => !props.users || ! props.users.length)
const ContentWithUsersAndLoading = withUsers(withUsersLoading(Content)) // sorry for the long name
or, written as a bit more clean compose:
export default compose(
withUsers,
withLoading(props => !props.users || !props.users.length)
)(Content)
now you have both withUsers and withLoading reusable throughout your app
I'm not sure if this is possible due to the way meteor works. I'm trying to figure out how to unsubscribe and subscribe to collections and have the data removed from mini mongo on the client side when the user clicks a button. The problem I have in the example below is that when a user clicks the handleButtonAllCompanies all the data is delivered to the client and then if they click handleButtonTop100 it doesn't resubscribe. So the data on the client side doesn't change. Is it possible to do this?
Path: CompaniesPage.jsx
export default class CompaniesPage extends Component {
constructor(props) {
super(props);
this.handleButtonAllCompanies = this.handleButtonAllCompanies.bind(this);
this.handleButtonTop100 = this.handleButtonTop100.bind(this);
}
handleButtonAllCompanies(event) {
event.preventDefault();
Meteor.subscribe('companiesAll');
}
handleButtonTop100(event) {
event.preventDefault();
Meteor.subscribe('companiesTop100');
}
render() {
// console.log(1, this.props.companiesASX);
return (
<div>
<Button color="primary" onClick={this.handleButtonAllCompanies}>All</Button>
<Button color="primary" onClick={this.handleButtonTop100}>Top 100</Button>
</div>
);
}
}
Path: Publish.js
Meteor.publish('admin.handleButtonAllCompanies', function() {
return CompaniesASX.find({});
});
Meteor.publish('admin.handleButtonTop100', function() {
return CompaniesASX.find({}, { limit: 100});
});
This is definitely possible, but the way to do that is to fix your publication. You want to think MVC, i.e., separate as much as possible the data and mode from the way you are going to present it. This means that you should not maintain two publications of the same collection, for two specific purposes. Rather you want to reuse the same publication, but just change the parameters as needed.
Meteor.publish('companies', function(limit) {
if (limit) {
return CompaniesASX.find({}, { limit });
} else {
return CompaniesASX.find({});
}
});
Then you can define your button handlers as:
handleButtonAllCompanies(event) {
event.preventDefault();
Meteor.subscribe('companies');
}
handleButtonTop100(event) {
event.preventDefault();
Meteor.subscribe('companies', 100);
}
This way you are changing an existing subscription, rather than creating a new one.
I'm not yet super familiar with react in meteor. But in blaze you wouldn't even need to re-subscribe. You would just provide a reactive variable as the subscription parameter and whenever that would change, meteor would update the subscription reactively. The same may be possible in react, but I'm not sure how.
Ok, first thanks to #Christian Fritz, his suggestion got me onto the right track. I hope this helps someone else.
I didn't realise that subscriptions should be controlled within the Container component, not in the page component. By using Session.set and Session.get I'm able to update the Container component which updates the subscription.
This works (if there is a better or more meteor way please post) and I hope this helps others if they come across a similar problem.
Path CompaniesContainer.js
export default UploadCSVFileContainer = withTracker(({ match }) => {
const limit = Session.get('limit');
const companiesHandle = Meteor.subscribe('companies', limit);
const companiesLoading = !companiesHandle.ready();
const companies = Companies.find().fetch();
const companiesExists = !companiesLoading && !!companies;
return {
companiesLoading,
companies,
companiesExists,
showCompanies: companiesExists ? Companies.find().fetch() : []
};
})(UploadCSVFilePage);
Path: CompaniesPage.jsx
export default class CompaniesPage extends Component {
constructor(props) {
super(props);
this.handleButtonAllCompanies = this.handleButtonAllCompanies.bind(this);
this.handleButtonTop100 = this.handleButtonTop100.bind(this);
}
handleButtonAllCompanies(event) {
event.preventDefault();
// mongodb limit value of 0 is equivalent to setting no limit
Session.set('limit', 0)
}
handleButtonTop100(event) {
event.preventDefault();
Session.set('limit', 100)
}
render() {
return (
<div>
<Button color="primary" onClick={this.handleButtonAllCompanies}>All</Button>
<Button color="primary" onClick={this.handleButtonTop100}>Top 100</Button>
</div>
);
}
}
Path: Publish.js
Meteor.publish('companies', function() {
if (limit || limit === 0) {
return Companies.find({}, { limit: limit });
}
});
Path CompaniesContainer.js
export default CompaniesContainer = withTracker(() => {
let companiesHandle; // or fire subscribe here if you want the data to be loaded initially
const getCompanies = (limit) => {
companiesHandle = Meteor.subscribe('companies', limit);
}
return {
getCompanies,
companiesLoading: !companiesHandle.ready(),
companies: Companies.find().fetch(),
};
})(CompaniesPage);
Path: CompaniesPage.jsx
export default class CompaniesPage extends Component {
constructor(props) {
super(props);
this.handleButtonAllCompanies = this.handleButtonAllCompanies.bind(this);
this.handleButtonTop100 = this.handleButtonTop100.bind(this);
}
getCompanies(limit) {
this.props.getCompanies(limit);
}
handleButtonAllCompanies(event) {
event.preventDefault();
// mongodb limit value of 0 is equivalent to setting no limit
this.getCompanies(0);
}
handleButtonTop100(event) {
event.preventDefault();
this.getCompanies(100);
}
render() {
return (
<div>
<Button color="primary" onClick={this.handleButtonAllCompanies}>All</Button>
<Button color="primary" onClick={this.handleButtonTop100}>Top 100</Button>
</div>
);
}
}
Path: Publish.js
Meteor.publish('companies', function(limit) {
if (!limit) { limit = 0; }
return Companies.find({}, { limit: limit });
});
We have product detail page which contains multiple component in single page.
Product Component looks like:
class Product extends Component {
render() {
return (
<div>
<Searchbar/>
<Gallery/>
<Video/>
<Details/>
<Contact/>
<SimilarProd/>
<OtherProd/>
</div>
);
}
}
Here we have 3 APIs for
- Details
- Similar Product
- Other Products
Now from Detail API we need to set data to these components
<Gallery/>
<Video/>
<Details/>
<Contact/>
In which component we need to make a call to API and how to set data to other components. Lets say we need to assign a,b,c,d value to each component
componentWillMount(props) {
fetch('/deatail.json').then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Something went wrong ...');
}
})
.then(data => this.setState({ data, isLoading: false }))
.catch(error => this.setState({ error, isLoading: false }));
}
OR
Do we need to create separate api for each components?
Since it's three different components you need to make the call in the component where all the components meet. And pass down the state from the parent component to child components. If your app is dynamic then you should use "Redux" or "MobX" for state management. I personally advise you to use Redux
class ParentComponent extends React.PureComponent {
constructor (props) {
super(props);
this.state = {
gallery: '',
similarPdts: '',
otherPdts: ''
}
}
componentWillMount () {
//make api call and set data
}
render () {
//render your all components
}
}
The Product component is the best place to place your API call because it's the common ancestor for all the components that need that data.
I'd recommend that you move the actual call out of the component, and into a common place with all API calls.
Anyways, something like this is what you're looking for:
import React from "react";
import { render } from "react-dom";
import {
SearchBar,
Gallery,
Video,
Details,
Contact,
SimilarProd,
OtherProd
} from "./components/components";
class Product extends React.Component {
constructor(props) {
super(props);
// Set default values for state
this.state = {
data: {
a: 1,
b: 2,
c: 3,
d: 4
},
error: null,
isLoading: true
};
}
componentWillMount() {
this.loadData();
}
loadData() {
fetch('/detail.json')
.then(response => {
// if (response.ok) {
// return response.json();
// } else {
// throw new Error('Something went wrong ...');
// }
return Promise.resolve({
a: 5,
b: 6,
c: 7,
d: 8
});
})
.then(data => this.setState({ data, isLoading: false }))
.catch(error => this.setState({ error, isLoading: false }));
}
render() {
if (this.state.error) return <h1>Error</h1>;
if (this.state.isLoading) return <h1>Loading</h1>;
const data = this.state.data;
return (
<div>
<SearchBar/>
<Gallery a={data.a} b={data.b} c={data.c} d={data.d} />
<Video a={data.a} b={data.b} c={data.c} d={data.d} />
<Details a={data.a} b={data.b} c={data.c} d={data.d} />
<Contact a={data.a} b={data.b} c={data.c} d={data.d} />
<SimilarProd/>
<OtherProd/>
</div>
);
}
}
render(<Product />, document.getElementById("root"));
Working example here:
https://codesandbox.io/s/ymj07k6jrv
You API calls will be in the product component. Catering your need to best practices, I want to make sure that you are using an implementation of FLUX architecture for data flow. If not do visit phrontend
You should send you API calls in componentWillMount() having your state a loading indicator that will render a loader till the data is not fetched.
Each of your Components should be watching the state for their respective data. Let say you have a state like {loading:true, galleryData:{}, details:{}, simProducts:{}, otherProducts:{}}. In render the similar products component should render if it finds the respective data in state. What you have to do is to just update the state whenever you receive the data.
Here is the working code snippet:
ProductComponent:
import React from 'react';
import SampleStore from '/storepath/SampleStore';
export default class ParentComponent extends React.Component {
constructor (props) {
super(props);
this.state = {
loading:true,
}
}
componentWillMount () {
//Bind Store or network callback function
this.handleResponse = this.handleResponse
//API call here.
}
handleResponse(response){
// check Response Validity and update state
// if you have multiple APIs so you can have a API request identifier that will tell you which data to expect.
if(response.err){
//retry or show error message
}else{
this.state.loading = false;
//set data here in state either for similar products or other products and just call setState(this.state)
this.state.similarProducts = response.data.simProds;
this.setState(this.state);
}
}
render () {
return(
<div>
{this.state.loading} ? <LoaderComponent/> :
<div>
<Searchbar/>
<Gallery/>
<Video/>
<Details/>
<Contact/>
{this.state.similarProducts && <SimilarProd data={this.state.similarProducts}/>}
{this.state.otherProducts && <OtherProd data={this.state.otherProducts}/>}
</div>
</div>
);
}
}
Just keep on setting the data in the state as soon as you are receiving it and render you components should be state aware.
In which component we need to make a call to API and how to set data
to other components.
The API call should be made in the Product component as explained in the other answers.Now for setting up data considering you need to make 3 API calls(Details, Similar Product, Other Products) what you can do is execute the below logic in componentDidMount() :
var apiRequest1 = fetch('/detail.json').then((response) => {
this.setState({detailData: response.json()})
return response.json();
});
var apiRequest2 = fetch('/similarProduct.json').then((response) => { //The endpoint I am just faking it
this.setState({similarProductData: response.json()})
return response.json();
});
var apiRequest3 = fetch('/otherProduct.json').then((response) => { //Same here
this.setState({otherProductData: response.json()})
return response.json();
});
Promise.all([apiRequest1,apiRequest2, apiRequest3]).then((data) => {
console.log(data) //It will be an array of response
//You can set the state here too.
});
Another shorter way will be:
const urls = ['details.json', 'similarProducts.json', 'otherProducts.json'];
// separate function to make code more clear
const grabContent = url => fetch(url).then(res => res.json())
Promise.all(urls.map(grabContent)).then((response) => {
this.setState({detailData: response[0]})
this.setState({similarProductData: response[1]})
this.setState({otherProductData: response[2]})
});
And then in your Product render() funtion you can pass the API data as
class Product extends Component {
render() {
return (
<div>
<Searchbar/>
<Gallery/>
<Video/>
<Details details={this.state.detailData}/>
<Contact/>
<SimilarProd similar={this.state.similarProductData}/>
<OtherProd other={this.state.otherProductData}/>
</div>
);
}
}
And in the respective component you can access the data as :
this.props.details //Considering in details component.
In my OnEnter callback, I am making an api call to fetch more data. The data fetched is added to the array of elements I have. When I scroll back up, the api call is triggered again as the component is back in the viewport.
Is there a way to get around this?
react waypoint
code sample:
Okay, let me clean that up a little bit: #jmeas
fetchData(props){ function to call the api with server side pagination
if(props.previousPosition != 'above') { //an attempt to check if we passed that waypoint, do not call the api again
this.setState({page: this.state.page + 1}, function () { //increment the page number
this.getCatalogItems(this.state.categoryId, this.state.page) //make api call with incremented page number
.then((res) => {
console.log("fetched more data with scroll", res) //results
})
})
}else{
console.log("not calling fetch data")
}
}
This is how I am calling the waypoint:
class ProductContainer extends React.Component {
constructor(props) {
super(props);
console.log("catalog product container initialized", this.props);
}
render() {
const {catalogProducts, getCatalogItems, renderWaypoint} = this.props;
console.log("props in roduct container", this.props)
return (
<div className="products-container">
{
catalogProducts && catalogProducts['products'] ?
catalogProducts['products'].map((product) => {
return (
<span>
HIIIIIIIIIIIIIIIIIIIIIIIIIII
<CatalogProduct product={product}/>
</span>
)
})
:
false
}
{renderWaypoint()}
########################################################################### way point here ################################################
</div>
);
}
}
ProductContainer.propTypes = {
catalogProducts: React.PropTypes.any.isRequired,
getCatalogItems: React.PropTypes.func.isRequired,
renderWaypoint: React.PropTypes.func.isRequired
};
export default ProductContainer;
What I want to do:
I have an infinite scroll catalog page. I wish to make the api call when user has scrolled down to the waypoint which as in the component above is after we have rendered the products returned from first api call and would like to make another round trip to the server and render
Without reviewing any code of your code... It seems as though you could just create flag and set it to true once the data has been loaded, then just check that flag before you load the data. Perhaps not the most elegant way, but it'll work.
class SomeClass extends React.Component {
constructor(props) {
super(props);
this.data = [];
this.fetched = [];
this._loadPageContent = this._loadPageContent.bind(this);
}
render(){
return (
<Waypoint
key={cursor}
onEnter={this._loadPageContent(this.props.pageId)}
/>
);
}
_loadPageContent(i) {
if(this.fetched.indexOf(i) <= -1){
//go get the data
this.data.push(someApiCall());
this.fetched.push(i);
}
}
}
export default SomeClass;