---
url: /examples/infinite-history.md
description: >-
  Infinite history: pan toward the left edge and older bars arrive page by page
  — infiniteHistory owns the cursor, the threshold, and the in-flight
  bookkeeping; in React, useInfiniteHistory holds the data and the loader's
  place.
---

# Infinite history — infiniteHistory

In this imperative demo, the consumer's whole share is two functions: a `fetch` that produces the page
of bars strictly before a given x (empty array = the end of history), and a
`sink` that says where a landed page goes — here one fetch fans out to the
candle and volume handles. Everything the hand-rolled version had to carry —
the cursor, the "how close to the edge" test, in-flight dedup, trimming the
inclusive boundary bar exchanges like to send back, and re-checking after a
landing (a `prepend` never moves the domain, so no event announces it) — is
the loader's.

One sizing rule worth stealing: against a capped API (Toss and Upbit take
`count` up to 200, Binance `limit` up to 1000), request the cap every time.
One bigger page beats several small ones on every axis at once — round trips
while the user is looking at a gap, request quota, and landings (each landing
recomputes, wholesale, every derivation fed by the source that changed and
declaring no head door — `deriveFirst`, `calcFirst` or `headLookback` — and a price-axis
transform never declares one).

The status line reads the loader's `status()` / `statusChanges` pair — the
same snapshot-plus-subscription shape `usePluginState` consumes in React.

In React, `useInfiniteHistory` holds the data and the loader's place, and
`<InfiniteHistory history>` inside the chart pages into it:

```tsx
const CANDLES = new OHLCAccessor(); // module level
const history = useInfiniteHistory<OHLC, string>({ coordinates: CANDLES });
const { reset } = history; // stable — `history` itself changes with every page
useEffect(() => {
  let current = true;
  api.candles(symbol).then((page) => {
    if (!current) return;
    reset(page.bars, {
      next: page.next,
      fetchPage: (cursor) => api.candles(symbol, cursor),
    });
    // Paged by time instead: reset(page.bars, { fetch: (before) => api.candlesBefore(symbol, before) })
  });
  return () => {
    current = false;
  };
}, [symbol, reset]);

<ChartContainer deps={deps} data={history.data}>
  <ChartCandles />
  <InfiniteHistory history={history} />
</ChartContainer>
```

`history.data` is React state — pass it as the series' data and edit a live
bar with `history.setData`; `history.status` is the loader's state as React
state. The fetch comes with the load — `reset` takes the bars and how to page
back from them, from the scope that knows the symbol — so no later render can
pair one load's token with another symbol's API. The place outlives the chart: a chart remounted under a `key` resumes
from the first bar held and the token last taken, an end stays an end, and a
page landing for a load `reset` replaced is dropped. What `reset` is handed
is the caller's to vouch for: a first page that answers for a symbol already
left (the effect above, cleaned up) must not reach it. An edit through
`setData` is for one load's bars and is dropped if React applies it once
another load holds them: by default the load on screen when it's made, so a
tick for a symbol being left never lands on the next one's bars; a feed that
already knows its new load names it — `setData(update, load)` with the
handle `reset` returned (`history.load` reads the one on screen). One `<InfiniteHistory>` pages a history at a time;
a second one mounted alongside is refused, since two loaders on one token
would fetch and prepend the same page twice. The wrapper demo
(`apps/examples/src/App.tsx`) pages by time this way.

