Handling unknown number of states in React - reactjs

I am working on a React Native project and I have a React component which displays a list of questions and their options. Let's say all questions are multiple choices, and I use a Picker for that, DropDownPicker. (react-native-dropdown-picker). I need a way to manage whether the picker is opened or closed. The component has 2 props, open and setOpen. There is an unknown number of questions, so I can't initialise all the states.
I've tried doing const [open, setOpen] = useState([]), where open is an array of open statuses. Here's a code snippet:
questions.map((v,i) => {
setOpen([...open, false]); // causes infinite renders
return (
...
<DropDownPicker
open={open[i]}
setOpen={o => { // returns true/false
let list = [...open];
list[i] = o;
return list;
}/>
...
)
}
The code runs into infinite renders when appending a boolean to the state at setOpen([...open, false]). Another way I've tried is to use a variable rather than a state variable, but that doesn't work either.
Can I check whether there's an ideal way to handle an unknown number of states?

Your approach of using a state that is an array of Booleans seems like a reasonable approach. I would do it something like this:
// Initialize to array of false, of length equal to questions
const [open, setOpen] = useState(questions.map((v) => false));
return questions.map((v,i) =>
<DropDownPicker open={open[i]} setOpen={(o) => {
let list = [...open];
list[i] = o;
setOpen(list);
}/>
);
Note that setOpen does not immediately change open; rather, it schedules a change to the open state which happens after the render completes. This is why your code doesn't converge; every render triggers open to grow more.

You can manage open and close status in question array for example
let question:any[] = [
{id:1, questionText:'Some text',open:false, answers:[]}
{id:2, questionText:'Some text',open:false, answers:[]}
]
{question.forEach((item)=>{
<DropDownPicker
open={open[i]}
setOpen={o => { // returns true/false
let index = question.findIndex(id)=>id==itemid;
question[index].open = true;
}
}/>
});
}

Related

How to render and update a list of React components dynamically in a performant way?

I am trying to build a simple form builder, with which you can add questions to a list, and each kind of question has a specific component to create the question, and one to set an answer.
These components are contained in an object like this
{
longText:{
create: (initialData, setNewData)=>{...},
answer: (initialData, setNewData)=>{...}
},
multiple:{
create: (initialData, setNewData)=>{...},
answer: (initialData, setNewData)=>{...}
}
...
}
When a kind of question is selected from a dropdown, the right component should render, showing the initialData.
right now the only way I have found that works is something like this:
{(() => {
return React.createElement(components[questionKindName].create,{
initialData: data,
setNewData: (v) => setTheData(v),
});
})()}
But this has very poor performance,
as I am updating the data, on every change,
and it triggers a new render of the whole list of forms each time.
this is the code that keeps track of the list of questions.
const [questions, setQuestions] = useState<Question[]>([]);
const addNewQuestion = (question: Question) => setQuestions([...questions, question]);
// this is the function invoked to set the new data
const editQuestion = (
questionIndex: Number,
key: keyof Question,
value: string
) =>
setQuestions(
questions.map((s, i) => {
if (s && questionIndex === i) {
s[key] = value;
}
return s;
})
);
how can I make it work nicely?
What is that I am missing here?
Should I put a throttle around the IIFE?
I am learning about lazy and Suspense, might that help?
I know the question could have been clearer,
if you can help, i will be happy to improve it.
Thanks.
PS:
I have tried saving and updating the selected component with a useState and a useEffect hook,
but it just doesn't work.
I always got an error like.. "cannot read "InitialData" on "_ref" as it is undefined" that indicates that it was invoking the function, but not passing the props.

Filtered list: how best to keep keyboard nav in sync with the list as rendered?

I'm building out keyboard navigation for a composable React combobox component. As it stands, it works except when the visible list of options is filtered down to match text field input. I'm using React.Children in the callback below to build an array corresponding to my options list; this works well on initialization, but after filtering (and re-rendering the options that match the filter) the result when I call it still includes all items, rendered or not, with the props they initially had. (I had the "bright idea" of giving unrendered children a tabindex of -2 and filtering that way, but this only works if the values are updated...)
Is there a better way than React.Children to populate my array of IDs and values/labels for my options, and to update it based on what was most recently rendered?
If so, where could I learn about it?
If not:
how can I access the current state, rather than a stale one?
what's the best way of pointing to the specific children in question? The method I'm using looks brittle.
const updateOptions = useCallback(
() =>
// set the optionStateMap from children
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) return;
if (!child.props.children) return;
const optionsArray: Array<optionStateProps> = [];
child.props.children[0].forEach( // will break if my list is not the first child with children; d'oh
(option: { props: { id: string; label: string, tabIndex: number} }) => {
if (option.props.tabIndex < -1) return;
optionsArray.push({
id: option.props.id,
label: option.props.label
});
}
);
setOptionStateMap(optionsArray);
}),
[children]
);
Thanks for looking!

