Home  >  Q&A  >  body text

React nested accordion parent not updating height

I'm trying to build a mobile version of my homepage and there seems to be a bug with my nested accordion "items" where it doesn't display the correct height of the bottom item section when first opened.

To open it, you first click on the project text, then it lists the projects, then click on the project to toggle the project card.

(Updated) I believe this is happening because my parent accordion is not re-updating its height when the child accordion is opened.

Do you know a good way to do this? Or if necessary, should I restructure my components in a way that makes this possible? The difficulty is that the Accordion accepts children, and I'm reusing the Accordion within it, so it's quite confusing. I know I can use a callback function to trigger the parent, but not quite sure how to go about this.

Home.tsx

import { Accordion } from "@/components/atoms/Accordion"
import { AccordionGroup } from "@/components/atoms/AccordionGroup"
import { AccordionSlideOut } from "@/components/atoms/AccordionSlideOut"
import { Blog } from "@/components/compositions/Blog"
import { Contact } from "@/components/compositions/Contact"
import { Portfolio } from "@/components/compositions/Portfolio"
import { PuyanWei } from "@/components/compositions/PuyanWei"
import { Resumé } from "@/components/compositions/Resumé"
import { Socials } from "@/components/compositions/Socials"
import { Component } from "@/shared/types"

interface HomepageProps extends Component {}

export function Homepage({ className = "", testId = "homepage" }: HomepageProps) {
  return (
    <main className={`grid grid-cols-12 pt-24 ${className}`} data-testid={testId}>
      <section className="col-span-10 col-start-2">
        <AccordionGroup>
          <Accordion title="Puyan Wei">
            <PuyanWei />
          </Accordion>
          <Accordion className="lg:hidden" title="Portfolio">
            <Portfolio />
          </Accordion>
          <AccordionSlideOut className="hidden lg:flex" title="Portfolio">
            <Portfolio />
          </AccordionSlideOut>
          <Accordion title="Resumé">
            <Resumé />
          </Accordion>
          <Accordion title="Contact">
            <Contact />
          </Accordion>
          <Accordion title="Blog">
            <Blog />
          </Accordion>
          <Accordion title="Socials">
            <Socials />
          </Accordion>
        </AccordionGroup>
      </section>
    </main>
  )
}

Portfolio.tsx

import { Accordion } from "@/components/atoms/Accordion"
import { AccordionGroup } from "@/components/atoms/AccordionGroup"
import { ProjectCard } from "@/components/molecules/ProjectCard"
import { projects } from "@/shared/consts"
import { Component } from "@/shared/types"

interface PortfolioProps extends Component {}

export function Portfolio({ className = "", testId = "portfolio" }: PortfolioProps) {
  return (
    <AccordionGroup className={`overflow-hidden ${className}`} testId={testId}>
      {projects.map((project, index) => (
        <Accordion title={project.title} key={`${index}-${project}`} headingSize="h2">
          <ProjectCard project={project} />
        </Accordion>
      ))}
    </AccordionGroup>
  )
}

AccordionGroup.tsx - The purpose of an AccordionGroup is to allow only one child Accordion to be open at a time. If an Accordion is not in an AccordionGroup, it can be opened and closed independently.

"use client"

import React, { Children, ReactElement, cloneElement, isValidElement, useState } from "react"
import { AccordionProps } from "@/components/atoms/Accordion"
import { Component } from "@/shared/types"

interface AccordionGroupProps extends Component {
  children: ReactElement<AccordionProps>[]
}

export function AccordionGroup({
  children,
  className = "",
  testId = "accordion-group",
}: AccordionGroupProps) {
  const [activeAccordion, setActiveAccordion] = useState<number | null>(null)

  function handleAccordionToggle(index: number) {
    setActiveAccordion((prevIndex) => (prevIndex === index ? null : index))
  }

  return (
    <div className={className} data-testid={testId}>
      {Children.map(children, (child, index) =>
        isValidElement(child)
          ? cloneElement(child, {
              onClick: () => handleAccordionToggle(index),
              isActive: activeAccordion === index,
              children: child.props.children,
              title: child.props.title,
            })
          : child
      )}
    </div>
  )
}

Accordion.tsx

"use client"
import { Component } from "@/shared/types"
import React, { MutableRefObject, ReactNode, RefObject, useEffect, useRef, useState } from "react"
import { Heading } from "@/components/atoms/Heading"

export interface AccordionProps extends Component {
  title: string
  children: ReactNode
  isActive?: boolean
  onClick?: () => void
  headingSize?: "h1" | "h2"
}

