React issues with createRef for nested navigation + IntersectionObserver

Viewed 683

I have an intersectionObserver that watches some sections and highlights the corresponding navigation item. But I've only managed to get the "main sections Microsoft, Amazon working, but not the subsections Define, Branding, Design, Deduction. As seen in the gif below:

The reason why I want it structured this way is so that I can highlight the "main" sections if the subsections are in view.

Semi working demo: https://codesandbox.io/s/intersection-with-hooks-fri5jun1344-fe03x

Current

It might seems that I might be able to copy and paste the same functionality with the subsections as well. But I'm having a hard time wrapping my head around how to deal with nested data + useRef + reducer. I was wondering if someone could give me a pointer in the right direction.

Here is an gif of the desired effect. Notice the main title (Loupe, Canon) are still highlighted if one of the subsections are in view:

It all starts with an data array

const data = [
  {
    title: "Microsoft",
    id: "microsoft",
    color: "#fcf6f5",
    year: "2020",
    sections: ["define", "branding", "design", "deduction"]
  },
  {
    title: "Amazon",
    id: "amazon",
    color: "#FFE2DD",
    year: "2018",
    sections: ["define", "design", "develop", "deduction"]
  },
  {
    title: "Apple",
    id: "apple",
    color: "#000",
    year: "2020",
    sections: ["about", "process", "deduction"]
  }
];

App.js padding data object into reduce to create Refs

  const refs = data.reduce((refsObj, Case) => {
    refsObj[Case.id] = React.createRef();
    return refsObj;
  }, {});

My components passing in the props

          <Navigation
            data={data}
            handleClick={handleClick}
            activeCase={activeCase}
          />
          {data.map(item => (
            <Case
              key={item.id}
              activeCase={activeCase}
              setActiveCase={setActiveCase}
              refs={refs}
              data={item}
            />
          ))}

Case.js

export function Case({ data, refs, activeCase, setActiveCase }) {
  const components = {
    amazon: Amazon,
    apple: Apple,
    microsoft: Microsoft
  };

  class DefaultError extends Component {
    render() {
      return <div>Error, no page found</div>;
    }
  }
  const Tag = components[data.id] || DefaultError;

  useEffect(() => {
    const observerConfig = {
      rootMargin: "-50% 0px -50% 0px",
      threshold: 0
    };
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.target.id !== activeCase && entry.isIntersecting) {
          setActiveCase(entry.target.id);
        }
      });
    }, observerConfig);

    observer.observe(refs[data.id].current);
    return () => observer.disconnect(); // Clenaup the observer if unmount
  }, [activeCase, setActiveCase, refs, data]);

  return (
    <React.Fragment>
      <section
        ref={refs[data.id]}
        id={data.id}
        className="section"
        style={{ marginBottom: 400 }}
      >
        <Tag data={data} />
      </section>
    </React.Fragment>
  );
}

I've tried mapping the subsections like this but I get stuck at this part:

   const subRefs = data.map((refsObj, Case) => {
     refsObj[Case] = React.createRef();
     return refsObj;
   }, {});
2 Answers

Working Example

I've found a solution while trying to keep most of your logic intact. Firstly what you need to do is to store the subrefs (the sections ref) in the same object as your Case ref. So you will need an extra reduce function to create those inside the refs object:

App.js

const refs = data.reduce((refsObj, Case) => { // Put this outside the render
    const subRefs = Case.sections.reduce((subrefsObj, Section) => {
      subrefsObj[Section] = React.createRef();
      return subrefsObj;
    }, {});

    refsObj[Case.id] = {
      self: React.createRef(), // self is the Case ref, like Apple, Microsoft...
      subRefs // This is going to be the subrefs
    };
    return refsObj;
  }, {});

Then you add an extra state to handle which sub section is active, like const [activeSection, setActiveSection] = React.useState(); And you put it anywhere you also use the activeCase. You need that because you said that the Case and Sections need to work independently. (Both active at the same time).

Case.js

You will need to pass along the subrefs to the child components, so you do:

    <Tag data={data} subRefs={refs[data.id].subRefs} />

And you will also need the intersection observer for each of the subrefs. So your useEffect will look like:

 useEffect(() => {
    const observerConfig = {
      rootMargin: "-50% 0px -50% 0px",
      threshold: 0
    };

    const observerCallback = (entries, isCase) => {
      const activeEntry = entries.find(entry => entry.isIntersecting);

      if (activeEntry) {
        if (isCase) setActiveCase(activeEntry.target.id);
        else setActiveSection(activeEntry.target.id);
      } else if (isCase) {
        setActiveCase(null);
        setActiveSection(null);
      }
    };

    const caseObserver = new IntersectionObserver(
      entries => observerCallback(entries, true),
      observerConfig
    );
    caseObserver.observe(refs[data.id].self.current);

    const sectionObserver = new IntersectionObserver(
      entries => observerCallback(entries, false),
      observerConfig
    );

    Object.values(refs[data.id].subRefs).forEach(subRef => {
      sectionObserver.observe(subRef.current);
    });

    return () => {
      caseObserver.disconnect();
      sectionObserver.disconnect();
    }; // Clenaup the observer if unmount
  }, [refs, data]);

Then in your amazon/index.js ,microsoft/index.js and apple/index.js files. You pass along the ref again:

<Template
        data={this.props.data}
        caseSections={caseSections}
        subRefs={this.props.subRefs}
      />

Finally, in your template.js file you will have the following so you can assign the right ref:

const Template = props => {
  return (
    <React.Fragment>
      <div
        sx={{
          background: "#eee",
          transition: "background ease 0.5s"
        }}
      >
        {props.data.sections &&
          props.data.sections.map(subItem => (
            <Container
              ref={props.subRefs && props.subRefs[subItem]}
              id={`${props.data.id}--${subItem}`}
              key={subItem}
              className="article"
            >
              <Section sectionId={subItem} caseSections={props.caseSections} />
            </Container>
          ))}
      </div>
    </React.Fragment>
  );
};

I believe most of it is covered in the post. You can check your forked working repo here

You can simplify your code. You don't really need refs or intersectionObservers for your use case. You can simply scrollIntoView using document.getElementById (you already have ids to your navs.

You can do setActiveCase very well in handleClick.

Working demo

Modify handleClick like this


const handleClick = (subTabId, mainTabName) => {
    //console.log("subTabName, mainTabName", subTabId, mainTabName);
    setActiveCase({ mainTab: mainTabName, subTab: subTabId.split("--")[1] }); //use this for active tab styling etc
    document.getElementById(subTabId).scrollIntoView({
      behavior: "smooth",
      block: "start"
    });
  };

Navigation.js Call handleClick like this.

{item.sections &&
            item.sections.map(subItem => (
              <div
                className={`${styles.anchor}`}
                key={`#${item.title}--${subItem}`}
                sx={{ marginRight: 3, fontSize: 0, color: "text" }}
                href={`#${item.title}--${subItem}`}
                onClick={e => {
                  handleClick(`${item.id}--${subItem}`, item.id);
                  e.stopPropagation();
                }}
              >
                {toTitleCase(subItem)}
              </div>
            ))}
Related