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
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.
homeLink
contextThe context exports 2 hooks:
useRegisterListener
- accepts a function to register as listener.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>
);
};
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>
);
}