- Published on
How to prevent re-render in React with Tailwind CSS
Sometimes all you need is just CSS and HTML. No JavaScript needed.
- Authors
- Name
- Nico Prananta
- Follow me on Bluesky
Recently I was tasked with updating a minor change in Hyperjump Technology's website. But I thought it was a good chance to completely rewrite the website from scratch using Next.js because previously it was just a bunch of HTML and CSS files (I was lazy 😂). The good thing was we already used Tailwind CSS for the styling. So I simply just needed to copy the HTML to the new Next.js-powered website.
In the old website, there is a sticky navigation bar on the top of the page which has transparent background when the scoll position is at the top. But when user scrolls down, the background becomes white. It was implemented as follows:
<nav id="header" class="fixed w-full z-30 top-0 text-white">
<!-- content of the navigation -->
</nav>
var header = document.getElementById("header");
document.addEventListener("scroll", function () {
/*Apply classes for slide in bar*/
scrollpos = window.scrollY;
if (scrollpos > 10) {
header.classList.add("bg-white");
// some other code
} else {
header.classList.remove("bg-white");
// some other code
}
});
The straightforward solution
In React, we can implement it pretty much the same way:
export function StickyNav({
children,
}: {
children: React.ReactNode;
}) {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
const shouldBeScrolled = window.scrollY > 0;
setIsScrolled(shouldBeScrolled);
};
handleScroll(); // Call once to set initial state
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<nav className="sticky top-0 z-50 group">
<div
className={cn(
"flex items-center justify-between h-16 transition-colors duration-300",
isScrolled ? "bg-white shadow-md" : "bg-transparent shadow-none"
)}
>
{children}
</div>
</nav>
);
}
We added the scroll listener in the useEffect
hook and then conditionally set the Tailwind's class names based on the isScrolled
state. Honestly there's no problem with this code. It works. But I thought of another way that is more composable and Tailwind-y.
The alternative solution
I remembered Adam Wathan, the creator of Tailwind CSS, tweeted about a pattern that I thought was pretty cool. So instead of using ternary operator as shown in the previous code, we can use the data-* attribute and the group modifier to achive the same result.
First I created a component that listens to the scroll event:
"use client";
import { useEffect } from "react";
export default function ScrollObserver() {
useEffect(() => {
let rafId: number | null = null;
let isScrolled = false;
const handleScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
const shouldBeScrolled = window.scrollY > 0;
if (isScrolled !== shouldBeScrolled) {
isScrolled = shouldBeScrolled;
document.body.setAttribute(
"data-scroll",
isScrolled ? "true" : "false"
);
}
rafId = null;
});
};
handleScroll(); // Call once to set initial state
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return null;
}
Then I can put this ScrollObserver
component in layout.tsx
, or any component that wants to observe the scroll event.
import Hero from "@/app/components/hero";
import Nav from "@/app/components/nav";
import ScrollObserver from "@/app/components/scroll-observer";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<body data-scroll="false" className="group">
<ScrollObserver />
<Nav />
<Hero />
{children}
</body>
);
}
There are two important things to note here:
- add the
data-scroll="false"
attribute to the<body>
element. The idea is when the scroll position changes, thedata-scroll
attribute will be updated byScrollObserver
component. - add the
group
class to the<body>
element so that any child component can refer it when they need it.
Now this is the cool thing. Any component that needs to conditionally use different class names based on the scroll position can just use the data-scroll
attribute. For example, the StickyNav
component can be as simple as:
export default function StickyNav({
children,
}: {
children: React.ReactNode;
}) {
return (
<nav className="sticky top-0 z-50">
<div className="group-[[data-scroll='true']]:bg-white group-[[data-scroll='true']]:shadow-md ...other classes">
{children}
</div>
</nav>
);
}
On line 8, the bg-white
and shadow-md
class names will be applied only when the data-scroll
attribute in the parent component is true
, which in this case is the body
element. Which is the reason we added the group
class name in the body
element in the first place.
With this approach, the StickyNav
component is so much simpler:
- 🙅 no
useState
- 🙅 no
useEffect
- 🙅 and no ternary operator.
But there's more advantage: no re-render.
In the first example, the StickyNav
component was re-rendered every time the isScrolled
state changed as shown in this demo. In this video, I enabled the Highlight updates when component re-renders
option in the React DevTools so that you can see the re-render happening: The navigation bar is highlighted when it re-renders.
Meanwhile, when using the group
modifier approach, the component is not re-rendered!
Obviously this tiny re-render is not a big deal. But I just find it interesting that it's possible to avoid re-rendering by utilizing just CSS (with Tailwind in this case) and HTML.
I then use the same technique to toggle the logo from white colored logo when the scroll position is at the top to the full colored logo when the user scrolls down.
import WhiteLogo from "@/public/images/hyperjump-white.png";
import ColoredLogo from "@/public/images/hyperjump-colored.png";
function Logo() {
return (
<div>
{[WhiteLogo, ColoredLogo].map((image, i) => {
return (
<Image
key={i}
id="brandlogo"
className={cn(
"w-32",
image.src.includes("hyperjump-white")
? `group-[[data-scroll='false']]:block group-[[data-scroll='true']]:hidden`
: `group-[[data-scroll='true']]:block group-[[data-scroll='false']]:hidden`
)}
src={image}
alt="Hyperjump Logo"
width={128}
height={32}
/>
);
})}
</div>
)
}
In that Logo
component, there are actually two images which are shown depending on the data-scroll
attribute. Unfortunately I couldn't find a way to use a single Image
component in this case. If you know how to do that, please let me know. You can check out the code in the GitHub repository here and the live website here.
Closing thoughts
Another idea of abusing (😂) this approach is to avoid using React's context. If many different sibling or child components need to conditionally use different class names based on certain value, we don't need to initiate a React context and the components don't need to read the value from the context. Just use the data-*
attribute.
I think this is a pretty cool approach that simplifies a component, reduce JavaScript code, and has an extra benefit of not re-rendering the component. But of course this doesn't work in every case. There are cases when you actually need to use conditional operator.
If you only need to conditionally apply different class names in your component however, maybe give this approach a try instead of reaching for useState
, useEffect
and ternary operator.
By the way, I'm making a book about Pull Requests Best Practices. Check it out!