Hooks
| Hook | Package | Purpose |
|---|---|---|
useMasonry | @repo/masonry-engine | Memoized masonry with incremental append |
useMasonryWorker | @repo/masonry-engine | Off-main-thread masonry (Web Worker) |
useJustifiedLayout | @repo/justified-layout | Memoized justified layout |
useFixedGrid | @repo/justified-layout | Memoized fixed-aspect grid |
useContainerSize | @repo/ui | ResizeObserver width/height (rAF-coalesced) |
useVirtualGrid | @repo/ui | Windowing over positioned items |
useInfiniteImages | @repo/ui | IntersectionObserver-driven background paging, bi-directional (append + prepend) |
useScrollAnchor | @repo/ui | Keeps the viewport steady when content is prepended above |
Putting it together
A complete masonry feed is four hooks and a renderer:
const { ref, width } = useContainerSize<HTMLDivElement>();
const { images, sentinelRef } = useInfiniteImages({
initial,
fetchPage: (page, size) => fetchImagePage(page, size, seed),
});
const { positions, containerHeight } = useMasonry(images, { containerWidth: width });
return (
<div ref={ref}>
<VirtualCanvas
positions={positions}
containerHeight={containerHeight}
overscan={500}
sentinel={<div ref={sentinelRef} />}
renderItem={(item, priority) => (
<ImageTile item={item} priority={priority} sizes={`${item.width}px`} />
)}
/>
</div>
);Bi-directional: scrolling back up
Passing fetchPreviousPage makes the stream two-ended. Scrolling toward the top requests
earlier pages; useScrollAnchor cancels the coordinate shift so the view never jumps:
const { images, sentinelRef, loadEarlier, hasEarlier, earlierError, prependCount } =
useInfiniteImages({
initial,
fetchPage: (page, size) => fetchImagePage(page, size, seed),
fetchPreviousPage: (page, size) => fetchImagePage(page, size, seed), // pages go -1, -2, …
});
const { capture } = useScrollAnchor({ positions, prependCount });
// capture() must run while positions still describe the old layout.
useEffect(() => {
const onScroll = () => {
const event = window.scrollY;
const delta = event - prevScrollRef.current;
prevScrollRef.current = event;
if (event < 1200 && delta < 0 && hasEarlier && !earlierError) {
capture();
loadEarlier();
}
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [hasEarlier, earlierError, capture, loadEarlier]);The restore runs in a useLayoutEffect — before paint — so the browser never displays
the displaced frame. The upward-only delta < 0 guard prevents a spurious prepend on the
first downward pass past the threshold.
useInfiniteImages
const {
images, loading, error, hasMore,
loadMore, retry, sentinelRef,
// bi-directional (present only when fetchPreviousPage is provided):
hasEarlier, loadEarlier, earlierError, earlierRetry, prependCount,
} = useInfiniteImages({
initial, // server-rendered first page, kept as the stable prefix
fetchPage, // (page: number, pageSize: number) => Promise<ImageData[]>
fetchPreviousPage, // optional — enables prepending (pages count down from -1)
pageSize = 1000, // optional
maxImages = 5000, // optional hard cap
});loadEarlier prepends the previous page above the current stream; the hook preserves the
identity of every existing image object, so the masonry engine appends in O(delta) and
already-laid tiles keep their coordinates. Feed prependCount into useScrollAnchor to keep
the viewport fixed. A failed earlier fetch surfaces earlierError and pauses further prepends
until earlierRetry() — the same pause/resume contract as the forward error/retry.
useScrollAnchor
const { capture } = useScrollAnchor({
positions, // current layout positions
prependCount, // useInfiniteImages.prependCount
scrollContainerRef, // optional; defaults to window
});Call capture() immediately before loadEarlier(), while positions still describe the
pre-prepend layout. When prependCount advances (the prepend has re-laid out), the hook
restores the scroll offset in a layout effect, so the same tile ends up at the same viewport
position. The restore is an instant, non-smooth window.scrollTo (or container scrollTop),
so it reads the same under prefers-reduced-motion — no animated jump is ever produced.
useContainerSize
const { ref, width, height } = useContainerSize<HTMLDivElement>();Measures an element with a ResizeObserver, coalescing bursts to one update per frame. Layout
recomputes downstream the instant the width crosses a breakpoint.
useVirtualGrid
const { containerRef, visible } = useVirtualGrid({
positions,
containerHeight,
overscan: 500,
adaptiveOverscan: true,
onSample: (s) => { sampleRef.current = s; }, // visibleCount, velocity, overscan
});Returns only the items in view (+ overscan). The onSample callback writes to a ref (never
triggers a render) and feeds the benchmark HUD.