2
votes

Let's say we have a component Accordion that has an internal state isOpen, so you can close and open this component.

We now want to have a parent component that also has a state isOpen and has button. In this component, we have 2 times Accordion and we are passing to Accordion isOpen and we want that if the parent changes state isOpen Accordion accept this.

All component are functional components

 const Accordion = ({ isOpen: parentIsOpen = false }) => {
    const [isOpen, setIsOpen] = useState(parentIsOpen);
    const handleSetIsOpen = () => setIsOpen(!isOpen);
    return (
      <div>
        I'm open: {isOpen}
        <button onClick={handleSetIsOpen}>toggle isOpen child</button>
      </div>
    );
  };

  const MasterComponent = () => {
    const [isOpen, setIsOpen] = useState(false);
    const handleSetIsOpen = () => setIsOpen(!isOpen);
    return (
      <div>
        <button onClick={handleSetIsOpen}>toggle isOpen parent</button>
        <Accordion isOpen={isOpen} />
        <Accordion isOpen={isOpen} />
      </div>
    );
  };

In this case above Accordion will take on first render as the initial state parent isOpen prop. In case we press the button toggle isOpen parent we will change the parent state but children will not be updated.

To fix this we can use useEffect

  const Accordion = ({ isOpen: parentIsOpen = false }) => {
    const [isOpen, setIsOpen] = useState(parentIsOpen);
    const handleSetIsOpen = () => setIsOpen(!isOpen);

    useEffect(() => {
      if (parentIsOpen !== isOpen) {
        setIsOpen(parentIsOpen);
      }
    }, [parentIsOpen]);

    return (
      <div>
        I'm open: {isOpen}
        <button onClick={handleSetIsOpen}>toggle isOpen child</button>
      </div>
    );
  };

  const MasterComponent = () => {
    const [isOpen, setIsOpen] = useState(false);
    const handleSetIsOpen = () => setIsOpen(!isOpen);
    return (
      <div>
        <button onClick={handleSetIsOpen}>toggle isOpen parent</button>
        <Accordion isOpen={isOpen} />
        <Accordion isOpen={isOpen} />
      </div>
    );
  };

in this case, children will be properly updated when a parent changes isOpen state.

There is one issue with this:

"React Hook useEffect has a missing dependency: 'isOpen'. Either include it or remove the dependency array react-hooks/exhaustive-deps"

So how to remove this issue that esLint is complaining and we do not want to put isOpen in this since it will cause bug.

in case we add isOpen into the array like this:

 useEffect(() => {
      if (parentIsOpen !== isOpen) {
        setIsOpen(parentIsOpen);
      }
    }, [parentIsOpen, isOpen]);

We will have then a situation where we will click on the internal button in accordion and update the internal state then useEffect will run and see that parent has a different state than the child and will immediately set the old state.

So basically you have a loop where the accordion will never be open then.

The question is what is the best way to update the child state based on the parent state?

Please do not suggest to put all-state in parent and pass props without child state. this will not work since both Accordions in this example have to have their own state and be able to open and close in an independent way, but yet if parent says close or open it should listen to that.

Thank you!

2

2 Answers

3
votes

Actually I would say this is way to do it

 const Accordion = ({ isOpen: parentIsOpen = false }) => {
    const [isOpen, setIsOpen] = useState(parentIsOpen);
    const handleSetIsOpen = () => setIsOpen(!isOpen);

    useEffect(() => {
        setIsOpen(parentIsOpen);
    }, [parentIsOpen]);

    return (
      <div>
        I'm open: {isOpen}
        <button onClick={handleSetIsOpen}>toggle isOpen child</button>
      </div>
    );
  };

  const MasterComponent = () => {
    const [isOpen, setIsOpen] = useState(false);
    const handleSetIsOpen = () => setIsOpen(!isOpen);
    return (
      <div>
        <button onClick={handleSetIsOpen}>toggle isOpen parent</button>
        <Accordion isOpen={isOpen} />
        <Accordion isOpen={isOpen} />
      </div>
    );
  };

So just remove state check in a child component, let him update the state but since is updated with the same value it will not rerender or do some expensive behavior.

Tested it today and with a check, if states are different or without is the same, react takes care to not trigger rerender if the state that is updated is the same as before.

1
votes

What you’re saying not to suggest is in fact the solution I would offer… You’ll need state to control isOpen for the parent component. Also, you should have separate methods in the parent that control state for each accordion, passed along to each accordion in props…

Not sure why you want separate state for the child components. I believe something like this would suffice.

const MasterComponent = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [isOpen1, setIsOpen1] = useState(false);
  const [isOpen2, setIsOpen2] = useState(false);

  const handleParentClose = () => {
    setIsOpen(false);
    setIsOpen1(false);
    setIsOpen2(false);
  }

  return (
    <div>
      <button onClick={handleParentClose}>toggle isOpen parent</button>
      <Accordion isOpen={isOpen1} setIsOpen={setIsOpen1} />
      <Accordion isOpen={isOpen2} setIsOpen={setIsOpen2} />
    </div>
 );
};

const Accordion = props => {
 return (
   <div>
    I'm open: {props.isOpen}
    <button onClick={props.setIsOpen}>toggle isOpen child</button>
   </div>
   );
  }

This doesn't include code for actual visibility toggle, but the sate is there.

EDIT: Added code that closes accordions on parent close.