export function Accordion({
  className = "",
  title,
  children,
  isActive,
  onClick,
  headingSize = "h1",
  testId = "Accordion",
}: AccordionProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [height, setHeight] = useState("0px")
  const contentHeight = useRef(null) as MutableRefObject<HTMLElement | null>

  useEffect(() => {
    if (isActive === undefined) return
    isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
  }, [isActive])

  function handleToggle() {
    if (!contentHeight?.current) return
    setIsOpen((prevState) => !prevState)
    setHeight(isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`)
    if (onClick) onClick()
  }
  return (
    <div className={`w-full text-lg font-medium text-left focus:outline-none ${className}`}>
      <button onClick={handleToggle} data-testid={testId}>
        <Heading
          className="flex items-center justify-between"
          color={isActive ? "text-blue-200" : "text-white"}
          level={headingSize}
        >
          {title}
        </Heading>
      </button>
      <div
        className={`overflow-hidden transition-max-height duration-250 ease-in-out`}
        ref={contentHeight as RefObject<HTMLDivElement>}
        style={{ maxHeight: height }}
      >
        <div className="pt-2 pb-4">{children}</div>
      </div>
    </div>
  )
}

ProjectCard.tsx

import Image from "next/image"
import { Card } from "@/components/atoms/Card"
import { Children, Component, Project } from "@/shared/types"
import { Subheading } from "@/components/atoms/Subheading"
import { Tag } from "@/components/atoms/Tag"
import { Text } from "@/components/atoms/Text"

interface ProjectCardProps extends Component {
  project: Project
}

export function ProjectCard({
  className = "",
  testId = "project-card",
  project,
}: ProjectCardProps) {
  const {
    title,
    description,
    coverImage: { src, alt, height, width },
    tags,
  } = project
  return (
    <Card className={`flex min-h-[300px] ${className}`} data-testid={testId}>
      <div className="w-1/2">
        <CoverImage className="relative w-full h-full mb-4 -mx-6-mt-6">
          <Image
            className="absolute inset-0 object-cover object-center w-full h-full rounded-l-md"
            src={src}
            alt={alt}
            width={parseInt(width)}
            height={parseInt(height)}
            loading="eager"
          />
        </CoverImage>
      </div>
      <div className="w-1/2 p-4 px-8 text-left">
        <Subheading className="text-3xl font-bold" color="text-black">
          {title}
        </Subheading>
        <Tags className="flex flex-wrap pb-2">
          {tags.map((tag, index) => (
            <Tag className="mt-2 mr-2" key={`${index}-${tag}`} text={tag} />
          ))}
        </Tags>
        <Text color="text-black" className="text-sm">
          {description}
        </Text>
      </div>
    </Card>
  )
}

function CoverImage({ children, className }: Children) {
  return <div className={className}>{children}</div>
}
function Tags({ children, className }: Children) {
  return <div className={className}>{children}</div>
}

Any help would be greatly appreciated, thank you!

P粉293341969P粉293341969263 days ago325

reply all(2)I'll reply

  • P粉998100648

    P粉9981006482024-02-22 10:41:59

    problem analysis:

    TL;DR: The parent accordion needs to know about these changes so that it can adjust its height accordingly.

    I think you might be using amiut/accordionify as shown via "Create lightweight React Accordions" From Amin A. Rezapour.
    This is the only project I've found that uses AccordionGroup.

    The nested accordion structure in the application involves a parent-child relationship, where the height of the child accordion changes dynamically depending on whether it is expanded or collapsed.

    This can be illustrated by your Portfolio.tsx where the AccordionGroup component contains multiple Accordion component projects created based on array. These Accordion components are the "child" accordions mentioned:

    export function Portfolio({ className = "", testId = "portfolio" }: PortfolioProps) {
      return (
        <AccordionGroup className={`overflow-hidden ${className}`} testId={testId}>
          {projects.map((project, index) => (
            <Accordion title={project.title} key={`${index}-${project}`} headingSize="h2">
              <ProjectCard project={project} />
            </Accordion>
          ))}
        </AccordionGroup>
      )
    }
    

    Each child Accordion contains a ProjectCard that displays the project details. When the user clicks on the Accordion (or "Project"), it expands to show the ProjectCard.
    This is where height changes come into play; the accordion will expand or collapse based on user interaction, changing its height dynamically.

    Dynamic height is managed in Accordion.tsx:

      function handleToggle() {
        if (!contentHeight?.current) return
        setIsOpen((prevState) => !prevState)
        setHeight(isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`)
        if (onClick) onClick()
      }
    

    When the handleToggle function is called, it checks whether the accordion is currently open (isOpen). If it is, the height is set to "0px" (i.e. accordion collapsed). If not open, the height is set to the scroll height of the content (i.e. the accordion is expanded).

    The dynamic height changes of these children's accordions are a key part of your problem. The parent accordion needs to know about these changes so that it can adjust its height accordingly.

    I see in the same Accordion.tsx:

      useEffect(() => {
        if (isActive === undefined) return
        isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
      }, [isActive])
    

    The height of the accordion is set according to the isActive property, which indicates whether the accordion is currently open. If on, the height is set to the scroll height of the accordion content (effectively an expanded accordion), if not activated the height is set to 0px (a collapsed accordion).

    However, while this effect correctly adjusts the height of each accordion based on its own isActive state, it does not take into account changes in the height of the child accordions.

    When a nested (child) accordion changes its height (due to expansion or collapse), the height of the parent accordion is not recalculated and therefore does not adjust to fit the new height of the parent's child.

    In other words, when the height of the child accordion changes, the parent accordion doesn't know that it needs to re-render and adjust its height. Lack of re-rendering when a nested accordion is expanded or collapsed causes the parent accordion to not display the correct height.

    Possible solutions

    TL;DR: The solution involves making the parent aware of the child accordion's height changes so that it can adjust its own height accordingly.

    ("React: One of the techniques mentioned in Force component re-rendering | 4 easy ways " from Josip Miskovic)

    Your Accordion component can benefit from a callback function prop that is called when its height changes, such as onHeightChange. Then, in the Portfolio component, you can propagate this height change up to the Homepage component by passing a new callback function to the Accordion component, using onHeightChange property.

    accordion.tsx

    export interface AccordionProps extends Component {
      title: string
      children: ReactNode
      isActive?: boolean
      onClick?: () => void
      onHeightChange?: () => void
      headingSize?: "h1" | "h2"
    }
    
    export function Accordion({
      className = "",
      title,
      children,
      isActive,
      onClick,
      onHeightChange,
      headingSize = "h1",
      testId = "Accordion",
    }: AccordionProps) {
      // ...
    
      useEffect(() => {
        if (isActive === undefined) return
        isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
        if(onHeightChange) onHeightChange() // Call the onHeightChange callback
      }, [isActive])
    
      // ...
    }
    

    Then modify your Portfolio component to propagate the height change event:

    export function Portfolio({ className = "", testId = "portfolio", onHeightChange }: PortfolioProps & {onHeightChange?: () => void}) {
      return (
        <AccordionGroup className={`overflow-hidden ${className}`} testId={testId}>
          {projects.map((project, index) => (
            <Accordion 
              title={project.title} 
              key={`${index}-${project}`} 
              headingSize="h2"
              onHeightChange={onHeightChange} // Propagate the height change event
            >
              <ProjectCard project={project} />
            </Accordion>
          ))}
        </AccordionGroup>
      )
    }
    

    Finally, you can add a key to the Portfolio accordion on the home page that will change when the height change event is triggered. This will cause the accordion to re-render:

    export function Homepage({ className = "", testId = "homepage" }: HomepageProps) {
      const [key, setKey] = useState(Math.random());
      //...
    
      return (
        //...
        <Accordion className="lg:hidden" title="Portfolio" key={key}>
          <Portfolio onHeightChange={() => setKey(Math.random())} />
        </Accordion>
        //...
      )
    }
    

    This way, you will force the parent Accordion component to re-render whenever the height of the child Accordion changes.

    reply
    0
  • P粉649990273

    P粉6499902732024-02-22 10:08:40

    You know, the implementation here is a bit challenging because when you want to update the height of a grandparent accordion from its child accordion, you can't really know from there which corresponding grandparent accordion you want to update unless you pass the props Give the grandparent accordion, and pass the props to the intermediate component (e.g. Portfolio, the parent of the child accordion) so that it can propagate them to its child accordion.
    By doing this we can allow grandparents and child accordions to communicate in some way.
    Maybe this isn't the best solution you can find, but sadly I can't think of a better one.


    So to recap: the idea is to create a state at the top level to hold a reference to the height of each parent accordion, so it's an array where the length is set "manually", which makes it somewhat ugly , but if you have to use a data array to display your component dynamically, then this is not a problem, as we will find out later, we will also see the limitations of the workaround.


    Solution:

    Now we'll go with the simplest, most straightforward fix that works for what's included in the question.

    As mentioned above, first we create the state in the HomePage component:

    const [heights, setHeights] = useState(Array(7).fill("0px")); // you have 7 parent Accordions
    

    After creating the array state at the top level, now we pass the state setting function setHeights, index indexx and the corresponding height heightParent to each Accordion component if It is the parent Accordion

    <AccordionGroup>
      <Accordion title="Puyan Wei" heightParent={heights[0]} setHeights={setHeights} indexx="0">
          <PuyanWei setHeights={setHeights} indexx="0" />
      </Accordion>
      <Accordion title="Portfolio" heightParent={heights[1]} setHeights={setHeights} indexx="1">
          <Portfolio setHeights={setHeights} indexx="1" />
      </Accordion>
      //...
      <Accordion title="Socials" heightParent={heights[6]} setHeights={setHeights} indexx="6">
          <Socials setHeights={setHeights} indexx="6" />
      </Accordion> 
    </AccordionGroup>
    

    Note: The indexx attribute passed to the parent and the indexx attribute passed to the intermediate component (Portfolio) should have the same value represents the corresponding index, which is actually the key to the solution.
    Named "indexx" with two "x"s in it to avoid future conflicts.

    Then, pass these received props from the middle component to the child accordion:

    export function Portfolio({
      className = "",
      testId = "portfolio",
      indexx,
      setHeight,
    }: PortfolioProps) {
      // update props interface
      return (
        <AccordionGroup className={`overflow-hidden ${className}`} testId={testId}>
          {projects.map((project, index) => (
            <Accordion
              title={project.title}
              key={`${index}-${project}`}
              headingSize="h2"
              indexx={indexx}
              setHeight={setHeight}
            >
              <ProjectCard project={project} />
            </Accordion>
          ))}
        </AccordionGroup>
      );
    }
    

    Now, from your child Accordion component, you can utilize the passed indexx property to update the height of the corresponding Accordion parent in the HomePage state, so when we update the child height, we also update the parent high

    function handleToggle() {
      if (!contentHeight?.current) return;
      setIsOpen((prevState) => !prevState);
      let newHeight = isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`;
      if (!heightParent) { // there is no need to update the state when it is a parent Accordion
        setHeight(newHeight);
      }
      setHeights((prev) =>
        prev.map((h, index) => (index == indexx ? newHeight : h))
      );
    }
    

    Finally, when you specify the height of an Accordion, you can check if it receives heightParent as props so that we know it is the parent, this way, we make the Accordion component use heightParent As maxHeight instead of its own state height (if it is the parent state), this is why updating the height state is ignored when it is open parent Accordion, therefore, we have to change the way the maxHeight property is set, now it should depend on the nature of the Accordion:

    style={{ maxHeight: `${heightParent? heightParent : height }` }}
    

    If you want the parent Accordion just use its state height as maxHeight and keep the code the same, it makes more sense

    style={{ maxHeight: height }}
    

    You can still do this by adding a useEffect in the Accordion component and making sure it only runs when the received heightParent property is updated and defined, we do this to ensure the code Run only if the parent accordion should update its height state:

    useEffect(()=>{
     if(heightParent){
       setHeight(heightParent)
     }
    },[heightParent])
    

    As mentioned above, this makes more sense and is also the prettiest, but I still prefer the first one as it's simpler and also saves an extra render.


    Dynamic data processing:

    If we store data in an array and want to display our component based on this, we can do this:

    const data = [...]
    const [heights, setHeights] = useState(data.map(_ => "0px")); 
    //...
    <section className="col-span-10 col-start-2">
     <AccordionGroup>
      {data.map(element, index => {
         <Accordion key={index} title={element.title} heightParent={heights[index]} setHeights={setHeights} indexx={index} >
           {element.for === "portfolio" ? <Portfolio setHeights={setHeights} indexx={index} /> : <PuyanWei setHeights={setHeights} indexx={index} /> }  // just an example
         </Accordion>
      })
      }
     </AccordionGroup>
    </section>
    

    You can notice that we have to specify a key in the parent accordion so that we can use it instead of indexx, but you know key > The property is special and we don't want to mess with it no matter what, I hope you understand


    limit:

    Obviously this solution only works for one level, so if the sub-accordion itself becomes a sub-accordion you have to wrap around it again, but if I understand what you are doing you probably won't face this Situation, because with your implementation the child Accordion should show the items, but who knows maybe one day you will need to make it return another child Accordion, that's why I think my suggestion is a workaround and not the best solution.


    Like I said, this might not be the best solution, but to be honest, especially for this implementation, I don't think such a multi-level working solution exists, please prove me wrong Yes, I am following this post.

    reply
    0
  • Cancelreply