I am pulling data from Themoviedb and right now you can only retrieve 20 results per page. I'm building a movie-rating application where the user can rate movies as they appear on the screen. One movie at a time is shown. When the user gets through the first 20 movies from Page 1, I want to automatically run a new request for Page 2 to get the next list of movies. I don't want the user to have to click a button to load more movies, or have a "Next Page" button or anything like that. Everything should be on the same page and load automatically.
Right now I am making the request in ComponentDidMount(), loading the list of movies into an array and storing that in my state, and I am clicking through the movies by keeping track of the array index. When I reach the end of the array, that's when I want to make another request for Page 2. This is where I'm having issues. My initial request is in ComponentDidMount(), so I'm not too sure how to handle a second request. Is there any way I can trigger this without a button click? I want something like when the array of the first 20 results is empty, request results from the next page. Would it be bad practice to call ComponentDidMount() again once array.length === 0?
My state:
state = {
currentMovieIndex: 0,
filteredMovieList: [],
currentPage: 1,
}
My request:
getAllMovies(page) {
return fetch(`https://api.themoviedb.org/3/discover/movie?api_key=2bb6427016a1701f4d730bde6d366c84&page=${page}`)
.then(res =>
(!res.ok)
? res.json().then(e => Promise.reject(e))
: res.json())
},
componentDidMount() {
Promise.all([
MovieService.getMyMovies(),
MovieService.getAllMovies(this.state.currentPage)
]).then(([arr1, arr2]) => {
// filter out movies that the user has already rated
let myMovieIds = [];
arr1.map(movie => {
myMovieIds.push(movie.id);
})
let filteredMovies = arr2.results.filter(val => !myMovieIds.includes(val.id))
this.setState({
filteredMovieList: filteredMovies,
});
})
}
If you want to implement a pagination logic without a "Next Page" button or another click trigger I think you should go and search about the "Cursor pagination" logic.
You should get the scroll event and calculate the position and call for more or next data when the scroll reaches a specific position.
(Sry, didn't found any good example!)
Related
I have a master page that is a list of items, and a details page where I fetch and can update an Item. I have the following hooks based upon the react-query library:
const useItems = (options) => useQuery(["item"], api.fetchItems(options)); // used by master page
const useItem = id => useQuery(["item", id], () => api.fetchItem(id)); // used by details page
const useUpdateItem = () => {
const queryClient = useQueryClient();
return useMutation(item => api.updateItem(item), {
onSuccess: ({id}) => {
queryClient.invalidateQueries(["item"]);
queryClient.invalidateQueries(["item", id]);
}
});
};
The UpdatePage component has a form component that takes a defaultValue and loads that into it's local "draft" state - so it's sort of "uncontrolled" in that respect, I don't hoist the draft state.
// UpdatePage
const query = useItem(id);
const mutation = useUpdateItem();
return (
{query.isSuccess &&
!query.isLoading &&
<ItemForm defaultValue={query.data} onSubmit={mutation.mutate} />
}
);
The problem is after I update, go to Master page, then back to Details page, the "defaultValue" gets the old item before the query completes. I do see it hitting the API in the network and the new value coming back but it's too late. How do I only show the ItemForm after the data is re-queried? Or is there a better pattern?
My updateItem API function returns the single updated item from the server.
I used setQueryData to solve this.
const useUpdateItem = () => {
const queryClient = useQueryClient();
// Note - api.updateItem is return the single updated item from the server
return useMutation(item => api.updateItem(item), {
onSuccess: data => {
const { id } = data;
// set the single item query
queryClient.setQueryData('item', id], data);
// set the item, in the all items query
queryClient.setQueryData(
['item'],
// loop through old. if this item replace, otherwise, don't
old => {
return old && old.map(d => (d.id === id ? data : d));
}
);
}
});
};
I will say, react-query is picky about the key even if it is fuzzy. Originally my id was from the url search params and a string, but the item coming back from the db an int, so it didn't match. So a little gotcha there.
Also, when I go back to the Master list page, I see the item change, which is kind of weird to me coming from redux. I would have thought it was changed as soon as I fired the synchronous setQueryData. Because I'm using react-router the "pages" are complete remounted so not sure why it would load the old query data then change it.
isLoading will only be true when the query is in a hard loading state where it has no data. Otherwise, it will give you the stale data while making a background refetch. This is on purpose for most cases (stale-while-revalidate). Your data stays in the cache for 5 minutes after your detail view unmounts because that’s the default cacheTime.
Easiest fix would just set that to 0 so that you don’t keep that data around.
You could also react to the isFetching flag, but this one will always be true when a request goes out, so also for window focus refetching for example.
Side note: invalidateQueries is fuzzy per default, so this would invalidate the list and detail view alike:
queryClient.invalidateQueries(["item"])
I had the same issue today. After scanning your code it could be the same issue.
const useItem = id => useQuery(["item", id], () => api.fetchItem(id)); // used by details page
The name of the query should be unique. But based on you details the ID changes depends on the item. By that you call the query "item" with different IDs. There for you will get the cached data back if you have done the first request.
The solution in my case was to write the query name like this:
[`item-${id}`...]
I have a Gatsby and Strapi photo blog and I want to have a home page that loads 10 pictures at a time until the user hits the bottom of the page, then load the next ten etc, so that the user isn't downloading all photos at once.
I'm using useStaticQuery to load the images initially. However, that can only run at build time. Is there a way to make another graphQL call when the user hits the bottom of the page and add it to my state? Or is this the "static" part of a static site generator 😄.
Alternatively, does making the graphQL call for all photo data make its way to the client device if I don't render it? Say if I just use React to render parts of the array at a time?
Below is my home page. I'm using Karl Run's bottom scroll listener, and the Photos component renders the photos as a list.
const IndexLayout: React.FC<Props> = () => {
const [photosData, setPhotosData] = useState<InitialQueryType['allStrapiPhoto']>(getSiteMetaDataAndTenPhotos().allStrapiPhoto)
const handleOnDocumentBottom = useCallback(() => {
console.log('at bottom, make a call')
// this throws an invalid hook error as getTenPhotos calls useStaticQuery
let morePhotosData = getTenPhotos(photosData.edges.length)
setPhotosData({ ...photosData, ...morePhotosData })
}, [photosData])
useBottomScrollListener(handleOnDocumentBottom)
return (
<LayoutRoot>
<div className={styles.bigHomeContainer}>
<div className={styles.titleContainer}>
<h1 className={styles.indexHeading}>TayloredToTaylor's Wildlife Photos</h1>
</div>
<Photos photos={photosData} />
</div>
</LayoutRoot>
)
}
export default IndexLayout
Github Repo
As you said, queries are called in the build-time. However, one workaround that may work for you is to retrieve all photos at the beginning (build-time) and show them on-demand in groups of 10 triggered by time or by the user's scroll, etc. Adapting something like this to your use-case should work:
Fetch all photos with:
const allPhotos=<InitialQueryType['allStrapiPhoto']>(getSiteMetaDataAndTenPhotos().allStrapiPhoto)
Set the current currentIndex:
const [currentIndex, setCurrentIndex]= useState(0);
And ask them on-demand:
const morePhotosData = ()=>{
let cloneOfAllPhotos= [...allPhotos];
let newPhotos= cloneOfAllPhotos.splice(currentIndex, currentIndex + 10) // change 10 to your desired value
setPhotosData(newPhotos);
setCurrentIndex(currentIndex+10);
}
Basically, you are cloning the allPhotos (let cloneOfAllPhotos= [...allPhotos]) to manipulate that copy and splicing them with a dynamic index (cloneOfAllPhotos.splice(currentIndex,currentIndex + 10)) that is increasing by 10 in each trigger of morePhotosData.
I have a simple collection which I would like to paginate.
I want to sort it and paginate it by a timestamp doc called createdAt.
This is how the call currently looks like:
function getPaginatedItems (db, startAfter) {
return db
.collection('items')
.orderBy('createdAt')
.startAfter(startAfter) // startAfter parameter will be a createdAt Timestamp doc
.limit(3)
.get()
}
To make this easier to work with and display, I created a function that will turn this query snapshot into a paginated object. This looks something like this:
function querySnapshotToPaginatedObject (querySnapshot, total, limit = 3) { if (querySnapshot.empty) {
return {
total: 0,
limit,
data: []
}
} else {
return {
total,
limit,
data: querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}))
}
}
}
As it stands I have a total of 11 items in my firestore, but would like to get them in chunks of three. This all works perfectly when moving forward with the data, however my question then becomes, how do I go back? That is, how do I get data from the previous pages?
Currently what I have in my hands is the total number of items I have, the limit which can be displayed and obviously the three items I am currently displaying.
I have no idea how to keep track of all other ones in order to jump back pages, or jump more than one page for that matter.
So I guess there are two questions here: how do I go back to previous data? And how could I jump different chunks of data?
Is there another way to do this, perhaps by index instead of a specific doc (like I am doing with createdAt)?
Edit: I was asked how I am making my next queries. Basically I have buttons (all with their page numbers) and when I click on them, I do a second call starting with the createdAt attribute of the last item. I then do a second call to my initial query, but passing in the last object as the startAfter parameter in the getPaginatedItems function call.
I am using react as the front-end, so it looks something like this:
getNextBatch (startAfter) {
return {
paginatedItems: querySnapshotToPaginatedObject(
await getPaginatedItems(db, startAfter), 11, 3
)
}
}
...
export default class MyComponent extends React.Component (
render () {
return (
<div>
{this.props.paginatedItem.map(x => <div>{x.name} {x.createdAt}</div>)}
<button onClick={(evt) => console.warn('How do I go back???')}>
Back
</button>
<button onClick={(evt) => getNextBatch(paginatedItem[paginatedItem - 1].createdAt)}>
Next
</button>
</div>
)
}
)
Keep in mind that the component does re-render every time I click the buttons.
Cloud Firestore APIs don't provide a way to page backward. The easiest thing to do in your case is to remember a list of startAfter values that you've used to fetch each page. Each value will represent a page of data. With that, going back to a previous page is just a matter of find the desired startAfter value from that list, then making the query with that.
To be honest, though, your total data set is pretty small, and it's probably not worth paging at all. I'd just get the whole thing and keep it in memory. Paging probably don't become worthwhile until you reach hundreds or thousands of documents (depending on how big each document is, of course, and how much memory you expect to have available).
In my Angular 2 app I am subscribing to an observable in the ngOnInit life cycle of my component in order to print a series of records to a table display. This is working as expected:
ngOnInit() {
this.filtersService.getRecords(this.page, this.pagesize, this.body)
.subscribe(resRecordsData => {
this.records = resRecordsData;
},
responseRecordsError => this.errorMsg = responseRecordsError
);
}
I also have some filters on this component. So once a user opens the component, they can further filter the data. The data filters as expected, and will apply filters even as the user paginates through various pages of results (I am loading 12 results per page). I am filtering via a post request, which I'm passing to the API via "this.body":
getRecords(page, pagesize, body) {
return this.api.obsPost(this.strReq, page, pagesize, body);
}
This works while I'm paginating (i.e. the chosen filters continue to filter the data) because the same component is still mounted, I'm just passing in "page":
private pageChange(page) {
this.filtersService.getRecords(page, this.pagesize, this.body)
.subscribe(resRecordsData => {
this.records = resRecordsData;
},
responseRecordsError => this.errorMsg = responseRecordsError
);
}
The problem I'm running into is, if the user then opens another component, and then, say 30 seconds later, clicks on the tab pertaining to the first component again (because the tab sits there as a background tab in the view), it will re-mount the component, effectively wiping out any of the filters that were applied (as well as any pagination they may have used).
In other words, if they had applied one filter, and moved to page 5 of results in component 1, when they click on the tab again (after having been on a different component/view), the component will re-mount and therefore load with zero filters applied and it will be back to page 1 of results.
I'd like to find a way to somehow cache the state of the first component so that when they click that tab for component 1 again (because it sits there as a background tab while on a different component), they will get the results as they last saw them (i.e., per my example, with one filter applied and on page 5 of results).
How would I go about doing this? Also, because I wouldn't want this to maintain these filtered results perpetually, I assume it'd make sense to do this in session storage. Is there a fairly straightforward way to do this?
Summary: New to ReactJS and I'm trying to figure out the best way to update a component when it's state depends on a remote API (i.e. keep component state in sync with remote database via AJAX API).
Example Use Case: Think of a product inventory where clicking a button adds a product to your cart and decrements the inventory by 1. Every time the user clicks it initiates an AJAX request and then upon completion of the request, the component re-renders with the new product inventory by calling setState().
Problem: I've ran into an issue where because both setState() and the AJAX request are asynchronous, the component becomes out of the sync with the server. For example if you click really quickly you can initiate more than one AJAX request for a single product ID because the component's state has not yet updated to reflect that the product ID is no longer in inventory. I have a simple example below to illustrate the concept:
Inadequate Solution: This could be handled on the server side by sending an error back if the client request a product that is no longer in inventory, however I'm really looking for the best way to handle this common scenario in ReactJS on the client side and to make sure I'm understanding the best way to handle component state.
Component extends React.Component {
constructor(props) {
super(props);
this.state = {
clicksLeft: 0,
};
}
componentDidMount() {
//getClicksLeft is async and takes a callback, think axios/superagent
getClicksLeft((response) => {
this.setState(response);
});
}
btnClicked = () => {
//This may appear redundant/useless but
//imagine sending an element in a list and then requesting the updated
//list back
const data = {clicks: this.state.clicksLeft--};
decrementClicksLeft(data, () => {
getClicksLeft((response) => {
this.setState(response);
});
}
}
render() {
<button onClick={this.btnClicked}>Click me {this.state.clicksLeft} times</button>
}
}
Is there any reason to have to call getClicksLeft when the button is clicked? You have already called it when the component is mounted and then anytime the button is clicked you just decrement that number by one.
btnClicked = () => {
if (this.state.clicksLeft > 0) {
decrementClicksLeft();
this.setState({clicksLeft: this.state.clicksLeft - 1});
}
}
This would work if there is only one user trying to buy stuff at a time. Otherwise you could also check the amount left before making the purchase.
btnClicked = () => {
getClicksLeft((response) => {
if (response > 0) {
decrementClicksLeft();
this.setState({clicksLeft: this.state.clicksLeft - 1});
}
});
}
This way if there are no clicks left, nothing happens.
The most basic solution would be to disable the button while you wait for the response to come back:
(I've also made your code simpler.)
Component extends React.Component {
constructor(props) {
super(props);
// Initial state
this.state = {
clicksLeft: 0, // No clicks are availabe
makeRequest: false, // We are not asking to make a request to the server
pendingTransaction: false, // There is no current request out to the server
};
}
componentDidMount() {
// Initial load completed, so go get the number of clicks
this._getClicksRemaining();
}
// Called whenever props/state change
// NOT called for the initial render
componentWillUpdate(nextProps, nextState) {
// If there is no existing request out to the server, AND if the next
// state is asking us to make a request (as set in _handleButtonClick)
// then go make the request
if (!this.state.pendingTransaction && nextState.makeRequest) {
const data = {
clicks: this.state.clicksLeft--,
};
// decrementClicksLeft is async
decrementClicksLeft(data, () => this._getClicksRemaining());
// First fire off the async decrementClicksLeft request above, then
// tell the component that there is a pending request out, and that it
// is not allowed to try and make new requests
// NOTE this is the one part of the code that is vulnerable to your
// initial problem, where in theory a user could click the button
// again before this setState completes. However, if your user is able
// to do that, then they are probably using a script and you shouldn't
// worry about them. setState/render is very fast, so this should be
// more than enough protection against human clicking
this.setState({
makeRequest: false,
pendingTransaction: true,
});
}
}
_getClicksRemaining() {
// getClicksLeft is async
getClicksLeft((response) => {
// Here we are inside of the callback from getClicksLeft, so we
// know that it has completed. So, reset our flags to show that
// there is no request still pending
const newState = Object.assign(
{
pendingTransaction: false,
},
response,
);
this.setState(newState);
});
}
// The button was clicked
_handleButtonClick = () => {
if (!this.state.pendingTransaction) {
// If there isn't a request out to the server currently, it's safe to
// make a new one. Setting state here will cause `componentWillUpdate`
// to get called
this.setState({
makeRequest: true,
});
}
}
render() {
// Disable the button if:
// * there are no clicks left
// * there is a pending request out to the server
const buttonDisabled = ((this.state.clicksLeft === 0) || this.state.pendingTransaction);
return (
<button
disabled={buttonDisabled}
onClick={this._handleButtonClick}
>
Click me {this.state.clicksLeft} times
</button>
);
}
}
After spending some time with react-redux, redux-thunk and redux-pack I decided to go with something simpler: react-refetch. I didn't really need the complexities of redux as I am only doing post and get operations on lists. I also need some simple side effects like when I do a post, I need to update multiple lists (which is achieved through andThen() in react-refetch).
This solution has much less boiler plate and works great for small projects. The core reason to choose this project over react-redux can be summarized in this quote from heroku's blog entry:
Looking around for alternatives, Redux was the Flux-like library du jour, and it did seem very promising. We loved how the React Redux bindings used pure functions to select state from the store and higher-order functions to inject and bind that state and actions into otherwise stateless components. We started to move down the path of standardizing on Redux, but there was something that felt wrong about loading and reducing data into the global store only to select it back out again. This pattern makes a lot of sense when an application is actually maintaining client-side state that needs to be shared between components or cached in the browser, but when components are just loading data from a server and rendering it, it can be overkill.
1: https://github.com/heroku/react-refetch
2: https://engineering.heroku.com/blogs/2015-12-16-react-refetch/