I made a TagItem
JSXElement that color changes when a user clicks/touches.
It receives a prop isSelected
and it also has its own state selected
inside, which initial value is prop isSelected
.
Its style changes depending on selected
state but the style doesn't change when prop isSelected
value changes...
The reason I figured out is that its state doesn't change even when prop isSelected
changes.
I don't know why this happens. Does anyone know?
The code is like this.
type TagItemProps = {
tag: Tag;
isSelected: boolean;
onSelect: (tag: Tag) => boolean;
onDeselect: (tag: Tag) => void;
};
const TagItem = ({ tag, isSelected = false, onSelect, onDeselect }: TagItemProps) => {
const [selected, setSelected] = useState(isSelected);
console.log("isSelected: ", isSelected);
console.log("selected: ", selected);
const handleSelect = useCallback(() => {
if (!onSelect(tag)) return;
setSelected(true);
}, [onSelect, tag]);
const handleDeselect = useCallback(() => {
onDeselect(tag);
setSelected(false);
}, [onDeselect, tag]);
return (
<TouchableOpacity
style={[styles.container, selected ? styles.selected : null]}
onPress={selected ? handleDeselect : handleSelect}
>
<Text style={[styles.text, selected ? { color: '#fff' } : null]}>
{capitalizeFirstLetter(tag.name)}
</Text>
</TouchableOpacity>
);
};
export default TagItem;
const styles = StyleSheet.create({
container: {
flex: 0,
backgroundColor: '#fff',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 12,
margin: 5,
},
selected: {
backgroundColor: 'green',
},
text: {
textAlign: 'center',
fontWeight: 'bold',
},
});
As you can see from above, when state selected
is true, styles.selected
should be applied.
I confirm that prop isSelected
changes but not selected
state, by logging in console.
console.log("isSelected: ", isSelected); // false
console.log("selected: ", selected);
Does anyone know why this is happening?
CodePudding user response:
This is a classic case of copying props into local state -- which you usually do not want to do as it introduces a surface area for bugs, including the one you are seeing. If something is available as a prop -- what is the purpose of copying it into local state? You should instead use callbacks to alter wherever the state of that prop lives in the ancestors. Copying means you now have to manage keeping the local state and prop in sync -- which is why usually copying in the first place is an antipatttern.
The reason the state doesn't update when isSelected
changes is because the paramater to useState
is only its initial value. By design, even when a rerender occurs due to the prop changing, the state item wont update. Copying it into local state means its up to you to keep them in sync (common cause of bugs).
Two choices:
Option A
Dont copy props into state, so you dont even need to deal with making sure the props and internal state are in sync. Use isSelected
directly and remove the state item. To set the selected state, you will need to pass down into the component from the parent a callback over props -- which accepts the changed value and changes the state in that parent component. This gets rid of the pointless barrier inbetween the props and the actual thing you are rendering.
Option B
If you must keep a copy of the state around for some reason, make sure you update the state when the props change with an additional effect.
type TagItemProps = {
tag: Tag;
isSelected: boolean;
onSelect: (tag: Tag) => boolean;
onDeselect: (tag: Tag) => void;
};
const TagItem = ({ tag, isSelected = false, onSelect, onDeselect }: TagItemProps) => {
const [selected, setSelected] = useState(isSelected);
useEffect(() => {
setSelected(isSelected )
}, [isSelected ])
console.log("isSelected: ", isSelected);
console.log("selected: ", selected);
const handleSelect = useCallback(() => {
if (!onSelect(tag)) return;
setSelected(true);
}, [onSelect, tag]);
const handleDeselect = useCallback(() => {
onDeselect(tag);
setSelected(false);
}, [onDeselect, tag]);
return (
<TouchableOpacity
style={[styles.container, selected ? styles.selected : null]}
onPress={selected ? handleDeselect : handleSelect}
>
<Text style={[styles.text, selected ? { color: '#fff' } : null]}>
{capitalizeFirstLetter(tag.name)}
</Text>
</TouchableOpacity>
);
};
export default TagItem;
const styles = StyleSheet.create({
container: {
flex: 0,
backgroundColor: '#fff',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 12,
margin: 5,
},
selected: {
backgroundColor: 'green',
},
text: {
textAlign: 'center',
fontWeight: 'bold',
},
});
CodePudding user response:
Just leaving the code I ended up with (@Adam 's option A) and it works totally fine.
type TagItemProps = {
tag: Tag;
isSelected: boolean;
onSelect?: (tag: Tag) => boolean;
onDeselect?: (tag: Tag) => void;
};
const TagItem = ({ tag, isSelected, onSelect, onDeselect }: TagItemProps) => {
const handleSelect = useCallback(() => {
onSelect && onSelect(tag);
}, [onSelect, tag]);
const handleDeselect = useCallback(() => {
onDeselect && onDeselect(tag);
}, [onDeselect, tag]);
return (
<TouchableOpacity
style={[styles.container, isSelected ? styles.selected : null]}
onPress={isSelected ? handleDeselect : handleSelect}
>
<Text style={[styles.text, isSelected ? { color: '#fff' } : null]}>
{capitalizeFirstLetter(tag.name)}
</Text>
</TouchableOpacity>
);
};