# Clusterize-Lazy

Vanilla-JS virtual list with lazy loading and initial skeletons.

<span id="size-badge">![gzip](https://img.shields.io/bundlejs/size/clusterize-lazy)</span> ![MIT](https://img.shields.io/github/license/JoobyPM/clusterize-lazy)

`Clusterize-Lazy` lets you render **millions of rows** in a scrollable
container while downloading data only for what the user can actually
see. Its goal is an **easy, framework-agnostic** API that just works in
any modern browser - no build step required.

> Small footprint, great DX - powered by [@tanstack/virtual-core](https://github.com/TanStack/virtual)

<div align="center"><img src="docs/assets/demo.gif" width="600" alt="demo"/></div>

## Features

- **Single dependency** - relies on the rock-solid engine\
  `@tanstack/virtual-core` (thanks Tanner & the TanStack team!)
- **Dynamic row height** - actual DOM sizes are measured automatically
- **Lazy loading + skeletons** - smooth UX even on shaky connections
- **Typed from the ground up** - shipped `.d.ts` works in ESM browsers and legacy browsers (with polyfills)
- **Batteries included** - debug logging, auto cache eviction, progress callback

## Live demo

Test it instantly (no transpiler):\
<https://joobypm.github.io/clusterize-lazy/examples/quotes.html>

> Source lives in `docs/examples/`

## Installation

### pnpm / npm / Yarn

```bash
pnpm add clusterize-lazy
```

```js
import Clusterize from 'clusterize-lazy';
const cluster = Clusterize({...});
```

### `<script>` tag (UMD)

```html
<script src="https://unpkg.com/clusterize-lazy/dist/index.iife.js"></script>
<script>
  const cluster = Clusterize.default({...});
</script>
```

## 30-second example

```html
<div id="scroll" style="height: 320px; overflow: auto">
	<div id="content"></div>
</div>

<script type="module">
	import Clusterize from '/dist/index.esm.js';

	function fetchRows(offset, size = 40) {
		return fetch(`/api/items?skip=${offset}&limit=${size}`).then((r) => r.json());
	}

	const cluster = Clusterize({
		rowHeight: 32,
		scrollElem: document.getElementById('scroll'),
		contentElem: document.getElementById('content'),

		fetchOnInit: async () => {
			const rows = await fetchRows(0);
			return { totalRows: 50_000, rows };
		},
		fetchOnScroll: fetchRows,
		renderSkeletonRow: (h, i) => `<div class="skeleton" style="height:${h}px"></div>`,
		renderRaw: (i, row) => `<div>${i + 1}. ${row.title}</div>`,
	});
</script>
```

## Mutation example

```js
// Enable mutations with buildIndex
const cluster = Clusterize({
	rowHeight: 40,
	buildIndex: true,        // Enable ID-based operations
	primaryKey: 'id',        // Use 'id' field as primary key
	scrollElem: document.getElementById('scroll'),
	contentElem: document.getElementById('content'),
	
	fetchOnInit: () => Promise.resolve([
		{ id: 1, name: 'Alice', status: 'active' },
		{ id: 2, name: 'Bob', status: 'inactive' },
		{ id: 3, name: 'Charlie', status: 'active' }
	]),
	fetchOnScroll: () => Promise.resolve([]),
	renderSkeletonRow: (h) => `<div style="height:${h}px">Loading...</div>`,
	renderRaw: (i, row) => `<div>${row.name} (${row.status})</div>`
});

// Add new rows
cluster.insert([{ id: 4, name: 'David', status: 'active' }], 1);

// Update existing row by ID
cluster.update([{ id: 2, data: { id: 2, name: 'Bob', status: 'active' } }]);

// Remove rows by ID
cluster.remove([1, 3]);
```

## Quick reference

| Option / method                   | Type / default                               | Purpose                                        |
| --------------------------------- | -------------------------------------------- | ---------------------------------------------- |
| **required**                      |                                              |                                                |
| `rowHeight`                       | `number`                                     | Fixed row estimate (px)                        |
| `fetchOnInit()`                   | `() ⇒ Promise<Row[] \| { totalRows, rows }>` | First data batch _or_ rows + total count       |
| `fetchOnScroll(offset)`           | `(number) ⇒ Promise<Row[]>`                  | Fetches when a gap becomes visible             |
| `renderSkeletonRow(height,index)` | `(number,number) ⇒ string`                   | Placeholder HTML                               |
| **optional**                      |                                              |                                                |
| `renderRaw(index,data)`           | `(number,Row) ⇒ string` · `undefined`        | Row renderer for object data                   |
| `buffer`                          | `5`                                          | Rows rendered above/under viewport             |
| `prefetchRows`                    | `buffer`                                     | Rows fetched ahead of viewport                 |
| `debounceMs`                      | `120`                                        | Debounce between scroll & fetch                |
| `cacheTTL`                        | `300 000`                                    | Milliseconds before a cached row is stale      |
| `autoEvict`                       | `false`                                      | Drop stale rows automatically                  |
| `showInitSkeletons`               | `true`                                       | Paint skeletons immediately before first fetch |
| `debug`                           | `false`                                      | Console debug output                           |
| `scrollingProgress(cb)`           | `(firstVisible:number) ⇒ void`               | Fires on every render                          |
| `buildIndex`                      | `false`                                      | Build ID-to-index map for mutations           |
| `primaryKey`                      | `'id'`                                       | Property name for primary key                  |
| **methods**                       |                                              |                                                |
| `refresh()`                       | `void`                                       | Force re-render                                |
| `scrollToRow(idx, smooth?)`       | `void` ( `true` = smooth)                    | Programmatic scroll                            |
| `getLoadedCount()`                | `number`                                     | How many rows are cached                       |
| `destroy()`                       | `void`                                       | Tear down listeners & cache                    |
| `insert(rows, at?)`               | `void`                                       | Insert rows at position (default: 0)          |
| `update(patches)`                 | `void`                                       | Update rows by ID or index                     |
| `remove(keys)`                    | `void`                                       | Remove rows by ID or index                     |
| `_dump()`                         | `{ cache, index }`                           | Debug helper for internal state               |

> See [docs/API.md](docs/API.md) for the full contract.

## Contributing

```bash
git clone https://github.com/JoobyPM/clusterize-lazy.git
pnpm i
pnpm test      # vitest + jsdom
pnpm build     # tsup + esbuild
```

Formatting & linting are handled by **Deno** (`deno fmt`, `deno lint`).
Please follow Conventional Commits; releases are automated.

## Acknowledgements

_Huge shout-out to_ **\[@tanstack/virtual-core]** - Clusterize-Lazy is
basically a thin, opinionated shell around this fantastic engine.
If you need a React / Vue / Solid binding or more power, use the TanStack
package directly and consider sponsoring the project.

## License

MIT © 2025 JoobyPM
