Wait until onSnapshot data is fetched Firestore Firebase Next JS - reactjs

On Click I want to fetch onSnapshot instance of data which startAfter current data. But When the request is sent for the 1st time it returns an empty array. I have to click twice to get new Data.
export default function Page() {
const [data, setData] = useState([])
const [newData, setNewData] = useState([])
const [loading, setLoading] = useState(false)
const [postsEnd, setPostsEnd] = useState(false)
const [postLimit, setPostLimit] = useState(25)
const [lastVisible, setLastVisible] = useState(null)
useEffect(() => {
const collectionRef = collection(firestore, "Page")
const queryResult = query(collectionRef, orderBy("CreatedOn", "desc"), limit(postLimit));
const unsubscribe = onSnapshot(queryResult, (querySnapshot) => {
setLastVisible(querySnapshot.docs[querySnapshot.docs.length-1])
setData(querySnapshot.docs.map(doc => ({
...doc.data(),
id: doc.id,
CreatedOn : doc.data().CreatedOn?.toDate(),
UpdatedOn : doc.data().UpdatedOn?.toDate()
}))
)
});
return unsubscribe;
}, [postLimit])
const ths = (
<tr>
<th>Title</th>
<th>Created</th>
<th>Updated</th>
</tr>
);
const fetchMore = async () => {
setLoading(true)
const collectionRef = collection(firestore, "Page")
const queryResult = query(collectionRef, orderBy("CreatedOn", "desc"), startAfter(lastVisible), limit(postLimit));
onSnapshot(await queryResult, (querySnapshot) => {
setLastVisible(querySnapshot.docs[querySnapshot.docs.length-1])
setNewData(querySnapshot.docs.map(doc => ({
...doc.data(),
id: doc.id,
CreatedOn : doc.data().CreatedOn?.toDate(),
UpdatedOn : doc.data().UpdatedOn?.toDate()
}))
)
});
//Commented If Statement cause data is not fetched completely
//if (newData < postLimit ) { setPostsEnd(true) }
setData(data.concat(newData))
setLoading(false)
console.log(newData)
}
return (
<>
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{
data.length > 0
? data.map((element) => (
<tr key={element.id}>
<td>{element.id}</td>
<td>{element.CreatedOn.toString()}</td>
<td>{element.UpdatedOn.toString()}</td>
</tr>
))
: <tr><td>Loading... / No Data to Display</td></tr>
}</tbody>
</Table>
{!postsEnd && !loading ? <Button fullWidth variant="outline" onClick={fetchMore}>Load More</Button> : <></>}
</>
)
}
Current Result
Expected Result

The problem seems to be rooted in React updating the state asynchronously. I saw the same behavior using your code in my Next.js project. When looking at it closely, the onSnapshot() function and your query are working as expected.
You cannot await onSnapshot() (code reference) since it's not an asynchronous function, so the async/await in fetchMore() does not have any effect. Regardless, React state update is asynchronous, so setData(data.concat(newData)) might run before newData is updated. By the second time you load more documents, newData will have the document data.
It looks like fetchMore() can be done without relying on a "newData" state, since it was only used to update the actual data and to hide the "Load More" button:
const fetchMore = () => {
setLoading(true)
const collectionRef = collection(firestore, "page")
const queryResult = query(collectionRef, orderBy("CreatedOn", "desc"), startAfter(lastVisible), limit(postLimit));
onSnapshot(queryResult, (querySnapshot) => {
setLastVisible(querySnapshot.docs[querySnapshot.docs.length - 1]);
//paginatedDocs simply holds the fetched documents
const paginatedDocs = querySnapshot.docs.map(doc => ({
...doc.data(),
id: doc.id,
CreatedOn: doc.data().CreatedOn?.toDate(),
UpdatedOn: doc.data().UpdatedOn?.toDate()
}));
//Replaced usage of newData here with the amount of documents in paginatedDocs
if (paginatedDocs.length < postLimit ) { setPostsEnd(true) }
//Updates the data used in the table directly, rebuilding the component
setData(data.concat(paginatedDocs));
});
setLoading(false);
}
With these changes, there is no problem updating the documents at the first try when clicking the button.

