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)
Related
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>
);
};
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;
});
}
I am following a course, sorting logic comes from "sortQuotes" function and storing its returned data to "sortedQuotes" constant, so how is it possible that even though I am not passing that modified data to JSX and still rendering original props.quotes - code works and pressing sorting button changes list to ascending/descending correctly?
const sortQuotes = (quotes, ascending) => {
return quotes.sort((quoteA, quoteB) => {
if (ascending) {
return quoteA.id > quoteB.id ? 1 : -1;
} else {
return quoteA.id < quoteB.id ? 1 : -1;
}
});
};
// props.quotes -----> [{id:'q1'},{id:'q2'},{id:'q3'}]
const QuoteList = props => {
const navigate = useNavigate();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const isSortingAscending = queryParams.get('sort') === 'asc';
console.log(props.quotes);
// Even before "sortQuotes" function call on the first render, props.quotes are still changed in order
const sortedQuotes = sortQuotes(props.quotes, isSortingAscending);
const changeSortingHander = () => {
navigate(`/quotes?sort=${!isSortingAscending ? 'asc' : 'des'}`);
};
return (
<>
<div className={classes.sorting}>
<button onClick={changeSortingHander}>
Sort {isSortingAscending ? 'Descending' : 'Ascending'}
</button>
</div>
<ul className={classes.list}>
{props.quotes.map(quote => (
<QuoteItem
key={quote.id}
id={quote.id}
author={quote.author}
text={quote.text}
/>
))}
</ul>
</>
);
};
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]}>