Managing state of individual rows in react js table

I have a requirement where for each row of a table(rows are dynamically populated), I have a 'Details' button, which is supposed to pop up a modal upon clicking. How do I maintain a state for each of the rows of this table, so that React knows which row I am clicking, and hence pass the props accordingly to the modal component?
What I tried out was create an array of all false values, with length same as my data's. The plan is to update the boolean for a particular row when the button is clicked. However, I'm unable to execute the same.
Here's what I've tried so far:
let initTableState = new Array(data.length).fill(false)
const [tableState, setTableState] = useState(initTableState)
const detailsShow = (index) => {
setTableState(...tableState, tableState[index] = true)
}
I get the 'data' from DB. In the function detailsShow, I'm trying to somehow get the index of the row, so that I can update the corresponding state in 'tableState'
Also, here's what my code to put in modal component looks like, placed right after the row entries are made:
{tableState[index] && DetailModal}
Here DetailModal is the modal component. Any help in resolving this use case is greatly appreciated!
The tableState is a single array object. You are also spreading the array into the setTableState updater function, the state updater function also takes only a single state value argument or callback function to update state.
You can use a functional state update and map the previous state to the next state, using the mapped index to match and update the specific element's boolean value.
const detailsShow = (index) => {
setTableState(tableState => tableState.map((el, i) => i === index ? true : el))
}
If you don't want to map the previous state and prefer to shallow copy the array first and update the specific index:
const detailsShow = (index) => {
setTableState(tableState => {
const nextTableState = [...tableState];
nextTableState[index] = true;
return nextTableState;
})
}

Input element's value in react component is not getting re-rendered when the state changes

My state object is a Map
const [voucherSet, setVoucherSet] = useState(initialVoucherSet);
initialVoucherSet is map I create at the beginning of the stateless component function.
const initialVoucherSet = new Map();
activeVoucher.voucherDenominations.forEach(voucherDemonination=> {
initialVoucherSet.set(voucherDemonination, 0);
});
const [voucherSet, setVoucherSet] = useState(initialVoucherSet);
activeVoucher.voucherDenominations an array of numbers.
I have a input which triggers a function on onChange.
const handleChange = (e)=>{
const voucherDemonination = parseInt(e.target.id);
const voucherQuantity = parseInt(e.target.value);
if (voucherQuantity >= 0) { setVoucherSet(voucherSet.set(voucherDemonination, voucherQuantity)); }
}
The state object voucherSet is getting updated, but my input's value is not getting re-rendered.
Below is the input element:
<CounterInput type='number' id={voucherDemonination} onChange={handleChange} value={voucherSet.get(voucherDemonination)} />
What I already tried
I thought that it might be because I was not setting a different object to the voucherSet state variable. So I tried something a bit hacky...
const handleChange = (e)=>{
const voucherDemonination = parseInt(e.target.id);
const voucherQuantity = parseInt(e.target.value);
if (voucherQuantity >= 0) {
const tempVoucherSet = voucherSet;
tempVoucherSet.set(voucherDemonination, voucherQuantity);
setVoucherSet(tempVoucherSet);
}
}
but it still didn't work.
Where am I wrong?
Much Thanks in advance! :)
So what is happening is that the Map itself is not changing (eg. every time you update the Map, you still have a reference to the same exact Map in memory), so react is not rerendering.
This falls under the whole "immutable" thing with react. Any time a state change happens, a new object or array ow whatever should be created so that react and easily detect that something changed and thus rerender. This makes it so react doesn't have to iterate over every key in your object/array to see if anything changed (which would kill your performance).
Try this in the code which updates your map:
tempVoucherSet.set(voucherDemonination, voucherQuantity);
setVoucherSet(new Map(tempVoucherSet)); // -> notice the new Map()
This is analogous to other code you may have seen with react and state changes where new objects/arrays are created any time a new property/item is added:
setState({ ...oldState, newProperty: 'newValue' })
setState([ ...oldArray, newItem ]);
I had the same issue in the past. Set your state this way:
setVoucherSet(new Map(voucherSet.set(voucherDemonination, voucherQuantity)));
That will cause a re-render.

Mutable global state causing issues with array length