Do you have persistence on? It could be firing once with the local result and again with the server data.

Related

React updates after two clicks instead of one

I'm creating a sortable table, and my issue is that the sorting shows after TWO clicks on a column, instead of just after one click.
This is my sorting logic: (DATA is hardcoded)
export const useFetch = (order) => {
const [data, setData] = useState();
const orderBy = order?.by || 'offer_id';
const fromTo = order?.fromTo || SORTING_ORDER.ascending;
useEffect(() => {
if (!orderBy || !fromTo) return;
let orderedData = DATA.sort((a, b) => (a[orderBy] - b[orderBy]));
setData(orderedData);
}, [orderBy, fromTo]);
return { data, status };
};
And I'm using this hook like this, from the component that has that table.
export const AcceptedOffers = ({ setModalIsOpen }) => {
const [order, setOrder] = useState({ by: 'maturity', fromTo: SORTING_ORDER.ascending });
const { data, status } = useFetch(order);
function onHeaderClick(header) {
setOrder({ by: header, fromTo: SORTING_ORDER.descending });
}
return (
<WidgetContainer>
<Title>
Accepted Offers
</Title>
<Table>
<Header>
<tr>
{
Object.entries(HEADERS).map(([key, value]) =>
<th
key={key}
onClick={() => onHeaderClick(key)}
>
{value}
</th>)
}
</tr>
</Header>
<Body>
{
data?.map(row => (<tr key={row.offer_id}>
<td>{row.offer_id}</td>
etc...
Can anyone explain what's wrong with this. Thank you.
The side effect represented by your useEffectis executed after the render triggered by the click: the data are rendered first, then sorted. That's where your "delay" commes from.
Here is an other solution. It may suit you, or not: the purpose is to show an alternative implementation to trigger the sort when order is modified, but without useEffect. It works by "overloading" setOrder:
export const useFetch = (initialOrder) => {
// useReducer may be a better choice here,
// to store order and data with a single state
// (and update this state through a single call)
const [order, setOrder] = useState(initialOrder);
const [data, setData] = useState();
const publicSetOrder = (newOrder) => {
setOrder(newOrder);
const orderBy = newOrder?.by || 'offer_id';
const fromTo = newOrder?.fromTo || SORTING_ORDER.ascending;
if (!orderBy || !fromTo) return;
let orderedData = DATA.sort((a, b) => (a[orderBy] - b[orderBy]));
setData(orderedData);
};
return { order, setOrder: publicSetOrder, data, status };
};
export const AcceptedOffers = ({ setModalIsOpen }) => {
const { order, setOrder, data, status } = useFetch({ by: 'maturity', fromTo: SORTING_ORDER.ascending });
function onHeaderClick(header) {
setOrder({ by: header, fromTo: SORTING_ORDER.descending });
}
// ...
Feel free to adapt to your use case ;)

updating data in useState constant | React

I'm trying to make "load more" pagination in react app fetching data from google books api via axios.
Firstly I'm grabbing data by submitting the form and setting it into the bookResponse const and then putting the value of bookResponse const into the booksArray const by setBooksArray(bookReponse).
Secondly I need to show more book's by clicking the 'Load More' button. By click I make a new request to the api and receive the response. I'm updating the bookResponse const with new data and trying to update the booksArray const by setBooksArray(booksArray + bookResponce) but it returns errror "books.map is not a function". How can i solve the problem?
{books && books.map(book => (
<div key={book.id}>
<img alt="book's thumbnail" src={book.volumeInfo.imageLinks.thumbnail} />
<div>
<h5>{book.volumeInfo.title}</h5>
<p>{book.volumeInfo.authors}</p>
<p>{book.volumeInfo.categories}</p>
</div>
</div>
))
}
function App() {
const [bookName, setBookName] = useState('')
const [bookCategory, setBookCategory] = useState('all')
const [bookFilter, setBookFilter] = useState('relevance')
const [bookResponse, setBookResponse] = useState([])
const [booksArray, setBooksArray] = useState([])
const apiKey = ('MY_KEY')
const [showLoadMoreButton, setShowLoadMoreButton] = useState(false)
const handleSubmit = (e) => {
e.preventDefault()
console.log(bookName, bookCategory, bookFilter)
axios.get(endpoint)
.then(res => {
console.log(res.data.items)
setBookResponse(res.data.items)
setBooksArray(bookResponse)
console.log(bookResponse ,booksArray)
})
setShowLoadMoreButton(true)
}
const handleLoadMore = (e) => {
e.preventDefault()
axios.get(endpoint)
.then(res => {
console.log(res.data.items)
console.log(bookResponse)
setBookResponse(bookResponse)
setBooksArray(booksArray + bookResponse)
})
}
You don't + to add 2 arrays, you can do something like this.
setBooksArray( prev => [ ...prev, ...bookResponse ] )
....
return (
....
{ booksArray.map(book => ( .....) }
.....
)
if booksArray and bookResponse types are array you can use this:
setBooksArray([...booksArray,...bookResponse])
When you do booksArray + bookResponse you are creating a string,
example would be
const arr1 = [1,2,3]
const arr2 = [4,5,6]
console.log(arr1 + arr2)
"1,2,34,5,6"
The solution would be to spread both of the arrays like this:
setArray([...arr1, ...arr2])

React useEffect not running after state change

I am querying Firebase real time database, saving the result into state and then rendering the results.
My data is not displaying because the page is rendering before the data is had. What I don't understand is why
useEffect(() => {
for (var key in projects) {
var projectData = {
title: projects[key].title,
description: projects[key].description,
};
result.push(<Project props={projectData} />);
}
}, [projects]);
My use effect is not running once the projects state change is triggered, populating the array and triggering the conditional render line.
What am I missing here?
const [projects, setProjects] = useState();
const { user } = useUserAuth();
const result = [];
const dbRef = ref(db, `/${user.uid}/projects/`);
useEffect(() => {
onValue(dbRef, (snapshot) => {
setProjects(snapshot.val());
});
}, []);
useEffect(() => {
for (var key in projects) {
var projectData = {
title: projects[key].title,
description: projects[key].description,
};
result.push(<Project props={projectData} />);
}
}, [projects]);
return (
<>
{result.length > 0 && result}
</>
);
};
result should also be a state!
Right now at every rerender result is being set to []. So when the useEffect does kick in, the subsequent rerender would set result to [] again.
This should not be a useEffect. Effects run after rendering, but you're trying to put <Project> elements on the page, which must happen during rendering. Simply do it in the body of your component:
const [projects, setProjects] = useState();
const { user } = useUserAuth();
const dbRef = ref(db, `/${user.uid}/projects/`);
useEffect(() => {
onValue(dbRef, (snapshot) => {
setProjects(snapshot.val());
});
}, []);
const result = [];
for (var key in projects) {
var projectData = {
title: projects[key].title,
description: projects[key].description,
};
result.push(<Project props={projectData} />);
}
return (
<>
{result.length > 0 && result}
</>
);
result.push does not mutate result in place. It instead creates a copy of the array with the new value.
As a solution, you could get your current code working by hoisting result into a state variable like so:
const [result, setResult] useState([])
...
useEffect(() => {
for (var key in projects) {
...
setResult([...result, <Project props={projectData} />])
}
}, [result, projects]);
however, this solution would result in an infinite loop...
My suggestion would be to rework some of the logic and use projects to render your Project components, instead of creating a variable to encapsulate your render Components. Something like this:
const [projects, setProjects] = useState();
const { user } = useUserAuth();
const dbRef = ref(db, `/${user.uid}/projects/`);
useEffect(() => {
onValue(dbRef, (snapshot) => {
setProjects(snapshot.val());
});
}, []);
return (
<>
{projects.length > 0 && projects.map(project=>{
var projectData = {
title: projects[key].title,
description: projects[key].description,
};
return <Project props={projectData} />
})}
</>
);
};
You're component is not re-rendering since react doesn't care about your result variable being filled.
Set it up as a state like this: const [result, setResult] = useState([]);
Then use map to return each item of the array as the desire component:
{result.length > 0 && result.map((data, index) => <Project key={index} props={data} />)}

How can I reinitialize a React hook

I have a page where I display the data from the API based on an id. I am using React Query to manage the storage of the data. What I am trying to do is when the input with the id is changed I'd like to refetch the data for a different object. I tried to do the following:
const useData = (id: string) => useQuery(
['data', id],
() => axios.get(`/api/data/${id}`),
{
enabled: !!id,
},
);
const Page = () => {
const [id, setID] = useState('1');
const [result, setResult] = useState(useData(id));
useEffect(() => {
setResult(useData(id));
}, [id]);
return (
<div>
{result.data}
<input onChange={(e) => setID(e.target.value)} />
</div>
);
};
But you cannot call hooks inside the useEffect callback. What would be the correct approach for me to reset the result with the data from a new API call?
react-query will automatically refetch if parts of the query key change. So you are on the right track regarding your custom hook, and for your App, it also becomes much simpler:
const useData = (id: string) => useQuery(
['data', id],
() => axios.get(`/api/data/${id}`),
{
enabled: !!id,
},
);
const Page = () => {
const [id, setID] = useState('1');
const result = useData(id);
return (
<div>
{result.data}
<input onChange={(e) => setID(e.target.value)} />
</div>
);
};
that's it. that's all you need.
if id changes, the query key changes, thus giving you a new cache entry, which react-query will fetch for you.

Can't Update State After Deleting Item from Backend with Axios and React

Working on a practice phonebook project where the visitor can enter a name and phone number. Utilizing json-server for the backend and React for front end.
The full code is here Phonebook Github Code
The functionality of adding a number works fine, but I'm having issues with a button which allows the visitor to delete a number. When a user clicks on the 'delete' button, it is successfully removed from the backend (file is db.json). However on the frontend, the deleted number isn't removed, and I can see that the state isn't changing.
Any help is appreciated.
Here's my delete function for removing the number from backend
const deletePerson = id => {
const request = axios.delete(baseUrl + `/` + id);
return request.then(response => response.data);
};
and this function is being called from a button onClick method
const deleteNum = event => {
let personID = event.target.value;
if (window.confirm("Do you really want to delete?")) {
personService
.deletePerson(personID)
.then(() => {
setPersons(persons.filter(item => item.id !== personID));
})
.catch(error => {
console.log("Error", error);
});
}
};
and the rest of the relevant code to give this context
const App = () => {
const [persons, setPersons] = useState([]);
const [newName, setNewName] = useState("");
const [newNumber, setNewNumber] = useState("");
const [filter, setFiltered] = useState("");
useEffect(() => {
personService.getAll().then(initialPersons => setPersons(initialPersons));
}, []);
console.log("Persons", persons);
const peopleToShow =
filter === ""
? persons
: persons.filter(person =>
person.name.toLowerCase().includes(filter.toLowerCase())
);
const rows = () =>
peopleToShow.map(p => (
<p key={p.name}>
{p.name} {p.number}{" "}
<span>
<button value={p.id} onClick={deleteNum}>
delete
</button>
</span>
</p>
));
item.id is stored as a number, whereas the personID is taken as a string. Hence, try changing !== to !=.

Resources