For purely visual effects — fades, slides, counters starting — set a class or data-* attribute on the element directly in the IntersectionObserver callback through a ref, and let CSS animate it; React state is only needed when visibility changes what renders, not how it looks.
Problem / Scenario Context
A marketing site built in React animates each section, card and statistic as it scrolls into view using a useInView hook that returns a boolean, then renders className={inView ? 'visible' : ''}. A long landing page has 120 animated elements; scrolling through it triggers 120 re-renders, each re-rendering the component and its children — including a few heavy illustrated sections. React DevTools shows render bursts during scrolling, and on mid-range phones the first scroll through the page is visibly janky.
The re-renders buy nothing: the only output that changes is one class name, which the DOM could have received directly. The React Observer Hooks topic covers state-based hooks; this page covers when to bypass state.
Mechanics Explanation
A React state update schedules a render of the component and, unless memoised, its children. React then reconciles and commits DOM changes — here, a single className change — in a later task. For a visual toggle, all of that work produces the same result as one classList.add() call, and it lands later: the observer callback runs after paint, then the render and commit happen in another task, and the class is painted a frame or more after the crossing (see why observer callbacks lag one frame behind).
Writing the class directly from the callback:
- Skips render and reconciliation entirely — no component function runs.
- Lands a task earlier — the class is applied in the callback's task and painted in the next frame.
- Does not conflict with React as long as React does not also manage that class. React only touches attributes it rendered; a class added through
classListsurvives re-renders if the component rendersclassNameas a stable string and the manual class is re-applied, or — simpler — if the manual state lives in adata-*attribute React never renders.
Comparison Table: When to Use State vs Direct Writes
| Effect of visibility | Changes what renders? | Use |
|---|---|---|
| Fade / slide in | no | data attribute + CSS |
| Start a CSS animation or count-up | no | data attribute; count-up via ref |
| Play or pause a video | no | call video.play() from the callback |
| Mount a heavy chart or map | yes | state (once), or lazy component |
| Swap a placeholder for real content | yes | state (once) |
| Show "new" badge in a list | maybe | state if other components read it |
Minimal Reproducible Example
function Stat({ value, label }: { value: number; label: string }) {
const [ref, inView] = useInView<HTMLDivElement>(); // state-based
return (
<div ref={ref} className={inView ? 'stat visible' : 'stat'}>
<HeavyIllustration /> {/* re-renders on every toggle */}
<strong>{value}</strong> {label}
</div>
);
}
declare function useInView<T extends Element>(): [(el: T | null) => void, boolean];
declare function HeavyIllustration(): JSX.Element;
Production-Safe Solution
// useRevealRef.ts — returns a ref callback; no state, no re-renders.
import { useCallback } from 'react';
let io: IntersectionObserver | null = null;
const onReveal = new WeakMap<Element, () => void>();
function shared(): IntersectionObserver {
return io ??= new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
(e.target as HTMLElement).dataset.inView = ''; // CSS takes it from here
onReveal.get(e.target)?.();
io!.unobserve(e.target); // one-shot
}
}, { rootMargin: '0px 0px -10% 0px' });
}
export function useRevealRef<T extends HTMLElement>(onVisible?: (el: T) => void) {
return useCallback((el: T | null) => {
if (!el) return;
if (onVisible) onReveal.set(el, () => onVisible(el));
shared().observe(el);
return () => { shared().unobserve(el); onReveal.delete(el); };
}, [onVisible]);
}
function Stat({ value, label }: { value: number; label: string }) {
const countRef = useRef<HTMLElement>(null);
const ref = useRevealRef<HTMLDivElement>(
useCallback(() => countUp(countRef.current!, value), [value]), // imperative, no state
);
return (
<div ref={ref} className="stat">
<HeavyIllustration />
<strong ref={countRef}>{value}</strong> {label}
</div>
);
}
declare function countUp(el: HTMLElement, to: number): void;
.stat { opacity: 0; transform: translateY(12px); transition: opacity 400ms, transform 400ms; }
.stat[data-in-view] { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .stat { transform: none; transition: opacity 150ms; } }
/* Without JS the observer never runs — keep content visible: */
@media (scripting: none) { .stat { opacity: 1; transform: none; } }
React never renders data-in-view, so it never removes it; the attribute persists across re-renders of the component for other reasons. The count-up animation writes directly to the <strong> element's text via its ref. The server-rendered HTML shows the final value, and the count-up only animates from zero once visible — so users without JavaScript, and crawlers, see the real number. The no-JS rule keeps content visible, as recommended in fade in on scroll.
When You Do Need State
Direct writes are for presentation. Use React state when visibility changes the component tree:
- Mounting something that should not exist until visible — a heavy chart, an iframe, a lazily imported component. Set state once (a latch) and never unset it, so the component does not unmount when scrolled away.
- Coordinating with other components — a table of contents that highlights the visible section needs shared state or a store, because other components read it.
- Accessibility state that React renders —
aria-hidden,aria-expanded— should stay in React's control so it is consistent with the rendered tree.
In all of these, keep the stateful component as small as possible and pass the heavy content as children, so the re-render when visibility changes does not reach subtrees that do not care.
Verification Steps
- Profile a scroll through the page with React DevTools; reveal animations should cause no renders.
- Re-render a revealed component for another reason and confirm
data-in-viewpersists. - Disable JavaScript and confirm content is visible with final values.
- Emulate reduced motion and confirm elements appear without movement.
- Check StrictMode: ref cleanup and re-observation must not re-hide revealed elements.
Common Mistakes to Avoid
- Using state for pure presentation. Every toggle re-renders a subtree for one class.
- Manipulating classes React also renders. Use a
data-*attribute React never touches. - Hiding content without a no-JS fallback. The observer may never run.
- Unsetting a mount latch on scroll-away. Heavy components then unmount and remount repeatedly.
FAQ
Is writing to the DOM directly from React components an anti-pattern?
Not for attributes React does not manage. Refs exist for exactly this kind of imperative integration. The rule is to avoid changing things React renders, so the two never disagree.
Will React remove the data attribute on the next render?
No. React only updates attributes it rendered. A data-in-view attribute that never appears in the JSX is left alone across renders.
How does this interact with StrictMode's double mounting?
In development, the ref callback runs, its cleanup unobserves, and it runs again, re-observing the element. If the element was already revealed, the fresh observation re-applies the attribute, so nothing is hidden again.
Why is the direct write also faster visually?
It applies the attribute in the observer callback's task. A state update schedules a render and commit in a later task, so the visual change can land a frame later.
Should the count-up start from zero on the server?
No. Render the final value on the server so the content is correct without JavaScript, and let the client animate from zero only when the element becomes visible.
Can Framer Motion or similar libraries do this without re-renders?
Many animation libraries drive styles through their own imperative engines and support in-view triggers. Check whether their in-view helpers update React state or write styles directly; the latter behaves like the pattern shown here.
Related
- Building a useResizeObserver Hook in React — the same state-vs-imperative split for size
- Staggered Reveal Animations for Card Grids — ordering reveals
- Why Callback Refs Beat useEffect for Observer Targets — the ref pattern
↑ Back to React Observer Hooks