Having a state-nightmare that has had me stumped all week. Hope you can help.
I'm making a web app for gym-goers to record and track their progress on various different exercises. The exercises get stored in the user object, in a nested object, and then themselves have a nested object called history which the date and weight of their attempts, so an entry in users.exercises might look like:
{
exercise: "exercise 1",
target: "10",
history: [
{ date: 1, weight: 5 },
{ date: 2, weight: 6 },
{ date: 3, weight: 6 },
],
};
I then have a component which renders the specific exericse, called Exercise.js. At the moment, all it renders is the history.length:
<h1>History array length: {exerciseProp.history.length}</h1>
I also have a button which logs to the console that same property:
<button onClick={() => console.log(exerciseProp.history.length)}>
log
</button>
Initially, they give the same number. So far so good.
There's then a child component which allows users to add another entry to the history property. That is successfully updating the entry in MongoDB, but it isn't changing the number being rendered on the page. It is, however, changing the number that gets logged when I press the button. So the page will render, for example, "5", but when I press the button it logs "6". But I can't for the life of me work out why!
One thing I tried was this - I wondered if the problem was that it was getting the users array, using const users = useSelector((state) => state.auth); first and then not updating it when a child component updated state. So I've passed a handleUpdate prop down to the child component that then, as well as the component updating state, it updates the exerciseProp value with a newEntry:
const handleUpdate = (formData) => {
formData._id = "tempID";
let newEntry = exerciseProp;
newEntry.history = [...newEntry.history, formData];
setExerciseProp(newEntry);
};
I'd of course rather not have to do that and have it just refresh and update the users prop when the state changed, but I couldn't make that work.
I've tried to strip out all the irrelevant bits of code, but here's the component at the moment:
import React, { useState, useEffect } from "react";
import { ExerciseFooter } from "./ExerciseFooter";
import { useLocation } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { getUsers } from "../actions/auth";
export const Exercise = (props) => {
const location = useLocation();
const dispatch = useDispatch();
//get full list of users
useEffect(() => {
dispatch(getUsers());
}, []);
const users = useSelector((state) => state.auth);
//get local user
const localUser = JSON.parse(localStorage.getItem("profile"));
//initialise user and specific exercise variables
const [user, setUser] = useState("");
const [exerciseProp, setExerciseProp] = useState({
history: [""],
target: 0,
});
//set user and exercise to match params/local storage
useEffect(() => {
localUser &&
localUser?.result &&
users.length > 0 &&
setUser(
users.filter(
(filteredUser) => filteredUser._id == props.match.params.userId
)[0]
);
if (!localUser) setUser("");
setExerciseProp(
user?.exercises?.filter(
(exercise) => exercise._id == props.match.params.exerciseId
)[0]
);
}, [users]);
//force update to state
const handleUpdate = (formData) => {
formData._id = "tempID";
let newEntry = exerciseProp;
newEntry.history = [...newEntry.history, formData];
console.log("new Ent", newEntry);
setExerciseProp(newEntry);
};
if (exerciseProp) {
return (
<div style={{ marginTop: "150px" }}>
<h1>History array length: {exerciseProp.history.length}</h1>
<button onClick={() => console.log(exerciseProp.history.length)}>
log
</button>
{exerciseProp && (
<ExerciseFooter
user={user}
exercise={exerciseProp}
handleUpdate={(formData) => handleUpdate(formData)}
/>
)}
</div>
);
} else {
return <>Loading...</>;
}
};
So the main problem is: the state on the page isn't changing when I expect it to, and the secondary one - though I think my workaround for it might be okay - was that the state wasn't changing in the parent component when the child dispatched a change to it.
Any help very much welcome!
Related
I'm working on a project where a user can input multiple exercise names/weights. These go into a state array as objects. For example:
[
{
name: bench
weight: 100
},
{
name: squat:
weight: 200
}
]
Each of these objects becomes a button that displays the name and weight. What I want to happen is allow the user to click the button and navigate to another page where that specific name and weight are displayed, but I can't figure out how to do that. Currently, I am getting the whole state array instead of just the specific one clicked.
Here is my button component:
const MovementButtons = (props) => {
const navigate = useNavigate();
const {
move
} = props;
const mapMoves = move.map((movement) => {
return (
<Button
key={movement.name}
onClick={() => navigate(`/movement/${movement.name}/${movement.weight}`)}
>
{movement.name} - {movement.weight}
</Button>
)
})
return <div>{mapMoves}</div>
};
const mapStateToProps = state => {
return {
move: state.moveReducer
}
};
export default connect(mapStateToProps)(MovementButtons);
Here is the page where the button redirects to and where I want to try and get the name of the button that was clicked:
const PercentChart = (props) => {
const {
move
} = props;
return (
<div>
{move.name}
{move.weight}
</div>
);
};
const mapStateToProps = (state) => {
return {
move: state.moveReducer,
}
};
export default connect(mapStateToProps)(PercentChart);
So right now if I click on the button labeled "bench" for example, I get redirected to the PercentChart page, where instead of getting just the "bench" object I get the whole state array. Can anyone point me in the right direction on how I can get the specific object based on the button that was clicked? If I am missing code that needs to be shown let me know and I'll update.
I am building a simple application using React, Apollo and React Router. This application allows you to create recipes, as well as edit and delete them (your standard CRUD website).
I thought about how I would present my problem, and I figured the best way was visually.
Here is the home page (localhost:3000):
When you click on the title of a recipe, this is what you see (localhost:3000/recipe/15):
If you click the 'create recipe' button on the home page, this is what you see (localhost:3000/create-recipe):
If you click on the delete button on a recipe on the home page, this is what you see (localhost:3000):
If you click on the edit button on a recipe on the home page, this is what you see (localhost:3000/recipe/15/update):
This update form is where the problem begins. As you can see, the form has been filled with the old values of the recipe. Everything is going to plan. But, when I refresh the page, this is what you see:
It's all blank. I am 67% sure this is something to do with the way React renders components or the way I am querying my apollo server. I don't fully understand the process React goes through to render a component.
Here is the code for the UpdateRecipe page (what you've probably been waiting for):
import React, { useState } from "react";
import { Button } from "#chakra-ui/react";
import {
useUpdateRecipeMutation,
useRecipeQuery,
useIngredientsQuery,
useStepsQuery,
} from "../../types/graphql";
import { useNavigate, useParams } from "react-router-dom";
import { SimpleFormControl } from "../../shared/SimpleFormControl";
import { MultiFormControl } from "../../shared/MultiFormControl";
interface UpdateRecipeProps {}
export const UpdateRecipe: React.FC<UpdateRecipeProps> = ({}) => {
let { id: recipeId } = useParams() as { id: string };
const intRecipeId = parseInt(recipeId);
const { data: recipeData } = useRecipeQuery({
variables: { id: intRecipeId },
});
const { data: ingredientsData } = useIngredientsQuery({
variables: { recipeId: intRecipeId },
});
const { data: stepsData } = useStepsQuery({
variables: { recipeId: intRecipeId },
});
const originalTitle = recipeData?.recipe.recipe?.title || "";
const originalDescription = recipeData?.recipe.recipe?.description || "";
const originalIngredients =
ingredientsData?.ingredients?.ingredients?.map((ing) => ing.text) || [];
const originalSteps = stepsData?.steps?.steps?.map((stp) => stp.text) || [];
const [updateRecipe] = useUpdateRecipeMutation();
const navigate = useNavigate();
const [formValues, setFormValues] = useState({
title: originalTitle,
description: originalDescription,
ingredients: originalIngredients,
steps: originalSteps,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<SimpleFormControl
label="Title"
name="title"
type="text"
placeholder="Triple Chocolate Cake"
value={formValues.title}
onChange={(e) => {
setFormValues({ ...formValues, title: e.target.value });
}}
/>
<SimpleFormControl
label="Description"
name="description"
type="text"
placeholder="A delicious combination of cake and chocolate that's bound to mesmerize your tastebuds!"
value={formValues.description}
onChange={(e) => {
setFormValues({ ...formValues, description: e.target.value });
}}
/>
<MultiFormControl
label="Ingredients"
name="ingredients"
type="text"
placeholder="Eggs"
values={formValues.ingredients}
onAdd={(newValue) => {
setFormValues({
...formValues,
ingredients: [...formValues.ingredients, newValue],
});
}}
onDelete={(_, index) => {
setFormValues({
...formValues,
ingredients: formValues.ingredients.filter(
(__, idx) => idx !== index
),
});
}}
/>
<MultiFormControl
ordered
label="Steps"
name="steps"
type="text"
placeholder="Pour batter into cake tray"
color="orange.100"
values={formValues.steps}
onAdd={(newValue) => {
setFormValues({
...formValues,
steps: [...formValues.steps, newValue],
});
}}
onDelete={(_, index) => {
setFormValues({
...formValues,
steps: formValues.steps.filter((__, idx) => idx !== index),
});
}}
/>
<Button type="submit">Update Recipe</Button>
</form>
);
};
I'll try to explain it as best as I can.
First I get the id parameter from the url. With this id, I grab the corresponding recipe, its ingredients and its steps.
Next I put the title of the recipe, the description of the recipe, the ingredients of the recipe and the steps into four variables: originalTitle, originalDescription, originalIngredients and originalSteps, respectively.
Next I set up some state with useState(), called formValues. It looks like this:
{
title: originalTitle,
description: originalDescription,
ingredients: originalIngredients,
steps: originalSteps,
}
Finally, I return a form which contains 4 component:
The first component is a SimpleFormControl and it is for the title. Notice how I set the value prop of this component to formValues.title.
The second component is also a SimpleFormControl and it is for the description, which has a value prop set to formValues.description.
The third component is a MultiFormControl and it's for the ingredients. This component has its value props set to formValues.ingredients.
The fourth component is also aMultiFormControl and it's for the steps. This component has its value props set to formValues.steps.
Let me know if you need to see the code for these two components.
Note:
When I come to the UpdateRecipe page via the home page, it works perfectly. As soon as I refresh the UpdateRecipe page, the originalTitle, originalDescripion, originalIngredients and originalSteps are either empty strings or empty arrays. This is due to the || operator attached to each variable.
Thanks in advance for any feedback and help.
Let me know if you need anything.
The problem is that you are using one hook useRecipeQuery that will return data at some point in the future and you have a second hook useState for your form that relies on this data. This means that when React will render this component the useRecipeQuery will return no data (since it's still fetching) so the useState hook used for your form is initialized with empty data. Once useRecipeQuery is done fetching it will reevaluate this code, but that doesn't have any effect on the useState hook for your form, since it's already initialized and has internally cached its state. The reason why it's working for you in one scenario, but not in the other, is that in one scenario your useRecipeQuery immediately returns the data available from cache, whereas in the other it needs to do the actual fetch to get it.
What is the solution?
Assume you don't have the data available for your form to properly render when you first load this component. So initialize your form with some acceptable empty state.
Use useEffect to wire your hooks, so that when useRecipeQuery finishes loading its data, it'll update your form state accordingly.
const { loading, data: recipeData } = useRecipeQuery({
variables: { id: intRecipeId },
});
const [formValues, setFormValues] = useState({
title: "",
description: "",
ingredients: [],
steps: [],
});
useEffect(() => {
if (!loading && recipeData ) {
setFormValues({
title: recipeData?.recipe.recipe?.title,
description: recipeData?.recipe.recipe?.description,
ingredients: ingredientsData?.ingredients?.ingredients?.map((ing) => ing.text),
steps: stepsData?.steps?.steps?.map((stp) => stp.text),
});
}
}, [loading, recipeData ]);
I have read:
react passing data from parent to child component
and
https://www.freecodecamp.org/news/pass-data-between-components-in-react/
While they outline the desired method. It does not seem to be working for the following example. I must be doing something obvious, but I cannot seem to figure out what I am doing wrong.
Structure
The structure is a wizard style form (4 steps) below which is contained in newrequest.js. Below each step is a list table component with various items associated with each step.
Think of it like a shopping cart, except you can have multiple carts (step 1), you can leave them in the store filled with all sorts of things (step 2-3) for quite some time before buying anything (step 4).
A user many want to create several carts all at the time, and then add/create/edit other items to the carts over the course of several months before finalizing. The carts, and the items in those carts need to stay grouped together, and they need to be found easily enough by the user to keep working on them at some later date.
User Action:
The user goes to step 1, either makes a new cart, or grabs an existing cart to edit and proceeds to step 2. For the edit option, I have been able to pass the id of an existing cart (list-request-saved.js) to (newrequest.js), edit, save etc. I can also tie the items to the cart with a cartid prop when this is happening the first time.
The Problem:
The issue is I cannot seem to figure out how to pass the cartid akarequestID, to step 2 when editing an existing cart akaSavedRequest so the database can fetch the items which belong in that cart. Without the cartid, the database just spits out all items in any cart by anyone. To fix this, I would need to get the cartid to "newrequest.js" from the child component "list-requests-saved.js " in step 1 (This works ok) and then pass the cartid to child component "list-components.js" in step 2 (This does not work ok).
newrequest.js (parent) // Removed the non-impacting sections
//queries
import { listTestRequests } from '../../graphql/queries'
import { listComponents } from '../../graphql/queries'
import { getSavedRequest } from '../../graphql/queries'
import { getComponent } from '../../graphql/queries'
//components
import SavedRequests from "../../pages/list-requests-saved"
import Components from "../../pages/list-components"
const initialTestRequestState = getInitialState(listTestRequests)
const initialComponentState = getInitialState(listComponents)
function NewRequest(props) {
const [testRequest, setTestRequest] = React.useState(initialTestRequestState)
const [component, setComponent] = React.useState(initialComponentState)
const [editRequest, setEditRequest] = React.useState('')
const [editComponent, setEditComponent] = React.useState('')
const requestTableToParent = (requestID) => {
setEditRequest(requestID)
}
const componentTableToParent = (componentID) => {
setEditComponent(componentID)
}
useEffect(() => {
fetchRequest(editRequest)
async function fetchRequest(id) {
if (!id) return
const requestData = await API.graphql({ query: getSavedRequest, variables: { id } })
setTestRequest(requestData.data.getSavedRequest)
}
}, [editRequest])
useEffect(() => {
fetchComponent(editComponent)
async function fetchComponent(id) {
if (!id) return
const componentData = await API.graphql({ query: getComponent, variables: { id } })
setComponent(componentData.data.getComponent)
}
}, [editComponent])
return (
<form
autoComplete="off"
noValidate
{...props}
>
{
activeStep == 0
? <SavedRequests requestTableToParent={requestTableToParent}/>
: null
}
{
activeStep == 1
? <Components componentTableToParent={componentTableToParent} requestID={editRequest}/>
: null
}
</form >
)
}
export default NewRequest
list-requests-saved.js (child-step-1) // Removed the non-impacting sections
function SavedRequests({requestTableToParent}) {
const renderEditButton = (params) => {
return (
<strong>
<Button
variant="text"
color="primary"
size="small"
style={{ marginLeft: 16 }}
onClick={() => requestTableToParent(params.row.id)}
>
Edit
</Button>
</strong>
)
}
export default (SavedRequests)
list-components.js (child-step-2) // Removed the non-impacting sections
import { listComponents } from '../graphql/queries'
import { deleteComponents as deleteComponentsMutation } from '../graphql/mutations'
function Components({componentTableToParent}, {requestID}) {
const [component, setComponent] = useState([])
const classes = useStyles();
console.log("requestID", requestID) //this is undefined
}
export default (Components)
Additional Commentary: editRequest in "newrequest.js" has the correct value which was set by the edit button in "list-requests-saved.js". When I'm trying to catch the value in the child component "list-components.js", requestID is reporting as undefined. What am I doing wrong?
I am trying to add a favorite page to my application, which basically will list some of the previously inserted data. I want the data to be fetched from localStorage. It essentially works, but when I navigate to another page and come back, the localStorage is empty again. I want the data in localStorage to persist when the application is refreshed.
The data is set to localStorage from here
import React, { useState, createContext, useEffect } from 'react'
export const CombinationContext = createContext();
const CombinationContextProvider = (props) => {
let [combination, setCombination] = useState({
baseLayer: '',
condiment: '',
mixing: '',
seasoning: '',
shell: ''
});
const saveCombination = (baseLayer, condiment, mixing, seasoning, shell) => {
setCombination(combination = { baseLayer: baseLayer, condiment: condiment, mixing: mixing, seasoning: seasoning, shell: shell });
}
let [combinationArray, setCombinationArray] = useState([]);
useEffect(() => {
combinationArray.push(combination);
localStorage.setItem('combinations', JSON.stringify(combinationArray));
}, [combination]);
return (
<CombinationContext.Provider value={{combination, saveCombination}}>
{ props.children }
</CombinationContext.Provider>
);
}
export default CombinationContextProvider;
And fetched from here
import React, { useContext, useState } from 'react'
import { NavContext } from '../contexts/NavContext';
const Favorites = () => {
let { toggleNav } = useContext(NavContext);
let [favorites, setFavorites] = useState(localStorage.getItem('combinations'));
console.log(favorites);
return (
<div className="favorites" >
<img className="menu" src={require("../img/tacomenu.png")} onClick={toggleNav} />
<div className="favorites-title">YOUR FAVORITES</div>
<div>{ favorites }</div>
</div>
);
}
export default Favorites;
There are a few issues with your code. This code block:
useEffect(() => {
combinationArray.push(combination);
localStorage.setItem('combinations', JSON.stringify(combinationArray));
}, [combination]);
Will run any time the dependency array [combination] changes, which includes the first render. The problem with this is combination has all empty values on the first render so it is overwriting your local storage item.
Also, combinationArray.push(combination); is not going to cause a rerender because you are just changing a javascript value so react doesn't know state changed. You should use the updater function react gives you, like this:
setCombinationArray(prevArr => [...prevArr, combination])
You should push to your combinationArray and set the result as the new state value and be careful not to overwrite your old local storage values
I have issues with communication between a parent and a child component.
I would like the parent (Host) to hold his own state. I would like the child (Guest) to be passed that state and modify it. The child has his local version of the state which can change however the child wants. However, once the child finishes playing with the state, he passes it up to the parent to actually "Save" the actual state.
How would I correctly implement this?
Issues from my code:
on the updateGlobalData handler, I log both data and newDataFromGuest and they are the same. I would like data to represent the old version of the data, and newDataFromGuest to represent the new
updateGlobalData is being called 2X. I can solve this by removing the updateGlobalData ref from the deps array inside useEffect but I don't want to heck it.
My desired results should be:
the data state should hold the old data until updateGlobalData is called
I want updateGlobalData to be fired only once when I click the button
Code from Codesandbox:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
const Host = () => {
const [data, setData] = useState({ foo: { bar: 1 } });
const updateGlobalData = newDataFromGuest => {
console.log(data);
console.log(newDataFromGuest);
setData(newDataFromGuest);
};
return <Guest data={data} updateGlobalData={updateGlobalData} />;
};
const Guest = ({ data, updateGlobalData }) => {
const [localData, setLocalData] = useState(data);
const changeLocalData = newBarNumber => {
localData.foo = { bar: newBarNumber };
setLocalData({ ...localData });
};
useEffect(() => {
updateGlobalData(localData);
}, [localData, updateGlobalData]);
return (
<div>
<span>{localData.foo.bar}</span> <br />
<button onClick={() => changeLocalData(++localData.foo.bar)}>
Increment
</button>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<Host />, rootElement);
NOTE: Code solution below
Problem 1:
I want updateGlobalData to be fired only once when I click the button
To solve this issue, I have used a mix between React.createContext and the hook useReducer. The idea is to make the Host dispatcher available through its context. This way, you do not need to send the "updateGlobalData" callback down to the Guest, nor make the useEffect hook to be dependant of it. Thus, useEffect will be triggered only once.
Note though, that useEffect now depends on the host dipatcher and you need to include it on its dependencies. Nevertheless, if you read the first note on useReducer, a dispatcher is stable and will not cause a re-render.
Problem 2:
the data state should hold the old data until updateGlobalData is called
The solution is easy: DO NOT CHANGE STATE DATA DIRECTLY!! Remember that most values in Javascript are passed by reference. If you send data to the Guest and you directly modify it, like here
const changeLocalData = newBarNumber => {
localData.foo = { bar: newBarNumber }; // YOU ARE MODIFYING STATE DIRECTLY!!!
...
};
and here
<button onClick={() => changeLocalData(++localData.foo.bar)}> // ++ OPERATOR MODIFYES STATE DIRECLTY
they will also be modified in the Host, unless you change that data through the useState hook. I think (not 100% sure) this is because localData in Guest is initialized with the same reference as data coming from Host. So, if you change it DIRECTLY in Guest, it will also be changed in Host. Just add 1 to the value of your local data in order to update the Guest state, without using the ++ operator. Like this:
localData.foo.bar + 1
This is my solution:
import React, { useState, useEffect, useReducer, useContext } from "react";
import ReactDOM from "react-dom";
const HostContext = React.createContext(null);
function hostReducer(state, action) {
switch (action.type) {
case "setState":
console.log("previous Host data value", state);
console.log("new Host data value", action.payload);
return action.payload;
default:
throw new Error();
}
}
const Host = () => {
// const [data, setData] = useState({ foo: { bar: 1 } });
// Note: `dispatch` won't change between re-renders
const [data, dispatch] = useReducer(hostReducer, { foo: { bar: 1 } });
// const updateGlobalData = newDataFromGuest => {
// console.log(data.foo.bar);
// console.log(newDataFromGuest.foo.bar);
// setData(newDataFromGuest);
// };
return (
<HostContext.Provider value={dispatch}>
<Guest data={data} /*updateGlobalData={updateGlobalData}*/ />
</HostContext.Provider>
);
};
const Guest = ({ data /*, updateGlobalData*/ }) => {
// If we want to perform an action, we can get dispatch from context.
const hostDispatch = useContext(HostContext);
const [localData, setLocalData] = useState(data);
const changeLocalData = newBarNumber => {
// localData.foo = { bar: newBarNumber };
// setLocalData({ ...localData });
setLocalData({ foo: { bar: newBarNumber } });
};
useEffect(() => {
console.log("useEffect", localData);
hostDispatch({ type: "setState", payload: localData });
// updateGlobalData(localData);
}, [localData, hostDispatch /*, updateGlobalData*/]);
return (
<div>
<span>{localData.foo.bar}</span> <br />
<button onClick={() => changeLocalData(localData.foo.bar + 1)}>
Increment
</button>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<Host />, rootElement);
If you see anything not matching with what you want, please, let me know and I will re-check it.
I hope it helps.
Best,
Max.