Masa
Masa

Reputation: 362

Why React component doesn't re-render (change styles) when props changed?

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?

Upvotes: 0

Views: 4239

Answers (2)

adsy
adsy

Reputation: 11382

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 antipattern.

The reason the state doesn't update when isSelected changes is because the parameter 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

Don't copy props into state, so you don't 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 in between 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',
      },
});

Upvotes: 5

Masa
Masa

Reputation: 362

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>
  );
};

Upvotes: 2

Related Questions