React select dropdowns that depend on each other - reactjs

I am trying to make 3 select dropdowns automatically change based on the selection.
First dropdown has no dependencies, 2nd depends on first, and 3rd depends on 2nd.
This is a very simplified version of my code:
// record, onSave, planets, countries, cities passed as props
const [selectedPlanet, setSelectedPlanet] = useState(record.planet);
const [selectedCountry, setSelectedCountry] = useState(record.country);
const [selectedCity, setSelectedCity] = useState(record.city);
const filteredCountries = countries.filter(c => c.planet === selectedPlanet.id);
const filteredCities = cities.filter(c => c.country === selectedCountry.id);
return (
<div>
<select value={selectedPlanet.id} onChange={(e) => setSelectedPlanet(e.target.value)}>
{planets.map(p => (
<option key={p.id} value={p.id} name={p.name} />
)}
</select>
<select value={selectedCountry.id} onChange={(e) => setSelectedCountry(e.target.value)}>
{filteredCountries.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<select value={selectedCity.id} onChange={(e) => setSelectedCity(e.target.value)}>
{filteredCities.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<button onClick={() => onSave({planet: selectedPlanet, country: selectedCountry, city: selectedCity ) }
</div>
);
The select options will update accordingly, but onSave() will receive outdated values for country and city if I select a planet and click the save button.
That is because setSelectedCountry and setSelectedCity are not called on planet change. I know I can just call them in that event, but it would make the code much uglier because I would have to duplicate the country and city filtering. Is there a better way around this?

Update
I updated the code example for the useReducer approach. It is functionally equivalent to the original example, but hopefully with cleaner logic and structure.
Live demo of updated example: stackblitz
Although it takes some extra wiring up, useReducer could be considered for this use case since it might be easier to maintain the update logic in one place, instead of tracing multiple events or hook blocks.
// Updated to use a common structure for reducer
// Also kept the reducer pure so it can be moved out of the component
const selectsReducer = (state, action) => {
const { type, payload } = action;
switch (type) {
case "update_planet": {
const newCountry = payload.countries.find(
(c) => c.planet === payload.value
).id;
return {
planet: payload.value,
country: newCountry,
city: payload.cities.find((c) => c.country === newCountry).id,
};
}
case "update_country": {
return {
...state,
country: payload.value,
city: payload.cities.find((c) => c.country === payload.value).id,
};
}
case "update_city": {
return {
...state,
city: payload.value,
};
}
default:
return { ...state };
}
};
// Inside the component
const [selects, dispatchSelects] = useReducer(selectsReducer, record);
Original
While uglier or not is rather opinion-based, perhaps a potentially organized solution to handle the states dependent on each other is to use useReducer.
Live demo of below example: stackblitz
Here is a basic example that updates dependent values, such as if country is updated, then city will also change to the first available city in the new country to match it.
This keeps the value in the select lists updated, and it ensures onSave to always receive the updated values from the state.
const selectsReducer = (state, action) => {
const { type, planet, country, city } = action;
let newPlanet,
newCountry,
newCity = "";
switch (type) {
// 👇 Here to update all select values (the next 2 cases also run)
case "update_planet": {
newPlanet = planet;
}
// 👇 Here to update country and city (the next case also run)
case "update_country": {
newCountry = newPlanet
? countries.find((c) => c.planet === newPlanet).id
: country;
}
// 👇 Here to update only city
case "update_city": {
newCity = newCountry
? cities.find((c) => c.country === newCountry).id
: city;
return {
planet: newPlanet || state.planet,
country: newCountry || state.country,
city: newCity || state.city,
};
}
default:
return record;
}
};
const [selects, dispatchSelects] = useReducer(selectsReducer, record);

Since the dependency relationship is just in one direction, a simple approach would be just to clear everything that depends on the one that changed when it changes. So:
clear country and city when planet is selected
clear city when country is selected
<select value={selectedPlanet.id} onChange={(e) => setPlanetAndClearCountryAndCity(e)}>
{planets.map(p => (
<option key={p.id} value={p.id} name={p.name} />
)}
</select>
<select value={selectedCountry.id} onChange={(e) => setCountryAndClearCity(e)}>
{filteredCountries.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
And put some validation that requires all 3 before save button is enabled.
Also you could consider not clearing the country or city if they actually still have valid options. But that seems like more work than it's worht.

You could use useEffect hook to handle select box updates. So that, you don't miss any update in values.
demo
export const SelectList = ({ record, onSave, planets, countries, cities }) => {
const [selectedPlanet, setSelectedPlanet] = useState(record.planet);
const [selectedCountry, setSelectedCountry] = useState(record.country);
const [selectedCity, setSelectedCity] = useState(record.city);
/** handle filteredValues in state */
const [filteredCountries, setFilteredCountries] = useState([]);
const [filteredCities, setFilteredCities] = useState([]);
/** use useEffect to update values */
useEffect(() => {
const filteredCountries = countries.filter((c) => c.planet === selectedPlanet);
setFilteredCountries(filteredCountries);
setSelectedCountry(filteredCountries[0].id);
}, [selectedPlanet]);
useEffect(() => {
const filteredCities = cities.filter((c) => c.country === selectedCountry);
setFilteredCities(filteredCities);
setSelectedCity(filteredCities[0].id);
}, [selectedCountry]);
return (
<div>
<select value={selectedPlanet}
onChange={(e) => setSelectedPlanet(e.target.value)}>
{planets.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select value={selectedCountry}
onChange={(e) => setSelectedCountry(e.target.value)}>
{filteredCountries.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<select
value={selectedCity}
onChange={(e) => setSelectedCity(e.target.value)}>
{filteredCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button onClick={() => onSave({planet: selectedPlanet, country: selectedCountry, city: selectedCity })}>Save</button>
</div>
);
};

I think using React.useMemo hook for two variables: filteredCountries and filteredCities would be the best solution for you.
It will not make your code ugly and you don't have to filter twice.
Here is the updated code.
import * as React from 'react';
// record, onSave, planets, countries, cities passed as props
const [selectedPlanet, setSelectedPlanet] = useState(record.planet);
const [selectedCountry, setSelectedCountry] = useState(record.country);
const [selectedCity, setSelectedCity] = useState(record.city);
const filteredCountries = React.useMemo(() => {
const _countries = countries.filter(c => c.planet === selectedPlanet.id);
setSelectedCountry(_countries[0]);
return _countries;
}, [selectedPlanet]);
const filteredCities = React.useMemo(()=> {
const _cities = cities.filter(c => c.country === selectedCountry.id);
setSelectedCity(_cities[0]);
return _cities;
}, [setSelectedCountry])
return (
<div>
<select value={selectedPlanet.id} onChange={(e) => setSelectedPlanet(e.target.value)}>
{planets.map(p => (
<option key={p.id} value={p.id} name={p.name} />
)}
</select>
<select value={selectedCountry.id} onChange={(e) => setSelectedCountry(e.target.value)}>
{filteredCountries.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<select value={selectedCity.id} onChange={(e) => setSelectedCity(e.target.value)}>
{filteredCities.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<button onClick={() => onSave({planet: selectedPlanet, country: selectedCountry, city: selectedCity ) }
</div>
);

One way is to handle it by useEffect hook. this soloution is simple logic and obviously u can handle it with more details and conditions.
import React, { useEffect, useState } from "react";
const planets = [
{ id: 1, name: "earth", value: "earth" },
{ id: 2, name: "mars", value: "mars" },
{ id: 3, name: "jupiter", value: "jupiter" }
];
const countries = [
{ id: 1, name: "USA", value: "USA", planet: "earth" },
{ id: 2, name: "UAE", value: "UAE", planet: "mars" },
{ id: 3, name: "Canada", value: "Canada", planet: "mars" },
{ id: 4, name: "England", value: "England", planet: "earth" }
];
const cities = [
{ id: 1, name: "Los Angeles", value: "Los Angeles", country: "USA" },
{ id: 2, name: "Dubai", value: "Dubai", country: "UAE" },
{ id: 3, name: "Torento", value: "Torento", country: "Canada" },
{ id: 4, name: "Washington", value: "Washington", country: "USA" },
{ id: 5, name: "London", value: "London", country: "England" }
];
export default function MaterialUIPickers() {
const [selectedPlanet, setSelectedPlanet] = useState(planets[0].name);
const [selectedCountry, setSelectedCountry] = useState("");
const [selectedCity, setSelectedCity] = useState("");
const [countryItems, setCountryItems] = useState([]);
const [cityItems, setCityItems] = useState([]);
useEffect(() => {
setCountryItems([]); // reset select
setCityItems([]); // reset select
setSelectedCountry(""); // reset select
setSelectedCity(""); // reset select
selectedPlanet &&
setCountryItems(countries.filter((c) => c.planet === selectedPlanet));
}, [selectedPlanet]);
useEffect(() => {
setCityItems([]); // reset select
setSelectedCity(""); // reset select
selectedCountry &&
setCityItems(cities.filter((c) => c.country === selectedCountry));
}, [selectedCountry]);
return (
<div style={{ display: "flex-box", width: "vh" }}>
<select
value={selectedPlanet.id}
onChange={(e) => setSelectedPlanet(e.target.value)}
>
{planets.map((p) => (
<option key={p.id} value={p.name} name={p.name}>
{p.name}
</option>
))}
</select>
<select
value={selectedCountry?.id}
onChange={(e) => setSelectedCountry(e.target.value)}
>
{countryItems.map((c) => (
<option key={c.id} value={c.name} name={c.name}>
{c.name}
</option>
))}
</select>
<select
value={selectedCity?.id}
onChange={(e) => setSelectedCity(e.target.value)}
>
{cityItems.map((c) => (
<option key={c.id} value={c.name} name={c.name}>
{c.name}
</option>
))}
</select>
<button
onClick={() =>
console.log({
planet: selectedPlanet,
country: selectedCountry,
city: selectedCity
})
}
>
log selcets
</button>
</div>
);
}
https://codesandbox.io/s/kind-wilson-zpenc?file=/src/App.js
this is sandbox link

Related

Select fields not being populated by state

I have created a page with a multiple state controlled input fields but am having difficulty ensuring that they retain the correct text values on the front end after deleting a row of elements.
Here is an image of the component:
Here is the component which displays the inputs:
<InputContainer>
<PrimaryLabel>Criteria</PrimaryLabel>
{currentRuleCriteria.map((criteria, index) => (
<TripleInputContainer>
<StyledTriplePrimarySelect name="criteria-column" id="criteria-column-select" value={currentRuleCriteria[index].column} onChange={(e) => handleCriteriaColumnChange(index, e.target.value)}>
{columns.map((option, index) => (
<option key={index} value={option.value}>
{option.text}
</option>
))}
</StyledTriplePrimarySelect>
<StyledTriplePrimarySelect name="criteria-operator" id="criteria-operator-select" value={currentRuleCriteria[index].operator} onChange={(e) => handleCriteriaOperatorChange(index, e.target.value)}>
{criteriaOperators.map((option, index) => (
<option key={index} value={option.value}>
{option.text}
</option>
))}
</StyledTriplePrimarySelect>
<InputTypeSelectorsContainer>
<StyledColumnIcon active={criteria.valueType === "column"} onClick={() => handleCriteriaValueTypeChange(index, "column")} />
<StyledTextIcon active={criteria.valueType === "text"} onClick={() => handleCriteriaValueTypeChange(index, "text")} />
</InputTypeSelectorsContainer>
{ criteria.valueType === "column" ? (
<StyledPrimarySelect name="criteria-value" id="criteria-value-select" value={currentRuleCriteria[index].value} onChange={(e) => handleCriteriaValueChange(index, e.target.value)}>
{columns.map((option, index) => (
<option key={index} value={option.value}>
{option.text}
</option>
))}
</StyledPrimarySelect>
) : (
<StyledPrimaryInput type="text" placeholder="" value={currentRuleCriteria[index].value} onChange={(e) => handleCriteriaValueChange(index, e.target.value)} />
)}
<RemoveItemContainer onClick={() => handleRemoveCriteria(index)}>
<StyledRemoveItemIcon active={currentRuleCriteria.length > 1} />
</RemoveItemContainer>
</TripleInputContainer>
))}
<AdditionalEntryContainer>
<AdditionalEntryButton onClick={() => handleAddCriteria()}>+ Add another</AdditionalEntryButton>
</AdditionalEntryContainer>
</InputContainer>
Here is the state which is mapped over to produce the input elements:
const [currentRuleCriteria, setCurrentRuleCriteria] = useState([{
column: "title",
operator: "contains",
value: "",
valueType: "text"
}]);
Here are the handler functions for interacting with state:
const handleCriteriaColumnChange = (index, value) => {
const newCriteria = [...currentRuleCriteria];
newCriteria[index].column = value;
setCurrentRuleCriteria(newCriteria);
};
const handleCriteriaOperatorChange = (index, value) => {
const newCriteria = [...currentRuleCriteria];
newCriteria[index].operator = value;
setCurrentRuleCriteria(newCriteria);
};
const handleCriteriaValueChange = (index, value) => {
setCriteriaValue(value);
const newCriteria = [...currentRuleCriteria];
newCriteria[index].value = value;
setCurrentRuleCriteria(newCriteria);
};
const handleCriteriaValueTypeChange = (index, value) => {
const newCriteria = [...currentRuleCriteria];
newCriteria[index].valueType = value;
setCurrentRuleCriteria(newCriteria);
setCriteriaValueType(value);
};
const handleAddCriteria = () => {
setCurrentRuleCriteria([...currentRuleCriteria, {
column: criteriaColumn,
operator: criteriaOperator,
value: criteriaValue,
valueType: criteriaValueType
}]);
setCriteriaColumn("title");
setCriteriaOperator("contains");
setCriteriaValue("");
setCriteriaValueType("text");
};
const handleRemoveCriteria = (index) => {
const newCriteria = [...currentRuleCriteria];
newCriteria.splice(index, 1);
setCurrentRuleCriteria(newCriteria);
};
The Problem:
I can see that when I add a new criteria a new set of input fields are added. Similarly, when I edit the values the state is always maintained correctly. The issue I am having is when deleting a pre-existing row of elements.
If I have two criteria in place, as in the image above, and I select the first one to be deleted - the state updates correctly, however the incorrect input fields seem to be deleted on the frontend and so it no longer aligns with the state values. In this example the following would then be visible:
I don't understand this as the input fields should be being generated by mapping over the currentRuleCriteria state value.
I have tried many things to try and rectify this, but can't seem to figure it out. I previously did not have the value specified on the individual select elements and was in the same situation as I am currently. Adding in a value field and referencing the state did not change anything and so I believe the select fields are not being populated by state values.
Any thoughts? Thank you!
You should set the key on TripleInputContainer:
<TripleInputContainer key={criteria.id}>
React docs says "Keys should be stable, predictable, and unique". But in your case there may be complete duplicates of the criteria. So it makes sense to use Math.random():
const [currentRuleCriteria, setCurrentRuleCriteria] = useState([
{
id: Math.random(),
column: "title",
operator: "contains",
value: "",
valueType: "text",
},
]);
setCurrentRuleCriteria([
...currentRuleCriteria,
{
id: Math.random(),
column: criteriaColumn,
operator: criteriaOperator,
value: criteriaValue,
valueType: criteriaValueType,
},
]);
It's even better to use nanoid and not worry about collisions.

REACT-Send requests the selected dish options ids to my backend not working

How can I retrieve the dishId selected from my options react - select that shows me thedishType in order to send them my parent component FormRender and to the backend.
My first dropdown shows me: Menu1, Menu2...
My second one: type2...
So if I click ontype4, how can I store the related dishId(here = 4). I can click on several values i.e: type2 andtype3.
How do I keep the dish ids i.e : 2 and 3 and send them to my FormRender parent
Menus(first page of my multi - step form):
export default function Menus() {
const [selectionMenus, setSelectionMenus] = useState({});
const [selectionDishes, setSelectionDishes] = useState({});
const [menus, setMenus] = useState([])
const [date, setDate] = useState('')
useEffect(() => {
axios
.post(url)
.then((res) => {
console.log(res);
setMenus(res.data.menus);
})
.catch((err) => {
console.log(err);
});
}, []);
const names = menus?.map(item => {
return {
label: item.menuId,
value: item.name
}
})
const types = menus?.flatMap(item => {
return item.dishes.map(d => ({
label: d.dishId,
value: d.dishType
}))
})
const handle = (e) => {
if (e?.target?.id === undefined) return setInfo(e);
if (e?.target?.id === undefined) return setSelectionMenus(e);
if (e?.target?.id === undefined) return setSelectionDishes(e);
switch (e.target.id) {
case "date":
setDate(e.target.value);
break;
...
default:
}
}
};
return (
<>
<form>
<div>My menus</div>
<label>
Dishes :
<Dropdown
options={names}
value={selectionMenus}
setValue={setSelectionMenus}
isMulti={true}
/>
</label>
<label>
<Dropdown
options={types}
value={selectionDishes}
setValue={setSelectionDishes}
isMulti={true}
/>
</label>
<label>
Date:
<div>
<input
type="date"
name='date'
value={date}
onChange={handle}
id="date"
/>
</div>
</label>
...
</form>
<div>
<button onClick={() => nextPage({ selectionDishes, selectionMenus, date })}>Next</button>
</div>
</>
);
}
Here the parent Component FormRender that is supposed to retrieve the values of all dishId selected and send them to the backend:
export default function FormRender() {
const [currentStep, setCurrentStep] = useState(0);
const [info, setInfo] = useState();
const [user, setUser] = useState();
const headers = ["Menus", "Details", "Final"];
const steps = [
<Menus
nextPage={(menu) => {
setInfo(menu);
setCurrentStep((s) => s + 1);
}}
/>,
<Details
backPage={() => setCurrentStep((s) => s - 1)}
nextPage={setUser}
/>,
<Final />
];
useEffect(() => {
if (info === undefined || user === undefined) return;
const data = {
date: info.date,
id: //list of dishId selected but don't know how to do that??
};
}, [info, user]);
return (
<div>
<div>
<Stepper steps={headers} currentStep={currentStep} />
<div >{steps[currentStep]}</div>
</div>
</div>
);
}
Dropdown:
export default function Dropdown({ value, setValue, style, options, styleSelect, isMulti = false }) {
function change(option) {
setValue(option.value);
}
return (
<div onClick={(e) => e.preventDefault()}>
{value && isMulti === false ? (
<Tag
selected={value}
setSelected={setValue}
styleSelect={styleSelect}
/>
) : (
<Select
value={value}
onChange={change}
options={options}
isMulti={isMulti}
/>
)}
</div>
);
}
Here my json from my api:
{
"menus": [
{
"menuId": 1,
"name": "Menu1",
"Description": "Descritption1",
"dishes": [
{
"dishId": 2,
"dishType": "type2"
},
{
"dishId": 3,
"dishType": "type3"
},
{
"dishId": 4,
"dishType": "type4"
}
]
},
...
]
}
You already store the selected values inside the selectionMenus and selectionDishes states. So, if you want to send them to the parent FormRender component you can instead create those two states inside that component like this:
export default function FormRender() {
const [selectionMenus, setSelectionMenus] = useState();
const [selectionDishes, setSelectionDishes] = useState();
....
}
Then pass those values to the Menus component:
<Menus
selectionMenus={selectionMenus}
setSelectionMenus={setSelectionMenus}
selectionDishes={selectionDishes}
setSelectionDishes={setSelectionDishes}
nextPage={(menu) => {
setInfo(menu);
setCurrentStep((s) => s + 1);
}}
/>
Subsequently, you will have to remove the state from the Menus component and use the one you receive from props:
export default function Menus({ selectionMenus, setSelectionMenus, selectionDishes, setSelectionDishes }) {
/*const [selectionMenus, setSelectionMenus] = useState({}); //remove this line
const [selectionDishes, setSelectionDishes] = useState({});*/ //remove this line
...
}
Finally, you can use inside your useEffect hook the two states and map them to only get the selected ids:
useEffect(() => {
// ... other logic you had
if(selectionDishes?.length && selectionMenus?.length){
const data = {
date: info.date,
id: selectionDishes.map(d => d.dishId),
idMenus: selectionMenus.map(m => m.menuId)
};
}
}, [info, user, selectionMenus, selectionDishes]);
react-select has options to format the component:
getOptionLabel: option => string => used to format the label or how to present the options in the UI,
getOptionValue: option => any => used to tell the component what's the actual value of each option, here you can return just the id
isOptionSelected: option => boolean => used to know what option is currently selected
onChange: option => void => do whatever you want after the input state has changed
value => any => if you customize the above functions you may want to handle manually the value
Hope it helps you

React State update a nested array with objects based on the id when iterated

I have an react state with objects/array that is filled, but also then later I want to edit text inputs and edit the related fields on object also, i found no solutions until now.
So this is my state for example:
const [data, setData] = useState({
working_hours: [
{
id: 1,
description: 'Random Text',
price: 100,
},
{
id: 2,
description: 'Text Random',
price: 100,
},
]
});
Here is my Jsx:
{data.working_hours.map(item =>
<>
<input type={text} value={item.description}
onChange={(e) => handleChange(e)} />
<input type={text} value={item.price}
onChange={(e) => handleChange(e)} />
</>
)}
Here is what I tried:
function handleChange(e){
const value = e.target.value;
setData({...data, [...data.working_hours, e.target.value]})
}
You need to pass additional parameters to your handleChange as item ID which you want to update and property name because without these you will not be able to identify which property to update dynamically. This way you can use the same handleChange for multiple inputs.
See below code -
function handleChange(e, itemId, property) {
const value = e.target.value;
//copying data to temp variable so that we do not directly mutate original state
const tempWorkingHours = [...data.working_hours];
//findIndex to find location of item we need to update
let index = tempWorkingHours.findIndex(item => item.id == itemId);
// -1 check to see if we found that object in working hours
if(index != -1){
tempWorkingHours[index] = {
...tempWorkingHours[index], //keeping existing values in object
[property]: value //here property can be "price" or "description"
}
}
setData({ ...data, working_hours: tempWorkingHours })
}
{
data.working_hours.map(item =>
<>
<input type={text} value={item.description}
onChange={(e) => handleChange(e, item.id, "description")} />
<input type={text} value={item.price}
onChange={(e) => handleChange(e, item.id, "price")} />
</>
)
}
When you want to update the state of objects in the nested array, you must identify these objects and the property you want to update. Thus your handler should look like this.
function handleChange(index, property, value){
// ...
}
The setData function will only trigger a rerender if you pass it a new object. Thus you should create a copy.
function handleChange(index, property, value) {
const new_working_hours = [...data.working_hours]; // copy the array
const new_working_hour = { ...data.working_hours[index] }; // copy the array item to change
new_working_hour[property] = value; // set the new value
new_working_hours[index] = new_working_hour; // assign the new item to the copied array
setData({ working_hours: new_working_hours }); // return a new data object
}
Here is a working example. Click Run code snippet below.
const { useState } = React;
const App = () => {
const [data, setData] = useState(initialState);
function handleChange(index, property, value) {
const new_working_hours = [...data.working_hours]; // copy the array
const new_working_hour = { ...data.working_hours[index] }; // copy the array item to change
new_working_hour[property] = value; // set the new value
new_working_hours[index] = new_working_hour; // assign the new item to the copied array
setData({ working_hours: new_working_hours }); // return a new data object
}
return data.working_hours.map((item, index) => (
<div>
<input
type="text"
value={item.description}
onChange={(e) => handleChange(index, "description", e.target.value)}
/>
<input
type="text"
value={item.price}
onChange={(e) => handleChange(index, "price", e.target.value)}
/>
</div>
));
};
const initialState = {
working_hours: [
{
id: 1,
description: "Random Text",
price: 100
},
{
id: 2,
description: "Text Random",
price: 100
}
]
};
ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
function handleChange(e){
const value = e.target.value;
const newData = {
...data,
working_hours: [
...(data?.working_hours ? data.working_hours : {}),
value
]
};
setData(newData);
}
Here is a simulation
let data = {
working_hours: [
{
id: 1,
field: "test",
}
],
};
function handleChange(e){
const value = e.target.value;
const newData = {
...data,
working_hours: [
...(data?.working_hours ? data.working_hours : {}),
value
]
};
console.log('newData', newData);
}
const event = {
target: {
value: { id: 2, field: "test2" },
}
};
handleChange(event);
The problem is that you are missing the part of indicating which object to update,
you can do so by passing the index of the array or by the id that the object has.
for example:
function handleChange(e){
const value = e.target.value;
const tempWorkingHours = data.working_hours.map((item) => {
if (item.id === value.id) {
return {
...item,
price: value
}
}
return item;
}
setData({...data, working_hours: tempWorkingHours })
}
in that way, you are using a map to loop over the array and find the item you want to change(but by the temp variable that holds a copy of the data to avoid mutating the state).
You can also pass the index to the handle change function:
{data.working_hours.map((item, index) =>
<>
<input type={text} value={item.description}
onChange={(e) => handleChange(e)} />
<input type={text} value={item.price}
onChange={(e,index) => handleChange(e, index)} />
</>
)}
And then in the handleChange use the index to access the relevant index to change the array. let me know if you want me to explain more with modification of your code, but I think the way I explain above with the id solution is nice.
Edit- the index version :
function handleChange(e, index) {
const value = e.target.value;
const tempWorkingHours = [...data.working_hours];
tempWorkingHours[index].price = value;
setData({...data, working_hours: tempWorkingHours});
}
Fixed the first example, the return moved outside the if statement in case it's not the relevant id we want to modify.
Read more about forms from react's official docs:
https://reactjs.org/docs/forms.html
It doesn't matter if you are using function components to get the point, its the same. Only the way You are updating the state is with
useState.
class Reservation extends React.Component {
constructor(props) {
super(props);
this.state = {
isGoing: true,
numberOfGuests: 2
};
this.handleInputChange = this.handleInputChange.bind(this);
}
handleInputChange(event) {
const target = event.target;
const value = target.type === 'checkbox' ? target.checked : target.value;
const name = target.name;
this.setState({
[name]: value
});
}
render() {
return (
<form>
<label>
Is going:
<input
name="isGoing"
type="checkbox"
checked={this.state.isGoing}
onChange={this.handleInputChange} />
</label>
<br />
<label>
Number of guests:
<input
name="numberOfGuests"
type="number"
value={this.state.numberOfGuests}
onChange={this.handleInputChange} />
</label>
</form>
);
}
}
change elements
{data.working_hours.map(item =>
<>
<input name='description' type={text} value={item.description}
onChange={(e) => handleChange(e,item.id)} />
<input name='price' type={text} value={item.price}
onChange={(e) => handleChange(e,item.id)} />
</>
)}
change handleChange
function handleChange(e,id){
const value = e.target.value;
const name= e.target.name;
var dataitemIndex = data.working_hours.findIndex(x=>x.id == id);
data.working_hours[dataitemIndex] = {...data.working_hours[dataitemIndex],[event.target.name]: event.target.value};
setData(data);
}

how to configure dynamic form fields inside a wizard when using a state machine

I am trying to implement a multi-step wizard using a state machine and am unsure how to handle some configurations. To illustrate this I put together an example of a wizard that helps you prepare a dish.
Assuming the following example what would be the appropriate way to model this form/wizard behavior as a state machine?
Step 1 - Dish
pick a dish from ["Salad", "Pasta", "Pizza"]
Step 2 - Preparation Method
pick a preparation method from ["Oven", "Microwave"]
Step 3 - Ingredients
add and select ingredients in a form, depending on the dish and the preparation method the form will look different
// ingredients based on previous selections
("Pizza", "Oven") => ["tomato", "cheese", "pepperoni"]
("Pizza", "Microwave") => ["cheese", "pepperoni", "mushrooms"]
("Pasta", "Oven") => ["parmesan", "butter", "creme fraiche"]
("Pasta", "Microwave") => ["parmesan", "creme fraiche"]
("Salad") => ["cucumber", "feta cheese", "lettuce"]
I tried to simplify the problem as much as possible. Here are my questions:
In step 3 I want to show a form with various fields of different types. The selections in step 1 and 2 define which fields will be shown in the form in step 3. What is the appropriate way to specify this form configuration?
Step 2 should be skipped if the selected dish from step 1 is "Salad". What is the appropriate way to declare this?
I plan to implement this using xstate as the project I'm working on is written in react.
Edit: I updated the example in reaction to Martins answer. (see my comment on his answer)
Edit 2: I updated the example in reaction to Davids answer. (see my comment on his answer)
For the overall flow, you can use guarded transitions to skip the method step if "salad" was selected:
const machine = createMachine({
initial: 'pick a dish',
context: {
dish: null,
method: null
},
states: {
'pick a dish': {
on: {
'dish.select': [
{
target: 'ingredients',
cond: (_, e) => e.value === 'salad'
},
{
target: 'prep method',
actions: assign({ dish: (_, e) => e.value })
}
]
}
},
'prep method': {
on: {
'method.select': {
target: 'ingredients',
actions: assign({ method: (_, e) => e.value })
}
}
},
'ingredients': {
// ...
}
}
});
And you can use the data-driven configuration from Matin's answer to dynamically show ingredients based on the context.dish and context.method.
You need to have a data structure that holds data and the relationship between them then you can use state to store the selected item and have your logic to display/hide specific step.
Below is just a simple example to show how you can do it:
Sandbox example link
const data = [
{
// I recommend to use a unique id for any items that can be selective
dish: "Salad",
ingredients: ["ingredient-A", "ingredient-B", "ingredient-C"],
preparationMethods: []
},
{
dish: "Pasta",
ingredients: ["ingredient-E", "ingredient-F", "ingredient-G"],
preparationMethods: ["Oven", "Microwave"]
},
{
dish: "Pizza",
ingredients: ["ingredient-H", "ingredient-I", "ingredient-G"],
preparationMethods: ["Oven", "Microwave"]
}
];
export default function App() {
const [selectedDish, setSelectedDish] = useState(null);
const [selectedMethod, setSelectedMethod] = useState(null);
const [currentStep, setCurrentStep] = useState(1);
const onDishChange = event => {
const selecetedItem = data.filter(
item => item.dish === event.target.value
)[0];
setSelectedDish(selecetedItem);
setSelectedMethod(null);
setCurrentStep(selecetedItem.preparationMethods.length > 0 ? 2 : 3);
};
const onMethodChange = event => {
setSelectedMethod(event.target.value);
setCurrentStep(3);
};
const onBack = () => {
setCurrentStep(
currentStep === 3 && selectedMethod === null ? 1 : currentStep - 1
);
};
useEffect(() => {
switch (currentStep) {
case 1:
setSelectedDish(null);
setSelectedMethod(null);
break;
case 2:
setSelectedMethod(null);
break;
case 3:
default:
}
}, [currentStep]);
return (
<div className="App">
{currentStep === 1 && <Step1 onDishChange={onDishChange} />}
{currentStep === 2 && (
<Step2
onMethodChange={onMethodChange}
selectedMethod={selectedMethod}
selectedDish={selectedDish}
/>
)}
{currentStep === 3 && <Step3 selectedDish={selectedDish} />}
{selectedDish !== null && (
<>
<hr />
<div>Selected Dish: {selectedDish.dish}</div>
{selectedMethod !== null && (
<div>Selected Method: {selectedMethod}</div>
)}
</>
)}
<br />
{currentStep > 1 && <button onClick={onBack}> Back </button>}
</div>
);
}
const Step1 = ({ onDishChange }) => (
<>
<h5>Step 1:</h5>
<select onChange={onDishChange}>
<option value={null} disabled selected>
Select a dish
</option>
{data.map(item => (
<option key={item.dish} value={item.dish}>
{item.dish}
</option>
))}
</select>
</>
);
const Step2 = ({ onMethodChange, selectedMethod, selectedDish }) => (
<>
<h5>Step 2:</h5>
<div>
<select onChange={onMethodChange} value={selectedMethod}>
<option value={null} disabled selected>
Select a method
</option>
{selectedDish.preparationMethods.map(method => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
</div>
</>
);
const Step3 = ({ selectedDish }) => (
<>
<h5>Step 3:</h5>
<h4>List of ingredient: </h4>
{selectedDish.ingredients.map(ingredient => (
<div key={ingredient}>{ingredient}</div>
))}
</>
);

Adding new columns dynamically with mui-datatable

I want to add a new column in mui-datatable every time a button is pressed. However datatables doesn't seem to be rendering it. However, if I clicked on the add column button, and then select the dropdown, the new columns appear. Furthermore the selected dropdown value does not seem to be reflected as well . I've narrowed the issue down to using const [columns,setColumns] = useState(...) for the column, but without that, I can't add any new columns dynamically at all. Appreciate any help to get me out of this pickle, thank you.
https://codesandbox.io/s/unruffled-sun-g77xc
const App = () => {
function handleChange(event) {
setState({ value: event.target.value });
}
const [state, setState] = useState({ value: "coconut" });
const [columns, setColumns] = useState([
{
name: "Column 1",
options: {}
},
{
name: "Column with Input",
options: {
customBodyRender: (value, tableMeta, updateValue) => {
return (
<div>
<select value={state.value} onChange={handleChange}>
<option value="grapefruit">Grapefruit</option>
<option value="lime">Lime</option>
<option value="coconut">Coconut</option>
<option value="mango">Mango</option>
</select>
</div>
);
}
}
}
]);
const options = {
responsive: "scroll"
};
const addColumn = () => {
columns.push({
name: "NewColumn",
label: "NewColumn"
});
setColumns(columns);
};
const data = [["value", "value for input"], ["value", "value for input"]];
return (
<React.Fragment>
<MUIDataTable columns={columns} options={options} data={data} />
//add a new column if this button is clicked
<button onClick={addColumn}>Add Column</button>
</React.Fragment>
);
};
Your new column wasn't actually getting pushed to the columns variable. When you're using useState, you can't make changes to the variable unless you use setColumns, otherwise it won't trigger a rerender. Try this:
const addColumn = () => {
setColumns([ ...columns, {
name: "NewColumn"
}]);
};
Or this:
const addColumn = () => {
const editableColumns = [...columns];
editableColumns.push({
name: "NewColumn"
});
setColumns(editableColumns);
};
Both will work, it's just your preference.
You can test if it's editing the columns with this:
useEffect(() => console.log(columns), [columns]);

Resources