javascriptreactjsreact-hooksintersection-observer

React: prevent useContext from re-rendering the whole app or share data between components without re-rendering all of them


I have a simple app with a Header.tsx component that is supposed to be available to all pages and a Home.tsx in which the most of the content will be.

Home.tsx hosts a intersectionObserver that sends data with useContext hook (called homeLinks) to Header.tsx trough App.tsx, so I can update the navigation with class of the currently intersected section. The problem here: anytime the observer updates the context, the whole DOM re-renders, and since Home will hold the brunt of the application that can't be the optimal solution. Could someone explain to me how to achieve this the correct way?

Could I somehow sends refs from Home to Header and set up the observer there?

CodeSandbox demo is available here

App

import homeLinks from "./contexts/homeLinks";


function App() {
  const [homePart, setHomePart] = useState<string>(""); //Home part which is observed, setting function
  console.log("App is rendered");

  return (
    <>
      <homeLinks.Provider value={{ homePart, setHomePart }}>
        <Header /> {/* Outside router, meant to be shared with other pages  */}
        <Home /> {/* Will be in react router */}
      </homeLinks.Provider>
    </>
  );
}
export default App;

Home

import homeLinks from "../contexts/homeLinks";

export const Home = () => {
  const homeParts = useRef(new Array());
  const { setHomePart } = useContext(homeLinks);

  useEffect(() => {
    const menuObserver = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry.isIntersecting)
          setHomePart(
            entry.target.getAttribute("id")
              ? entry.target.getAttribute("id")!
              : "ntn here",
          );
      },
      { rootMargin: "-40% 0px -50% 0px" },
    );

    homeParts.current.map((part) => {
      menuObserver.observe(part!);
    });
  }, []);

  console.log("Home is rendered");

  return (
    <>
      <div
        className="hero"
        id="home"
        ref={(element) => homeParts.current.push(element)}
      >
        <section>
          <div className="depth"></div>
          <div className="heroContent">
              <h1>This is Home</h1>
          </div>
        </section>
      </div>
      <div
        id="projects"
        style={{ height: "100vh", backgroundColor: "gray" }}
        ref={(element) => homeParts.current.push(element)}
      >
        This is Projects
      </div>

      <div
        id="contact"
        style={{ height: "100vh", backgroundColor: "cadetblue" }}
        ref={(element) => homeParts.current.push(element)}
      >
        This is contact
      </div>
    </>
  );
};

Header

import linkSection from "../contexts/homeLinks";

function Header() {
  const lastHash = useRef("");

  const { homePart } = useContext(linkSection);
  const homeLinks = [
    { href: "home", title: "Home" },
    { href: "projects", title: "Projects" },
    { href: "contact", title: "Contact" },
  ];

  return (
    <header>
      <div>
        <a href="/">Menu</a>
      </div>
      <nav>
        <ul>
          {homeLinks.map((homeLink) => (
            <li key={homeLink.href}>
              <a
                href={"/#" + homeLink.href}
                className={homePart == homeLink.href ? "active" : ""}
              >
                {homeLink.title}
              </a>
            </li>
          ))}
        </ul>
      </nav>
    </header>
  );
}

export default Header;

Context

import { createContext } from "react";

const homeLinks = createContext({
  homePart: "",
  setHomePart: (part: string) => {},
});

export default homeLinks;

Full code is also available in https://codesandbox.io/p/devbox/9p6fyv?file=%2Fsrc%2Fcontexts%2FhomeLinks.tsx&embed=1


