Building a site where users upload images/video, in the component that handles that, I have the images load into the page after being uploaded, so that the user can make sure they want to post them, and have the option to remove them if they like. I originally had this as a Class-based view, and everything worked as expected, but now, after changing to a functional component, after uploading images, React doesn't seem to notice the changes to imageLinks (even though the console shows that imageLinks is getting added to), until I update something else like the post title, then they all load in as expected. Once loaded in, if I click the delete button, React instantly updates and the photos/videos no longer shows up, as expected.
Any ideas as to why this is behaving so oddly?
The fact that the deleteMedia function works just fine is what really weirds me out.
I set up my variables like this
export default function NewPost(props) {
const [postCategories, setPostCategories] = useState([]);
const [postTitle, setPostTitle] = useState();
const [postDescription, setPostDescription] = useState();
const [postHashtags, setPostHashtags] = useState();
const [imageLinks, setImageLinks] = useState([]);
...}
In my component, I have this to handle uploading and deleting files.
const uploadMedia = async (file) => {
var csrftoken = getCookie("csrftoken");
var media_id = makeMediaID();
let formData = new FormData();
formData.append("content_id", contentID);
formData.append("creator_id", USER_ADDRESS);
formData.append("content_media", file);
formData.append("media_id", media_id);
const response = await fetch("/api/newpost/media/", {
method: "POST",
headers: {
"X-CSRFToken": csrftoken,
},
body: formData,
});
console.log(response);
const r_json = await response.json();
if (r_json.success) {
let tempLinks = imageLinks;
tempLinks.push({
img: r_json.image_url,
id: r_json.m_id,
type: r_json.m_type,
});
setImageLinks(tempLinks);
console.log(imageLinks);
} else {
console.log(r_json.message);
}
};
const deleteMedia = async (media) => {
var csrftoken = getCookie("csrftoken");
let formData = new FormData();
formData.append("media_id", media);
formData.append("creator_id", USER_ADDRESS);
const response = await fetch("/api/newpost/media/delete/", {
method: "DELETE",
headers: {
"X-CSRFToken": csrftoken,
},
body: formData,
});
console.log(response);
const r_json = await response.json();
if (r_json.success) {
let tempLinks = imageLinks.filter((item) => item.id !== media);
setImageLinks(tempLinks);
} else {
console.log("Media deletion error");
}
};
And in my render, I have this, which worked just fine when it was a class-based component.
{imageLinks.map((item) => (
<Grid item xs={9} align="center" key={item.img}>
<Card style={{ maxWidth: 550, margin: 15 }}>
<div
style={{
display: "flex",
alignItem: "center",
justifyContent: "center",
}}
>
<CardMedia
style={{
width: "100%",
maxHeight: "550px",
}}
component={item.type}
image={item.img}
controls
title={String(item.img)}
/>
</div>
<div align="center">
<CardActions>
<Button
endIcon={<DeleteForeverIcon />}
label="DELETE"
color="secondary"
variant="contained"
onClick={() => deleteMedia(item.id)}
>
REMOVE
</Button>
</CardActions>
</div>
</Card>
</Grid>
))}
Issue
The issue is a state mutation.
const [imageLinks, setImageLinks] = useState([]);
tempLinks is a reference to the imageLinks state. You are pushing directly into this tempLinks array and saving the same array reference back into state. The code is also incorrectly attempting to log the state immediately after enqueueing the state update. This doesn't work as React state updates are asynchronously processed.
if (r_json.success) {
let tempLinks = imageLinks; // <-- reference to state
tempLinks.push({ // <-- state mutation!
img: r_json.image_url,
id: r_json.m_id,
type: r_json.m_type,
});
setImageLinks(tempLinks); // <-- same reference back into state
console.log(imageLinks); // <-- only logs current state
}
The imageLinks state array reference never changes so React doesn't see this as state actually being updated.
Solution
Create and return a new array reference for React.
if (r_json.success) {
setImageLinks(imageLinks => [ // <-- new array reference
...imageLinks, // <-- shallow copy previous state
{ // <-- append new element object
img: r_json.image_url,
id: r_json.m_id,
type: r_json.m_type,
}
]);
}
Use a separate useEffect hook to console log the state updates.
useEffect(() => {
console.log(imageLinks);
}, [imageLinks]);
Related
running into an issue with ISR and SWR on a dynamic page when updating content.
I have a page under product/[slug], that uses staticProps and Paths to generate the pages/routes
export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = params!.slug;
const products = await request(baseUrl, productsBySlug, {
slug: slug,
});
return {
props: {
products: products,
},
revalidate: 10,
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const products = await request(baseUrl, allProductsQuery);
const paths = products.products.map((product: Product) => ({
params: { slug: product.slug },
}));
return { paths, fallback: true };
};
now, when I change the title of a product inside of the graphql-playground the slug is automatically updated (this is in laravel).
The problem here is that, the url won't change to reflect the potential slug update e.g.
my current url is product/test-product.
someone updates the name of the product to Updated product which is transformed to a slug which means the url is now product/updated-product.
But because the paths are statically generated next cannot find this and it gives an error when fetching.
I'm also using SWR to fetch data periodically and to keep it synced but this also seems to not work (unless i'm doing something wrong).
again on the product/[slug] page I have SWR running to update the data in real time for the current user, while using ISR to update it for any subsequent requests
const variables = {
slug: router.query.slug,
};
const { data, error } = useSWR([productsBySlug, variables], {
fallbackData: products,
revalidateOnMount: false,
});
if (router.isFallback) {
return (
<Box
sx={{
mx: "auto",
}}
>
<Skeleton animation="wave" variant="circular" width={40} height={40} />
</Box>
);
}
if (!error && data) {
return (
<Box
sx={{
mx: "auto",
}}
>
<Head>
<title>Cross-poc | {data.productBySlug.slug}</title>
</Head>
<Button variant="outlined">
<Link
href={`edit/${encodeURIComponent(
data.productBySlug.id
)}/${encodeURIComponent(data.productBySlug.slug)}`}
>
Edit this product
</Link>
</Button>
<ProductComponent
showButton={false}
key={data.productBySlug.id}
product={data.productBySlug}
/>
</Box>
);
}
return <Alert severity="error">Could not fetch product info</Alert>;
If I'm on the product page when it's updated I alway get the Alert popup to display.
Is their anyway I can make URL's change dynamically with next as well?
I'm working on an app with a login page and a main page. In my main page, I have a component A that accepts a request and posts it to a nodejs server, which updates it on a database. Another component B fetches data from the database and displays it. I want to be able to rerender component B when component A's request has been returned with a response.
I understand that I can create a dummy state variable and pass it to B and pass a prop function to A to allow it to change it, but is there a more effective way of doing this?
Code for A's get request:
const onSubmit = async (event) => {
event.preventDefault();
if (vendor === "" || amount === "") {
} else {
const res = await axios.post(`http://localhost:4000/newpayment`, {
vendor,
amount,
});
// want to check res and rerender component B
}
setVendor("");
setAmount("");
};
Code for component B:
import React, { useState, useEffect } from "react";
import axios from "axios";
const ComponentB= () => {
const [payments, setPayments] = useState([]);
const fetchPayments = async () => {
const res = await axios.get(`http://localhost:4000/paymentspending`);
setPayments(res.data);
};
useEffect(() => {
fetchPayments();
}, []);
const onClick = async (id) => {
await axios.post(`http://localhost:4000/markpaid`, { id });
fetchPayments();
};
const renderedPayments = Object.values(payments).map((payment) => {
return (
<div
className="card"
style={{ width: "100%", marginBottom: "20px" }}
key={payment.id}
>
<div className="card-body">
<h4>{payment.vendor}</h4>
<h5>{payment.amount}</h5>
<button
className="btn btn-success"
onClick={() => onClick(payment.id)}
>
Mark as Paid
</button>
</div>
</div>
);
});
return <div className="d-flex flex-row flex-wrap">{renderedPayments}</div>;
};
export default ComponentB;
There are several ways to share the state between components:
Lift the state up.
Add the global store to the app, for example, Redux or Recoil.
If it is a small app, lift the state. Use the Redux if it is a big project.
I am using drawer navigation with react navigation v5, and i have a screen called profile, that takes in a route prop, that i pass user id to. Problem is, when I visit a profile, and then logout, and log back in, i get an error saying that route.params.id is not an object, undefined. In my profile.tsx I checked where i use the route params, and its as in the shown code below:
useEffect(() => {
if (!route) {
return navigation.navigate("Søg Brugere");
}
getUser();
}, [route]);
and getUser function should not be executed, however I include it for clarlity.
const getUser = async () => {
if (!route) return;
try {
console.log(route.params.id);
const id = route.params.id;
setRefreshing(true);
const { data } = await (await HttpClient()).get(
config.SERVER_URL + "/api/user/get-user-by-id/" + id
);
setRefreshing(false);
setProfile(data.user);
setMatch(data.match);
setInitiated(true);
if (socket && user) {
const notificationData = {
url: `/profile/${user._id}`,
type: "new-visit",
text: `Nyt besøg fra ${user.displayName}`,
user: data.user,
resourceId: user._id,
};
socket.emit("notification", notificationData);
}
} catch (e) {
navigation.navigate("Søg Brugere");
}
};
and also a snippet of my logout function, used in drawer navigator:
<View style={{ flex: 1, justifyContent: "flex-end" }}>
<Button
onPress={async () => {
props.navigation.replace("Søg Brugere");
props.navigation.closeDrawer();
await AsyncStorage.removeItem("token");
setUser(null);
}}
title="Log Ud"
color="#F44336"
/>
</View>
i solved this by adding at the top of my rfc:
const id = route?.params?.id
I am having two users have a private message by having both of them join the same Socket.io room, which is based on the room created from the sender and receiver ids. Every time I create a message, the client rejoins the room, due to a Socket.io emit event re-rendering because of a state change, and the roomId is console logged. As a result, the message is not being transmitted over multiple instances of the chat when it has joined the same room.
const ChatApp = () => {
const [messages, setMessages] = React.useState([]);
const userId = useSelector(state => state.profile.profile._id);
const senderFullName = useSelector(state => state.auth.user.fullname);
const authId = useSelector(state => state.auth.user._id);
const roomId = [authId, userId].sort().join('-');
const ioClient = io.connect("http://localhost:5000");
ioClient.emit('join', {roomid: roomId});
ioClient.emit('connected', {roomid: roomId} );
ioClient.on('message', (msg) => {
const messageObject = {
username: msg.from,
message : msg.body,
timestamp: msg.timestamp
};
addMessage(messageObject);
});
const addMessage = (message) => {
const messagess = [...messages];
messagess.push(message);
setMessages(messagess);
console.log(messagess);
}
const sendHandler = (message) => {
var res = moment().format('MM/DD/YYYY h:mm a').toString();
// Emit the message to the server
ioClient.emit('server:message', {from: senderFullName, body: message, timestamp: res });
}
return (
<div className="landing">
<Container>
<Row className="mt-5">
<Col md={{ span: 8, offset: 2 }}>
<Card style={{ height: "36rem" }} border="dark">
<Messages msgs={messages} />
<Card.Footer>
<ChatInput onSend={sendHandler}>
</ChatInput>
</Card.Footer>
</Card>
</Col>
</Row>
</Container>
</div>
)
};
Api code that is used in server
io.sockets.on("connection",function(socket){
// Everytime a client logs in, display a connected message
console.log("Server-Client Connected!");
socket.on('join', function(data) {
socket.join(data.roomid, () => {
//this room id is being console logged every time i send a message
console.log(data.roomid);
});
});
socket.on('connected', function(data) {
//load all messages
console.log('message received');
(async () => {
try {
console.log('searching for Schema');
const conversation = await Conversation.find({roomId: data.roomid}).populate('messages').lean().exec();
const mess = conversation.map();
console.log(mess);
console.log('Schema found');
}
catch (error) {
console.log('Schema being created');
Conversation.create({roomId: data.roomid});
}
})();
});
socket.on('server:message', data => {
socket.emit('message', {from: data.from, body: data.body, timestamp: data.timestamp});
console.log(data.body);
Message.create({from: data.from, body: data.body});
})
});
You could either move the socket state up to a parent component, or move the event initialization into a custom hook that is shared once across your application, or into a useEffect hook here so it only runs on initial render.
Probably the simplest is the latter option, you can just wrap those lines of code you only want to be written once up like this:
useEffect(() => {
ioClient.emit('join', {roomid: roomId});
ioClient.emit('connected', {roomid: roomId} );
ioClient.on('message', (msg) => {
const messageObject = {
username: msg.from,
message : msg.body,
timestamp: msg.timestamp
};
addMessage(messageObject);
});
}, []);
The empty array at the end signifies that this will only run on initial render, and not again, as it has no dependencies.
Update
You may or may not be having closure or timing issues, but to be on the safe side, replace the line "addMessage(messageObject);`" with:
setMessages((previousMessages) => [messageObject, ...previousMessages]);
This will do the same thing as the addMessage function, but by passing a callback function to setMessages, you avoid using the state object from outside.
I have an app that get data from an API as a page. I've added functionality to get the next page of the data and set it to state.
I want to be able to get the next pages data but rather than replace the state I want to add the next page value.
Here is the code
const TopRatedPage = () => {
const [apiData, setApiData] = useState([]);
const [isLoading, setLoading] = useState(false);
const [pageNumber, setPageNumber] = useState(1);
const { results = [] } = apiData;
useEffect(() => {
setLoading(true);
fetchTopRatedMovies(pageNumber).then((data) => setApiData(data));
setLoading(false);
}, [apiData, pageNumber]);
return (
<div className='top-rated-page-wrapper'>
<h1>TopRatedPage</h1>
{isLoading ? <h1>Loading...</h1> : <MovieList results={results} />}
<button
onClick={() => {
setPageNumber(pageNumber + 1);
}}>
MORE
</button>
</div>
);
I've tried setApiData(...apiData,data) but it gives error, apiData is not iterable.
Here is the data returned Object { page: 1, total_results: 5175, total_pages: 259, results: (20) […] }
To clarify I want to be able to allow user to click button that adds more API data to state and display more.
Updated to reflect the shape of the object returned by your api call.
One way to handle this, as demonstrated below, is to replace the page metadata (page, total_results, total_pages) in your component's state with the info from latest api call, but append the results each time.
If you don't care about the paging information you could just drop it on the floor and store only the results, but keeping it around lets you display paging info and disable the 'load more' button when you get to the end.
Displaying the current page and total pages might not make sense given that you're just appending to the list (and thus not really "paging" in the UI sense), but I've included it here just to provide a sense of how you might use that info if you chose to do so.
The key thing in the snippet below as it relates to your original question is the state merging:
const more = () => {
fetchTopRatedMovies(page + 1)
.then(newData => {
setData({
// copy everything (page, total_results, total_pages, etc.)
// from the fetched data into the updated state
...newData,
// append the new results to the old results
results: [...data.results, ...newData.results]
});
});
}
I'm using spread syntax to copy all of the fields from newData into the new state, and then handling the results field separately because we want to replace it with the concatenated results. (If you left the results: [...data.results, ...newData.results] line out, your new state would have the results field from newData. By specifying the results field after ...newData you're replacing it with the concatenated array.
Hope this helps.
/*
Your api returns an object with the following shape.
I don't know what the individual results entries look like,
but for our purposes it doesn't really matter and should be
straightforward for you to tweak the following code as needed.
{
page: 1,
total_results: 5175,
total_pages: 259,
results: [
{
title: 'Scott Pilgrim vs. The World',
year: 2010,
director: 'Edgar Wright'
},
{
title: 'Spiderman: Into the Spider-Verse',
year: 2018,
director: ['Bob Persichetti', 'Peter Ramsey', 'Rodney Rothman']
},
{
title: 'JOKER',
year: 2019,
director: 'Todd Phillips'
},
];
}
*/
const {useState} = React;
// simulated api request; waits half a second and resolves with mock data
const fetchTopRatedMovies = (page = 0) => new Promise((resolve) => {
// 5 is arbitrary
const numPerPage = 5;
// generate an array of mock results with titles and years
const results = Array.from(
{length: numPerPage},
(_, i) => ({
title: `Movie ${(page - 1) * numPerPage + i}`,
year: Math.floor(Math.random() * 20) + 2000
})
);
// 16 is arbitrary; just to show how you can disable
// the 'more' button when you get to the last page
const total_results = 16;
// the return payload
const data = {
page,
total_results,
total_pages: Math.floor(total_results / numPerPage),
results
}
setTimeout(() => resolve(data), 500);
});
const Demo = () => {
const [data, setData] = useState({ page: 0, results: [] });
const more = () => {
fetchTopRatedMovies(page + 1)
.then(newData => {
setData({
// copy everything (page, total_results, total_pages, etc.)
// from the fetched data into the updated state
...newData,
// append the new results to the old results
results: [...data.results, ...newData.results]
});
});
}
// pull individual fields from state
const {page, results, total_results, total_pages} = data;
// are there more pages? (used to disable the button when we get to the end)
const hasMore = page === 0 || (page < total_pages);
return (
<div className='wrapper'>
<div>
{total_pages && (
<div>
<div>Page: {page} of {total_pages}</div>
<div>Total: {total_results}</div>
</div>
)}
<button disabled={!hasMore} onClick={more}>More</button>
<ul>
{results.map(m => (
<li key={m.title}>{m.title} - ({m.year})</li>
))}
</ul>
</div>
<div>
<h3>component state:</h3>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
</div>
);
};
ReactDOM.render(
<Demo />,
document.getElementById('demo')
);
/* purely cosmetic. none of this is necessary */
body {
font-family: sans-serif;
line-height: 1.6;
}
.wrapper {
display: flex;
background: #dedede;
padding: 8px;
}
.wrapper > * {
flex: 1 1 50%;
background: white;
margin: 8px;
padding: 16px;
}
h3 {
margin: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.0/umd/react-dom.production.min.js"></script>
<div id="demo" />