I have a container element that renders a bunch of button components within it. One of the requirements I'm trying to implement is that in case only one button is rendered, I want it to be disabled. Now, the logic that determines how many buttons will be rendered within the container is quite complicated so I can't just check the length of a list to determine that.
So I thought I would be creative and use a ref to check how many children the container has to determine whether the button inside should be disabled:
simplified code snippet:
import React, { useRef } from 'react';
const Component = () => {
const containerRef = useRef();
const isDisabled = !containerRef.current || ref.current.children.length < 2;
return (
<div ref={containerRef}>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};
The above code works in my app but the problem is that when I'm trying to test this component, ref.current is always undefined which prevents me from testing the case where I have more than one button rendered in the container and that they are NOT disabled.
My test:
it('calls handleClick when a button is clicked', async () => {
const { user } = render(
<Component {...defaultProps} rosterPositionsConfig={config}/>
);
const firstButton = screen.getAllByRole('button')[0];
await user.click(firstButton );
expect(defaultProps.handleClick).toHaveBeenCalledTimes(1); <-- assertion failing
});
The first render of this component will always have undefined for containerRef.current, because the element on the page can't exist until after you've rendered.
So actually, the only reason your code is "working" is that your component is rendering twice (or more). The first render always has no ref and thus sets isDisabled to true, and the second one does have the ref and calculates isDisabled from that. I would guess that it's rendering twice due to <React.StrictMode>, in which case your code will stop working when you do a production build (strict mode causes a double render in dev builds only). In the test environment, you only render once, so this accidental double rendering goes away, and the bug becomes more apparent.
I recommend that you fix this by not using a ref. Instead, you can count the number of children from the data directly. There's a few ways this logic could look, but here's one:
const Component = () => {
const containerRef = useRef();
let count = 0;
for (const category of roster) {
if (category.positions.every((position) => position.isSelected)) {
count += 1;
} else {
count += category.positions.filter(
(position) => position.isSelected
).length;
}
}
const isDisabled = count <= 1;
return (
<div>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};
If you don't like the fact that this is basically writing the same looping logic twice (i'm not a fan of that either), here's another way you might do it. You create an array of buttons assuming that they will not be disabled, but then if the length is 1, you use cloneElement to edit that JSX element:
import { cloneElement } from "react";
const Component = () => {
const containerRef = useRef();
const isDisabled = !containerRef.current || ref.current.children.length < 2;
// I'm building the array this way so that it's just a 1-d array, and it
// doesn't have any `false`s in it
const buttons = [];
roster.forEach((category) => {
if (category.positions.every((position) => position.isSelected)) {
buttons.push(<Button disabled={false} {...otherProps} />);
} else {
category.positions.forEach((position) => {
if (position.isSelected) {
buttons.push(<Button disabled={false} {...otherProps} />);
}
});
}
});
if (buttons.length === 1) {
buttons[0] = cloneElement(buttons[0], { disabled: true });
}
return (
<div ref={containerRef}>
<h3>Title</h3>
{buttons}
</div>
);
};
If you absolutely had to use a ref (which, again, i do not recommend for this case), you would need to wait until after the render is complete for the ref to be updated, then count the children, then set state to force a second render. You would probably need to use a layout effect so that this double render doesn't cause a flicker to the user:
const Component = () => {
const containerRef = useRef();
const [disabled, setDisabled] = useState(true);
useLayoutEffect(() => {
setDisabled(containerRef.current.children.length < 2);
});
return (
<div ref={containerRef}>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};
Related
The Goal:
My React Native App shows a list of <Button /> based on the value from a list of Object someData. Once a user press a <Button />, the App should shows the the text that is associated with this <Button />. I am trying to achieve this using conditional rendering.
The Action:
So first, I use useEffect to load a list of Boolean to showItems. showItems and someData will have the same index so I can easily indicate whether a particular text associated with <Button /> should be displayed on the App using the index.
The Error:
The conditional rendering does not reflect the latest state of showItems.
The Code:
Here is my code example
import {someData} from '../data/data.js';
const App = () => {
const [showItems, setShowItems] = useState([]);
useEffect(() => {
const arr = [];
someData.map(obj => {
arr.push(false);
});
setShowItems(arr);
}, []);
const handlePressed = index => {
showItems[index] = true;
setShowItems(showItems);
//The list is changed.
//but the conditional rendering does not show the latest state
console.log(showItems);
};
return (
<View>
{someData.map((obj, index) => {
return (
<>
<Button
title={obj.title}
onPress={() => {
handlePressed(index);
}}
/>
{showItems[index] && <Text>{obj.item}</Text>}
</>
);
})}
</View>
);
};
This is because react is not identifying that your array has changed. Basically react will assign a reference to the array when you define it. But although you are changing the values inside the array, this reference won't be changed. Because of that component won't be re rendered.
And furthermore, you have to pass the key prop to the mapped button to get the best out of react, without re-rendering the whole button list. I just used trimmed string of your obj.title as the key. If you have any sort of unique id, you can use that in there.
So you have to notify react, that the array has updated.
import { someData } from "../data/data.js";
const App = () => {
const [showItems, setShowItems] = useState([]);
useEffect(() => {
const arr = [];
someData.map((obj) => {
arr.push(false);
});
setShowItems(arr);
}, []);
const handlePressed = (index) => {
setShowItems((prevState) => {
prevState[index] = true;
return [...prevState];
});
};
return (
<View>
{someData.map((obj, index) => {
return (
<>
<Button
key={obj.title.trim()}
title={obj.title}
onPress={() => {
handlePressed(index);
}}
/>
{showItems[index] && <Text>{obj.item}</Text>}
</>
);
})}
</View>
);
};
showItems[index] = true;
setShowItems(showItems);
React is designed with the assumption that state is immutable. When you call setShowItems, react does a === between the old state and the new, and sees that they are the same array. Therefore, it concludes that nothing has changed, and it does not rerender.
Instead of mutating the existing array, you need to make a new array:
const handlePressed = index => {
setShowItems(prev => {
const newState = [...prev];
newState[index] = true;
return newState;
});
}
In my container component I have a state that gets initialized with an object that I use as data.
I clone the state array to prevent the initial state from being mutated but it still gets mutated, which I don't want to happen since I will need to compare the current state with the initial state later on.
The who system is kept inside the CubeOfTruthSystem component
function CubeOfTruthSystem() {
const [cubeIndex, setCubeIndex] = useState(0);
const [faceIndex, setFaceIndex] = useState(0);
return (
<React.Fragment>
<CubeSelector handleClick={(index) => setCubeIndex(index)} />
<CubeContainer cubeIndex={cubeIndex} faceIndex={faceIndex} />
<FaceSelector handleClick={(index) => setFaceIndex(index)} />
<button id="reset-face" onClick={() => console.log(CubeOfTruth)}>
Reset
</button>
</React.Fragment>
);
}
The parent component for the state looks like this:
function CubeContainer({ cubeIndex, faceIndex }) {
const [cube, setCube] = useState(CubeOfTruthData);
const handleCellClick = (id, row) => {
const cubeClone = [...cube];
const item = cubeClone[cubeIndex].faces[faceIndex].data[0].find(
(item) => item.id === id
);
item.state = "active";
cubeClone[cubeIndex].faces[faceIndex].data = activateAdjacentCells(
id,
row,
cubeClone[cubeIndex].faces[faceIndex].data,
item
);
setCube(cubeClone);
};
return (
<div id="cube-container">
{cube[cubeIndex].faces[faceIndex].data.map((row) => {
return row.map((item) => {
return (
<CubeItem item={item} handleClick={handleCellClick} key={item.id} />
);
});
})}
</div>
);
}
And this is the child component
function CubeItem({ item, handleClick }) {
const handleBgClass = (cellData) => {
if (cellData.state === "inactive") {
return cellData.bg + "-inactive";
} else if (cellData.state === "semi-active") {
return cellData.bg + "-semi-active";
} else {
return cellData.bg;
}
};
return (
<button
className={`cell-item ${handleBgClass(item)}`}
disabled={item.state === "inactive" ? true : false}
onClick={() => handleClick(item.id, item.row)}
/>
);
}
In the CubeOfTruth component, I'm trying to get the initial state (which is the CubeOfTruth array), but after changing the state, cube, cubeClone and CubeOfTruth all have the same values.
How can I make sure CubeOfTruth never gets mutated?
You're trying to clone cube array but you're making a shallow copy of it.
If you want to prevent mutation of nested properties you should make a deep copy instead.
Replace this:
const cubeClone = [...cube];
With this:
const cubeClone = JSON.parse(JSON.stringify(cube));
Or use some library like lodash
const cubeClone = _.cloneDeep(cube)
Hello i am currently facing this situation.
here is the simplified version of the code.
const Parent = ({prop}) => {
const [listOfBool, setListOfBool] = useState([true, false])
const handleCallback = (e, ev) => {
var cloneListOfBool = [...listOfBool]
cloneListOfBool[e] = ev
setListOfBool(cloneListOfBool)
}
return (
<div>
<p>{prop}</p>
<div>{listOfBool.map((bool, idx) => <Child key={idx} prop1={idx} activeProp={bool} parentCallback={handleCallback} />)}</div>
</div>
)
}
this is the child component
const Child = ({prop1, activeProp, parentCallback}) => {
const [active, setActive] = useState(activeProp)
const setThis = (e) => {
if (active === true){
parentCallback(e, false)
setActive(false)
} else {
parentCallback(e, true)
setActive(true)
}
}
return (
<>
<p className={`${active === true ? 'selected' : ''}`} onClick={() => setThis(prop1)}>{prop1}</p>
</>
)
}
prop1 is a number, i use that number to acces the array and change its value.
in the parent i set the list of boolean values, i map through them and i create the childrens. Now when the props of the parent changes, i would like to re render every child . Everything works as i want except for this part. Later on i will need to make a request to get the list of bools. Can you tell me waht is wrong, i have tried a couple of different solutions with no succes. Thank You
You can use key to force a react component to rerender.
So in this case if prop is the parent prop that you want to listen to, you can do something similar to:
<div>
{listOfBool.map((bool, idx) => (
<Child
key={`idx_${prop}`}
prop1={idx}
active={bool}
parentCallback={handleCallback}
/>
))}
</div>
May I ask why you want the component to rerender on a prop it does not use? As much as possible, you should just pass that property to the child, as a setup like this implies some sort of side effect that might be hard to spot.
Update (see comments)
const Parent = ({prop}) => {
const [listOfBool, setListOfBool] = useState([true, false])
const setActiveAtIndex = (idx, active) => {
setListOfBool((list) => {
const newList = [...list]
newList[idx] = active
return newList
})
}
return (
<div>
<p>{prop}</p>
<div>
{listOfBool.map((bool, idx) => (
<Child
key={idx}
prop1={idx}
activeProp={bool}
setActive={active => setActiveAtIndex(idx, active)}
/>
))}
</div>
</div>
)
}
const Child = ({prop1, active, setActive, parentCallback}) => {
return (
<>
<p className={`${active ? 'selected' : ''}`} onClick={() => setActive(!active)}>{prop1}</p>
</>
)
}
There are few problems with your code. I think the children are re-rendered, but if you expect the className name to change that's not gonna happed.
prop1 is a number, but you are using a === equality when setting active. This means that it will always be false.
The second problem is that you're deconstructing props and get a var named active and then declare another one with the same name, shadowing the prop one.
You should have only one source of truth and that's the parent in this case. There is no need for a child state.
const Child = ({prop1, active, parentCallback}) => {
return (
<>
<p className={`${active === true ? 'selected' : ''}`} onClick={() => parentCallback(prop1, !active)}>{prop1}</p>
</>
)
}
And also, prop1 is a bad name.
I am creating a custom multiple choice question, but I am having difficulties updating my selection choice using useState.
const QuestionPage = ({ audioFiles }) => {
const [choice, setChoice] = useState(-1); // -1 is when none of the choices are selected
const updateChoice = val => {
setChoice(val);
}
return (
// each MultipleChoice contains an audio file and a radio button
<MultipleChoice audioFiles={audioFiles} choice={choice} updateChoice={updateChoice} />
)
};
const MultipleChoice = ({ audioFiles, choice, updateChoice }) => {
const answerOption = audioFiles.map((item, key) =>
<AudioButton file={file} index={key} choice={choice} updateChoice={updateChoice} />
);
return (
{answerOption}
);
}
const AudioButton = ({ file, index, choice, updateChoice }) => {
const handleClick = (val) => {
updateChoice(val);
};
const radioButton = (
<div className={`${index === choice ? "selected" : ""}`} onClick={() => handleClick(index)}>
</div>
);
return (
<>
{radioButton}
<Audio file={file} />
</>
);
}
In the first function, QuestionPage within updateChoice, when I use console.log(val), it updates according to the selections I make (i.e. 0 and 1). However, when I call console.log(choice), it keeps printing -1.
In addition, I keep getting an error message that says updateChoice is not a function.
Any advice? Thanks in advance!
Looks like you did not pass the value of audioFiles in MultipleChoice function
Can't manage to make useRef/createRef to get any other div's other then what was added last. How can i make it so when the button is clicked the ref to the div changes.
I've tried with both useRef and createRef. Since I want to make a new instance of ref, i've looked more into createRef rather then useRef.
I've also played around useEffect. But my solution didn't help me with my biggest problem
I have made a small project containing 3 components to help you understand what I'm trying to explain.
I also have a database containing mock data -> in my real project this isn't the problem. It's an array containing objects.
[{'id':'1', 'name':'first'},...]
Main:
const MainComponent = () => {
const dataRef = React.createRef(null)
React.useEffect (() => {
if(dataRef && dataRef.current){
dataRef.current.scrollIntoView({ behavior:'smooth', block:'start' })
}
},[dataRef])
const _onClick = (e) => {
dataRef.current.focus();
}
return(
<>
{data && data.map((entry, index) =>{
return <ButtonList
key={index}
entry={entry}
onClick={_onClick}
/>
})}
{data && data.map((entry, index) =>{
return <ListingAllData
key={index}
dataRef={dataRef}
entry={entry}
index={index}/>
})}
</>
)
}
Button Component
const ButtonList = ({ entry, onClick }) => {
return <button onClick={onClick}>{entry.name}</button>
}
Listing data component
const ListingAllData = (props) => {
const {entry, dataRef } = props;
return (
<div ref={dataRef}>
<p>{entry.id}</p>
<p>{entry.name}</p>
</div>
);
}
I've console logged the data.current, it only fetches the last element. I hoped it would fetch the one for the button I clicked on.
I think the main idea here is to create dynamic refs for each element (array of refs), that's why only the last one is selected when app renders out.
const MainComponent = () => {
const dataRefs = [];
data.forEach(_ => {
dataRefs.push(React.createRef(null));
});
const _onClick = (e, index) => {
dataRefs[index].current.focus();
dataRefs[index].current.scrollIntoView({
behavior: "smooth",
block: "start"
});
};
return (
<>
{data &&
data.map((entry, index) => {
return (
<ButtonList
key={index}
entry={entry}
onClick={e => _onClick(e, index)}
/>
);
})}
{data &&
data.map((entry, index) => {
return (
<>
<ListingAllData
key={index}
dataRef={dataRefs[index]}
entry={entry}
index={index}
/>
</>
);
})}
</>
);
};
Created working example in code sandbox.
https://codesandbox.io/s/dynamic-refs-so25v
Thanks to Janiis for the answer, my solution was:
in MainComponent
...
const refs = data.reduce((acc, value) => {
acc[value.id] = React.createRef();
return entry;
}, {});
const _onClick = id => {
refs[id].current.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
....
then i passed it through to the child and referred like
<div ref={refs[entry.id]}>