Solution

  • Whenever the IntersectionObserver detects an intersection, it sets the state in App, and App, and all it's children are re-rendered:

    function App() {
      // re-renders App and all children
      const [homePart, setHomePart] = useState<string>("");
      console.log("App is rendered");
    
      return (
        <>
          <homeLinks.Provider value={{ homePart, setHomePart }}>
            <Header /> {/* Outside router, meant to be shared with other pages  */}
            <Home /> {/* Will be in react router */}
          </homeLinks.Provider>
        </>
      );
    }
    

    Instead the context should be a funnel to register to intersection events, without holding a state of it's own. If a component needs to set a state (and trigger a re-render) it should listen to an event, and set it's own state.

    Sandbox

    The homeLink context

    The context exports 2 hooks:

    1. useRegisterListener - accepts a function to register as listener.
    2. useObserve - returns an observe function that can be used as a function ref to observe a DOM elements.

    The context also exports HomeLinksProvider the initiates the IntersectionObserver, and the listeners Set. It supplies the context consumer with all relevant functions to add/remove listener, and observe DOM elements. The provider handles all cleanups in case of unmount.

    It also waits for the observer to be ready before rendering the wrapped children.

    import React from "react";
    import {
      createContext,
      useContext,
      useState,
      useRef,
      useEffect,
      useMemo,
    } from "react";
    
    export type Listener = (id: string) => void;
    
    const HomeLinks = createContext({
      observe: (instance: HTMLElement | null) => {},
      addListener: (listener: Listener) => {},
      removeListener: (listener: Listener) => {},
    });
    
    // register listeners to intersection events
    export const useRegisterListener = (listener: Listener) => {
      const { addListener, removeListener } = useContext(HomeLinks);
    
      useEffect(() => {
        addListener(listener);
    
        return () => {
          removeListener(listener);
        };
      }, [addListener, removeListener, listener]);
    };
    
    // returns a ref function to observe DOM elements
    export const useObserve = () => {
      const { observe } = useContext(HomeLinks);
    
      return observe;
    };
    
    // The provider initiates the IntersectionObserver,
    // and calls listeners when elements are visible.
    // It also disconnects the observer and clears the listeners Set.
    export const HomeLinksProvider = ({ children }) => {
      // delay rendering children until context is ready
      const [ready, setReady] = useState(false);
      const menuObserver = useRef<IntersectionObserver>();
      const listners = useRef<Set<Listener>>();
    
      useEffect(() => {
        listners.current = new Set(); // initiate the listeners Set
        // create a new observer
        menuObserver.current = new IntersectionObserver(
          (entries) => {
            const entry = entries[0];
            const id = entry.target.getAttribute("id") ?? "ntn here";
    
            if (entry.isIntersecting)
              listners.current?.forEach((listener) => listener(id));
          },
          { rootMargin: "-40% 0px -50% 0px" },
        );
    
        setReady(true); // allow rendering children when context is ready
    
        return () => {
          // cleannup
          menuObserver.current?.disconnect();
          listners.current?.clear();
        };
      }, []);
    
      const contextValue = useMemo(
        () => ({
          // obsere DOM elements
          observe: (instance: HTMLElement | null) => {
            if (instance) menuObserver.current?.observe(instance);
          },
          // register listeners to Listeners Set
          addListener: (listener: Listener) => {
            listners.current?.add(listener);
          },
          // remove listeners from Listeners Set
          removeListener: (listener: Listener) => {
            listners.current?.delete(listener);
          },
        }),
        [],
      );
    
      // render children when context is ready
      return (
        <HomeLinks.Provider value={contextValue}>
          {ready ? children : null}
        </HomeLinks.Provider>
      );
    };
    

    Usage:

    App:

    function App() {
      console.log("App is rendered");
    
      return (
        <>
          <HomeLinksProvider>
            <Header /> {/* Outside router, meant to be shared with other pages  */}
            <Home /> {/* Will be in react router */}
          </HomeLinksProvider>
        </>
      );
    }
    

    Home:

    export const Home = () => {
      const observe = useObserve();
    
      console.log("Home is rendered");
    
      return (
        <>
          <div
            className="hero"
            id="home"
            ref={observe}
          >
            <section style={{ height: "100vh", backgroundColor: "palevioletred" }}>
              <div className="depth"></div>
              <div className="heroContent">
                <main>
                  <h1>This is Home</h1>
                </main>
              </div>
            </section>
          </div>
          <div
            id="projects"
            style={{ height: "100vh", backgroundColor: "gray" }}
            ref={observe}
          >
            This is Projects
          </div>
    
          <div
            id="contact"
            style={{ height: "100vh", backgroundColor: "cadetblue" }}
            ref={observe}
          >
            This is contact
          </div>
        </>
      );
    };
    

    Header:

    const homeLinks = [
      { href: "home", title: "Home" },
      { href: "projects", title: "Projects" },
      { href: "contact", title: "Contact" },
    ];
    
    function Header() {
      const [homePart, setHomePart] = useState('home');
    
      useRegisterListener(setHomePart);
    
      return (
        <header>
          <div>
            <a href="/">Menu</a>
          </div>
          <nav>
            <ul>
              {homeLinks.map((homeLink) => (
                <li key={homeLink.href}>
                  <a
                    href={"/#" + homeLink.href}
                    className={homePart == homeLink.href ? "active" : ""}
                  >
                    {homeLink.title}
                  </a>
                </li>
              ))}
            </ul>
          </nav>
        </header>
      );
    }