ckeller22
ckeller22

Reputation: 31

How to prevent shared state in a parent component from breaking CSS transitions in children component

I am currently working on building a portfolio website utilizing React and Tailwind. I am attempting to implement a mobile menu button and dropdown menu that animates using CSS transitions. I am conditionally applying an "open" class to the component using the classnames library based on the state set in the parent element. Unfortunately, whenever the state in the parent element is changed, i.e. the button is clicked and the state is updated, the CSS updates instantaneously instead of transitioning like expected. The transforms are applied immediately instead of over time.

I am able to enable/disable CSS rules to force the transition to animate in the inspector. If I remove the state hook from the parent element and utilize individual state in each children component, the transitions will also play out properly individually, but I cannot figure out how to make both transitions play simultaneously.

I've been able to track it down and I assume that it is because the parent component is rendering again and stopping the transition in it's tracks, but I cannot figure out how to circumvent the update from the resulting state change. I have tried memoize the parent component, but it still fails to render properly.

Any help in figuring out how to get the transitions to play properly would be greatly appreciated.

Parent element:

...
  const [mobileNavOpen, setMobileNavOpen] = useState(false);

  const handleMobileMenuClick = () => {
    setMobileNavOpen(!mobileNavOpen);
  };
...
   return (
    <nav>
      <CenteredContainer>
        {/* Padding for absolute elements don't inherit. If padding needs to be changed on center container, also change it here  */}
        <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-6 pt-4 mx-auto md:pt-2 md:px-0">
          <Logo />
          <NavLinks />
          <MobileMenuButton open={mobileNavOpen} />
        </div>
      </CenteredContainer>
      <MobileNavMenu open={mobileNavOpen} />
    </nav>
  );

Child elements

const MobileMenuButton = ({ open }) => {
    const tailwindClasses = "flex md:hidden mt-2 ";

    var animateMobileMenu = classNames(`${tailwindClasses} nav-icon`, {
      open: open,
    });

    return (
      <div onClick={handleMobileMenuClick} className={animateMobileMenu}>
        <span></span>
        <span></span>
        <span></span>
      </div>
    );
  };

  const MobileNavMenu = ({ open }) => {
    const tailwindClasses =
      "h-1/2 w-5/6 bg-white right-0 left-0 top-20 mx-auto flex flex-col z-20 ";

    const displayMobileNavLinks = classNames(
      `${tailwindClasses} mobile-nav-menu`,
      {
        open: open,
      }
    );

    return (
      <div className={displayMobileNavLinks}>
        <ul>
          <li>Test</li>
          <li>Test</li>
          <li>Test</li>
          <li>Test</li>
          <li>Test</li>
        </ul>
      </div>
    );
  };

CSS:

  .nav-icon {
    width: 40px;
    height: 40px;
    position: relative;
    cursor: pointer;
  }

  .nav-icon span {
    position: absolute;
    height: 15%;
    width: 100%;
    background-color: #6ee7b7;
    border-radius: 9px;
    opacity: 1;
    left: 0;
    transform: rotate(0deg);
    transition: all 0.5s ease;
  }

  .nav-icon span:nth-child(1) {
    top: 0%;
    transform-origin: left center;
  }

  .nav-icon span:nth-child(2) {
    top: 35%;
    transform-origin: left center;
  }

  .nav-icon span:nth-child(3) {
    top: 70%;
    transform-origin: left center;
  }

  .nav-icon.open span:nth-child(1) {
    transform: rotate(45deg);
    left: 8px;
  }

  .nav-icon.open span:nth-child(2) {
    width: 0%;
    opacity: 0;
  }

  .nav-icon.open span:nth-child(3) {
    transform: rotate(-45deg);
    left: 8px;
  }

Upvotes: 2

Views: 528

Answers (2)

semihlt
semihlt

Reputation: 154

Can you try changing your jsx in your parent component from this:

<MobileMenuButton open={mobileNavOpen} />

to this:

{MobileMenuButton({ open: mobileNavOpen })}

Call your child component like a function

Upvotes: 1

silencedogood
silencedogood

Reputation: 3299

In most cases, if a child component triggers a state change in the parent (mobileNavOpen in your case), the child and any sibling components should be able to rely on utilizing the updated state without issue.

However, you have stumbled across an edge case where the multiple renders (and more specifically, the order of those renders) triggered by this pattern hoses up your CSS transitions.

Without conditionally rendering components, there's no way to dictate which component will render first or last.

As noted here:

This is because the React reconciliation algorithm follows a depth-first traversal to beginWork and a component’s rendering is complete (completeWork) only once all its children’s rendering is complete. As a result, the root component in your tree will always be the last one to complete render.

This means your parent component will be the last to render; it's not hard to imagine this cutting off the CSS transitions in the children.

You mentioned using React.memo on the parent, but this is the equivalent of shouldComponentUpdate and will simply stop the parent from re-rendering, which will keep the new state from being passed as props to the children; in turn defeating the purpose of the state update which engages your transitions.

Using React.memo on the children is similarly counterintuitive. If the child doesn't re-render, the CSS class isn't updated; no transition.

In this case, you'll need to refactor your components to ensure the state change (and subsequent rerender) only occurs in the child component with the transition; or simply reduce all of this into a single component.

Since your MobileMenuButton, which triggers the state update, is distinctly separate from the MobileNavMenu, I think you're better suited for the latter.

You probably don't need to see the code but I'll post it anyways. You could consider converting this to a class component to clean things up a bit

const [mobileNavOpen, setMobileNavOpen] = useState(false);

const tailwindClassesBtn = "flex md:hidden mt-2 ";

const animateMobileMenu = classNames(`${tailwindClassesBtn} nav-icon`, 
{
  open: mobileNavOpen,
});

const tailwindClasses =
  "h-1/2 w-5/6 bg-white right-0 left-0 top-20 mx-auto flex flex-col z-20 ";

const displayMobileNavLinks = classNames(
  `${tailwindClasses} mobile-nav-menu`,
  {
    open: mobileNavOpen,
  }
);

const handleMobileMenuClick = () => {
  setMobileNavOpen(!mobileNavOpen);
};

return (
<nav>
  <CenteredContainer>
    {/* Padding for absolute elements don't inherit. If padding needs to be changed on center container, also change it here  */}
    <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-6 pt-4 mx-auto md:pt-2 md:px-0">
      <Logo />
      <NavLinks />
      <div onClick={handleMobileMenuClick} className={animateMobileMenu}>
        <span></span>
        <span></span>
        <span></span>
      </div>
    </div>
  </CenteredContainer>
  <div className={displayMobileNavLinks}>
    <ul>
      <li>Test</li>
      <li>Test</li>
      <li>Test</li>
      <li>Test</li>
      <li>Test</li>
    </ul>
  </div>
</nav>
);

You could use some trickery by transposing two elements, combined with react transition group, but IMO this would over-complicate things.

Upvotes: 0

Related Questions