I've been working on a SPA for a while and managing my global state with a custom context API, but it's been causing headaches with undesired rerenders down the tree so I thought I'd give react-easy-state a try. So far it's been great, but I'm starting to run into some issues which I assume has to do with the mutability of the global state, something which was easily solved with the custom context api implementation using a lib like immer.
Here's a simplified version of the issue I'm running into: I have a global state for managing orders. The order object primaryOrder has an array of addons into which additional items are added to the order - the list of available addons is stored in a separate store that is responsible for fetching the list from my API. The orderStore looks something like this:
const orderStore = store({
initialized: false,
isVisible: false,
primaryOrder: {
addons: [],
}
})
When a user selects to increases the quantity of an addon item, it's added to the addons array if it isn't already present, and if it is the qty prop of the addon is increased. The same logic applies when the quantity is reduced, except if it reaches 0 then the addon is removed from the array. This is done using the following methods on the orderStore:
const orderStore = store({
initialized: false,
isVisible: false,
primaryOrder: {
addons: [],
},
get orderAddons() {
return orderStore.primaryOrder.addons;
},
increaseAddonItemQty(item) {
let index = orderStore.primaryOrder.addons.findIndex(
(i) => i.id === item.id
);
if (index === -1) {
let updatedItem = {
...item,
qty: 1,
};
orderStore.primaryOrder.addons = [
...orderStore.primaryOrder.addons,
updatedItem,
];
} else {
orderStore.primaryOrder.addons[index].qty += 1;
}
console.log(orderStore.primaryOrder.addons);
},
decreaseAddonItemQty(item) {
let index = orderStore.primaryOrder.addons.findIndex(
(i) => i.id === item.id
);
if (index === -1) {
return;
} else {
// remove the item from the array if value goes 1->0
if (orderStore.primaryOrder.addons[index].qty === 1) {
console.log("removing item from array");
orderStore.primaryOrder.addons = _remove(
orderStore.primaryOrder.addons,
(i) => i.id !== item.id
);
console.log(orderStore.primaryOrder.addons);
return;
}
orderStore.primaryOrder.addons[index].qty -= 1;
}
}
})
The issue I'm running into has to do with the fact that one of my views consuming the orderStore.addons. My Product component is the consumer in this case:
const Product = (item) => {
const [qty, setQty] = useState(0);
const { id, label, thumbnailUrl, unitCost } = item;
autoEffect(() => {
if (orderStore.orderAddons.length === 0) {
setQty(0);
return;
}
console.log({ addons: orderStore.orderAddons });
let index = orderStore.orderAddons.findIndex((addon) => addon.id === id);
console.log({ index });
if (index !== -1) setQty(orderStore.findAddon(index).qty);
});
const Adder = () => {
return (
<div
className="flex"
style={{ flexDirection: "row", justifyContent: "space-between" }}
>
<div onClick={() => orderStore.decreaseAddonItemQty(item)}>-</div>
<div>{qty}</div>
<div onClick={() => orderStore.increaseAddonItemQty(item)}>+</div>
</div>
);
}
return (
<div>
<div>{label} {unitCost}</div>
<Adder />
</div>
)
}
export default view(Product)
The issue occurs when I call decreaseAddonItemQty and the item is removed from the addons array. The error is thrown in the Product component, stating that Uncaught TypeError: Cannot read property 'id' of undefined due to the fact that the array length reads as 2, despite the fact that the item has been removed ( see image below)
My assumption is that the consumer Product is reading the global store before it's completed updating, though of course I could be wrong.
What is the correct approach to use with react-easy-state to avoid this problem?
Seems like you found an auto batching bug. Just wrap your erroneous mutating code in batch until it is fixed to make it work correctly.
import { batch, store } from '#risingstack/react-easy-state'
const orderStore = store({
decreaseAddonItemQty(item) {
batch(() => {
// put your code here ...
})
}
})
Read the "Reactive renders are batched. Multiple synchronous store mutations won't result in multiple re-renders of the same component." section of the repo readme for more info about batching.
And some insight:
React updates are synchronous (as opposed to Angular and Vue) and Easy State (and all other state managers) use React setState behind the scenes to trigger re-renders. This means they are all synchronous too.
setState usually applies a big update at once while Easy State calls a dummy setState whenever you mutate a store property. This means Easy State would unnecessarily re-render way too often. To prevent this we have a batch method that blocks re-rendering until the whole contained code block is executed. This batch is automatically applied to most task sources so you don't have to worry about it, but if you call some mutating code from some exotic task source it won't be batched automatically.
We don't speak about batch a lot because it will (finally) become obsolete once Concurrent React is released. In the meantime, we are adding auto batching to as many places as possible. In the next update (in a few days) store methods will get auto batching, which will solve your issue.
You may wonder how could the absence of batching mess things up so badly. Older transparent reactivity systems (like MobX 4) would simply render the component a few times unnecessarily but they would work fine. This is because they use getters and setters to intercept get and set operations. Easy State (and MobX 5) however use Proxies which 'see a lot more'. In your case part of your browser's array.splice implementation is implemented in JS and Proxies intercept get/set operations inside array.splice. Probably array.splice is doing an array[2] = undefined before running array.length = 2 (this is just pseudo code of course). Without batching this results in exactly what you see.
I hope this helps and solves your issue until it is fixed (:
Edit: in the short term we plan to add a strict mode which will throw when store data is mutated outside store methods. This - combined with auto store method batching - will be the most complete solution to this issue until Concurrent React arrives.
Edit2: I would love to know why this was not properly batched by the auto-batch logic to cover this case with some tests. Is you repo public by any chance?

Resources