A lightweight and flexible React lazy loading library.
Load components or images only when they enter the viewport, improving performance, reducing initial bundle size, and providing optional callbacks when elements become visible.
- Lazy load any component or element in React.
- Optional fallback content while loading.
- Callback support when an element becomes visible.
- Works with Next.js, React 18, and TypeScript.
- Lightweight, fully typed, and tree-shakable.
- Use
@bentoo/react-lazy(core) for simple viewport-based lazy loading (components, images, infinite scroll withLazySentinel). It's minimal, SSR-safe, and works great with Next.js. - Use
@bentoo/react-lazy/virtualfor uniform row heights where rendering thousands of DOM nodes hurts performance. It's a lightweight subset (vertical/horizontal,overscan,scrollToIndex) without the overhead of a full virtualizer API. Rows must all share oneitemSize— there is no measurement. - Use TanStack Virtual if your rows have uneven or measured heights, grid layouts, window scrolling edge cases, or advanced APIs.
@bentoo/react-lazy/virtualintentionally keeps scope small to avoid bloat.
npm install @bentoo/react-lazy
# or
yarn add @bentoo/react-lazy
# or
pnpm add @bentoo/react-lazyLazy load any React component with a fallback:
import React from 'react';
import { LazyComponent } from '@bentoo/react-lazy';
export default function App() {
return (
<div>
<h1>My content</h1>
<LazyComponent fallback={<h2>Loading...</h2>}>
<img src="/myPicture.png" alt="MyPicture" />
</LazyComponent>
</div>
);
}Props:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Content to display after lazy load. |
fallback |
ReactNode |
Content displayed while loading. |
root |
Element | null |
Scroll container for observer |
rootMargin |
string |
Root margin for observer |
threshold |
number | number[] |
Visibility threshold |
triggerOnce |
boolean |
Load only once (default true) |
className |
string |
Wrapper className |
style |
React.CSSProperties |
Wrapper style |
Lazy load images with optional placeholder, blur, fade-in, and Next.js support:
import { LazyImage } from '@bentoo/react-lazy';
// For Next.js, pass ImageComponent={NextImage}
<LazyImage
src="/photo.jpg"
alt="My Photo"
width={600}
height={400}
placeholder="/photo-low.jpg"
blur
/>Props:
| Prop | Type | Description |
|---|---|---|
src |
string |
Image source |
alt |
string |
Image alt text |
width/height |
number |
Optional width/height |
placeholder |
string |
Low-res placeholder for blur effect |
blur |
boolean |
Apply blur to placeholder |
ImageComponent |
React Component |
Optional (pass next/image in Next.js) |
fadeInDuration |
number |
Fade-in duration in ms (default 300) |
onLoad/onError |
React handler |
Composed with the internal handlers |
Any other <img> attribute (className, loading, sizes, srcSet, …) is forwarded to the underlying image.
LazyImageobserves the viewport with the default options (threshold: 0.1,triggerOnce: true); it does not exposeroot/rootMargin/threshold/triggerOnce.
Defer rendering until the element is visible, then let React Suspense handle the loading state:
import { lazy } from 'react';
import { LazySuspense } from '@bentoo/react-lazy';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
export default function App() {
return (
<LazySuspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</LazySuspense>
);
}Props:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Rendered only after becoming visible. |
fallback |
ReactNode |
Passed to the inner Suspense boundary. |
Use this for components that suspend on their own (dynamic
import(), data fetching). For plain markup, preferLazyComponent.
Two hooks are available for custom elements:
import { useRef } from 'react';
import { useLazy, useLazyCallback } from '@bentoo/react-lazy';
export default function Section() {
// returns { ref, visible }
const { ref, visible } = useLazy<HTMLDivElement>({ triggerOnce: true });
// returns { ref } and calls back when visible
const { ref: cbRef } = useLazyCallback({
onVisible: () => console.log('Element is now visible!'),
triggerOnce: true
});
return (
<div>
<div ref={ref}>{visible ? 'in view' : 'not yet'}</div>
<div ref={cbRef}>Watch me appear!</div>
</div>
);
}Shared props (useLazy, useLazyCallback, LazyComponent, LazySentinel):
| Prop | Type | Default | Description |
|---|---|---|---|
root |
Element | null |
null |
Scroll container; null means the viewport |
rootMargin |
string |
'0px' |
Grows/shrinks the root box |
threshold |
number | number[] |
0.1 |
Visibility ratio that triggers the callback |
triggerOnce |
boolean |
true |
Stop observing after the first intersection |
Both hooks default their element type to
HTMLDivElement, so<div ref={ref}>works with no type argument. PassuseLazy<HTMLImageElement>when attaching to another element.
Trigger loading when scrolling to the end of a list:
import { useState } from 'react';
import { LazySentinel } from '@bentoo/react-lazy';
const PAGE = 20;
const TOTAL = 100;
export default function InfiniteList() {
const [items, setItems] = useState(Array.from({ length: PAGE }, (_, i) => i));
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function loadMore() {
if (loading || done) return;
setLoading(true);
await new Promise((r) => setTimeout(r, 500));
setItems((prev) => [...prev, ...Array.from({ length: PAGE }, (_, i) => prev.length + i)]);
setDone(items.length + PAGE >= TOTAL);
setLoading(false);
}
return (
<div>
{items.map((i) => <div key={i}>Item {i}</div>)}
{!done && (
<LazySentinel onVisible={loadMore} triggerOnce={false} rootMargin="200px" />
)}
{loading && <div>Loading...</div>}
</div>
);
}
triggerOncedefaults totrue, so a sentinel stops observing after the first intersection and the list never loads a second page. Infinite scroll needstriggerOnce={false}, plus two guards in your handler: a re-entrancy check while a request is in flight (loading) and an end-of-list check that stops rendering the sentinel (done). If the list scrolls inside its own container, also pass that element asroot.
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
onVisible |
() => void |
required | Called when the sentinel scrolls into view |
as |
React.ElementType |
'div' |
Element to render |
className |
string |
— | Class for the sentinel element |
style |
React.CSSProperties |
— | Merged over the hidden defaults |
root/rootMargin/threshold/triggerOnce |
see shared props |
The sentinel is hidden by default (
height: 0,visibility: hidden,pointer-events: none) so it never takes up space, and its children are hidden with it — render any status text as a sibling, not as children. Passstyle(it merges over the hidden defaults) if you actually want a visible sentinel. It also forwards a ref.
Works with or without virtualization libraries (e.g., TanStack Virtual).
Lightweight fixed-size virtualization. Import from the separate entrypoint to keep the core bundle small.
import { useRef } from 'react';
import { useVirtualizer } from '@bentoo/react-lazy/virtual';
function MyList({ count = 1000 }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
itemSize: 50,
overscan: 2,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: item.size,
transform: `translateY(${item.start}px)`,
}}
>
Item {item.index}
</div>
))}
</div>
</div>
);
}VirtualList creates and owns its own scroll container, so you only pass the data and the row size:
import { VirtualList } from '@bentoo/react-lazy/virtual';
function App() {
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);
return (
<VirtualList
items={items}
itemSize={50}
renderItem={(item) => <div>{item}</div>}
style={{ height: 400 }}
/>
);
}| Prop | Type | Description |
|---|---|---|
items |
T[] |
Data to render |
renderItem |
(item, index, virtual) => ReactNode |
Renders a single row |
itemSize |
number |
Fixed row size in px, defaults to 40. Rows must all share this height. |
overscan |
number |
Extra rows rendered outside the viewport |
horizontal |
boolean |
Lay out rows horizontally (default false) |
className |
string |
Class for the scroll container |
style |
React.CSSProperties |
Styles for the scroll container |
VirtualListderivescountfromitems.lengthand manages its scroll element for you. UseuseVirtualizerdirectly when you need to own the scroll container yourself.
Also works well alongside
LazyImageinside virtualized cells.
import Image from 'next/image';
import { LazyImage } from '@bentoo/react-lazy';
export default function NextApp() {
return (
<LazyImage
ImageComponent={Image}
src="/photo.jpg"
alt="Next.js Image"
width={600}
height={400}
placeholder="/photo-low.jpg"
blur
/>
);
}Note:
next/imagealready lazy-loads by default.LazyImageadds a placeholder + blur + fade-in sequence gated on the viewport, plus youronLoad/onErrorhandlers composed with the internal ones.
We welcome contributions!
- Fork the repository
- Create a feature branch (
git checkout -b my-feature) - Commit your changes (
git commit -m 'Add feature') - Push to the branch (
git push origin my-feature) - Open a Pull Request
MIT License – see the LICENSE file for details.