**A page counts once it reaches the chart.** The loader asks for the next page
only when the chart holds the one before it (`plot.getDataRange()` reaches the
page's first x) — a `setState` prepend lands a frame later, and the loader looks
again on that frame. A page the chart refuses never lands, so the loader stops
asking instead of piling up pages nobody draws. Outside React, a consumer that
starts a new loader from the same place reads the token from the cursor-mode
loader's `cursor()` — an empty page moves it without reaching the sink.

For a live feed on the same chart, the imperative example above wraps its
handle with [`conflated`](/examples/realtime) — the two doors compose on one
handle and are torn down together when the symbol changes. In React, a live
edit goes through `history.setData`.

## Source

`apps/examples/src/cases/infinite-history.ts` — the real thing, type-checked in CI.

```ts
/**
 * Infinite history through the `infiniteHistory` door.
 *
 * The consumer's whole job is two functions: a fetch that produces the page
 * of bars before a given x (here: generated, behind a simulated 250ms round
 * trip), and a sink that says where a landed page goes (here: fanned out to
 * the candle and volume handles — one fetch, two deliveries). The cursor,
 * the threshold test, in-flight dedup, boundary trimming, and the chaining
 * that keeps filling while the view sits past the data are the loader's.
 *
 * The status line under the chart is wired to the loader's snapshot +
 * subscription pair — `loading` flashes during a round trip, and once the
 * generator's backstop is reached the loader reports `done` and stops
 * asking.
 */
import { PlotBuilder, browserDeps } from "@finchart/dom";
import type { HistogramPoint, HistoryStatus, OHLC } from "@finchart/core";
import { OHLCAccessor, candleSeries, crosshair, histogramSeries, infiniteHistory, priceFormat, timeTicks } from "@finchart/core";
import { chartHost } from "./stage";

const timeFormat = new Intl.DateTimeFormat("en-US", {
  timeZone: "UTC",
  month: "short",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
});

export const title = "Infinite history — infiniteHistory";
export const description =
  "Drag toward the left edge — older bars arrive page by page from a simulated 250ms feed. infiniteHistory(plot, sink, fetch, { from }) owns the cursor and the bookkeeping; the consumer owns fetch and where a page lands. The status line reads the loader's status()/statusChanges pair.";

const MINUTE = 60_000;
const BASE = Date.UTC(2026, 7, 10, 9, 0);
const CHUNK = 80; // bars shown at first, and bars per fetched page
const MAX_HISTORY = 1200; // the generator's backstop — reaching it shows `done`

/** Deterministic pseudo-random in [0,1) — one index, always the same value. */
function noise(seed: number): number {
  const x = Math.sin(seed * 12.9898) * 43758.5453123;
  return x - Math.floor(x);
}

/** A pure function of the index, so the past extends deterministically. */
function priceAt(index: number): number {
  const drift = Math.sin(index / 19) * 700 + Math.sin(index / 67) * 1200 + index * 0.5;
  return Math.max(1_000, 42_000 + drift + (noise(index) - 0.5) * 2200);
}

function ohlcAt(index: number): OHLC {
  const open = priceAt(index - 1);
  const close = priceAt(index);
  const spread = noise(index * 7 + 3) * 700 + 90;
  const volume = Math.round(100 + noise(index * 13 + 1) * 400);
  return {
    x: BASE + index * MINUTE,
    open,
    close,
    high: Math.max(open, close) + spread,
    low: Math.min(open, close) - spread,
    volume,
  };
}

function toVolumePoint(candle: OHLC): HistogramPoint {
  return {
    x: candle.x,
    y: candle.volume ?? null,
    tone: candle.close >= candle.open ? "up" : "down",
  };
}

function bars(from: number, to: number): OHLC[] {
  const out: OHLC[] = [];
  for (let i = from; i < to; i++) out.push(ohlcAt(i));
  return out;
}

/** The exchange stand-in: the page of bars before `before`, after a round trip. */
function fetchOlder(before: number): Promise<OHLC[]> {
  const end = Math.round((before - BASE) / MINUTE);
  const from = Math.max(end - CHUNK, -MAX_HISTORY);
  const page = from >= end ? [] : bars(from, end);
  return new Promise((resolve) => setTimeout(() => resolve(page), 250));
}

const STATUS_LINE: Record<HistoryStatus, string> = {
  idle: "idle — pan left for more",
  loading: "loading older bars…",
  done: "done — the beginning of history",
  terminated: "terminated — the fetch broke its contract",
  stopped: "stopped — the loader was disposed",
};

export function mount(container: HTMLElement): () => void {
  const host = chartHost(container, 360);

  const status = document.createElement("div");
  status.style.cssText =
    "font-size: 13px; color: #64748b; margin-top: 6px; font-variant-numeric: tabular-nums";
  container.append(status);

  const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
    .setSize(container.clientWidth || 900, 360)
    .setAxis({
      // timeTicks covers the grid labels only — the crosshair badge reads
      // axis.x.format, and without it the raw epoch ms shows through.
      x: {
        ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }),
        format: (x) => timeFormat.format(x),
      },
      y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
    })
    .build(host);

  const initial = bars(-CHUNK, 0);
  const priceHandle = plot.mainPane.addSeries({
    series: candleSeries(),
    data: initial,
    name: "Price",
  });
  const volumeHandle = plot
    .addPane({ flex: 0.3, minHeight: 50 })
    .addSeries({
      // The volume pane recedes under price: a green/red pair at 45%, the same in
      // both modes, on the series — a CSS variable is chart-wide and would wash
      // an indicator's bars too.
      series: histogramSeries({ style: { up: "rgba(22, 163, 74, 0.45)", down: "rgba(220, 38, 38, 0.45)" } }),
      data: initial.map(toVolumePoint),
      name: "Volume",
    });
  plot.use(crosshair({ magnet: true }));

  let held = initial.length;
  const paint = () => {
    status.textContent = `${STATUS_LINE[loader.status()]} · ${held.toLocaleString("en-US")} bars held`;
  };

  const loader = infiniteHistory(
    plot,
    (older) => {
      // One fetch, two deliveries — the volume pane rides the same page.
      priceHandle.prepend(older);
      volumeHandle.prepend(older.map(toVolumePoint));
      held += older.length;
    },
    fetchOlder,
    { from: initial[0].x, coordinates: new OHLCAccessor() },
  );
  const offStatus = loader.statusChanges.subscribe(paint);
  paint();

  return Object.assign(
    () => {
      offStatus();
      loader.dispose();
      plot.destroy();
      host.remove();
    },
    { requestRender: () => plot.requestRender() },
  );
}

```
