# schedgrid/react

URL: https://schedgrid.fabio.cv/docs/react

useGrid, useFrame and useScrollContainer wrap the engine in hooks.

```ts
import { useFrame, useGrid, useScrollContainer } from 'schedgrid/react';
```

The hooks add nothing to the engine. They only tie its lifetime and its
subscription to a component.

All three take the item type as a parameter, so `frame.items[n].item` comes
back as your own type rather than `Item`.

## useGrid(options) [#usegridoptions]

```ts
function useGrid<T extends Item = Item>(options: GridOptions<T>): Grid<T>;
```

Creates a grid once for the lifetime of the component. Later option changes
go through the grid's setters (`setItems`, `setColumns`, ...); the options
object is only read on the first render.

```tsx
const grid = useGrid<Event>({ rows, columns, items });

useEffect(() => {
  grid.setItems(items);
}, [grid, items]);
```

`GridOptions` is documented on the [core page](/docs/core#creategridoptions).

## useFrame(grid) [#useframegrid]

```ts
function useFrame<T extends Item>(grid: Grid<T>): Frame<T>;
```

Subscribes the component to the grid and returns the current frame. The
frame object is memoised by the engine, so React bails out of re-renders
whenever nothing relevant changed. It is a thin wrapper over
`useSyncExternalStore`.

Note that `useFrame` has no default for `T`, unlike `useGrid` — it takes the
type from the grid you hand it, so you never write it out.

## useScrollContainer(grid) [#usescrollcontainergrid]

```ts
function useScrollContainer<
  T extends Item,
  E extends HTMLElement = HTMLElement,
>(grid: Grid<T>): (element: E | null) => void;
```

A callback ref for the scroll container. Attach it to the element with
`overflow: auto` and the grid receives its viewport; the element takes the
grid's scroll offset, so a `scroll` passed to `useGrid` still holds once the
element binds. It rebinds if the element or the grid changes and unbinds on
unmount.

```tsx
const ref = useScrollContainer<Event, HTMLDivElement>(grid);
return (
  <div ref={ref} style={{ overflow: 'auto' }}>
    …
  </div>
);
```

## Hit-testing [#hit-testing]

There is no hook for this: `grid.cellAt` takes a viewport-space point, and
[`viewportPoint`](/docs/dom#viewportpointelement-event) turns a React pointer
event into one. `e.currentTarget` is the scroll container, which is the
element the point must be relative to.

```tsx
import { viewportPoint } from 'schedgrid/dom';

<div
  ref={ref}
  onPointerMove={(e) => {
    const cell = grid.cellAt(viewportPoint(e.currentTarget, e));
    setHover(cell ? `${cell.row}, ${cell.column}` : '–');
  }}
  onPointerLeave={() => setHover('–')}
>
```

Pinned bands are resolved before scroll is applied, so a pointer over the
header row reports the header row wherever the user has scrolled.

## A complete renderer [#a-complete-renderer]

Two hundred resources by one day of 15-minute slots, with a pinned header
row and a pinned label column. Scroll it, and hover to hit-test.

<div className="not-prose">
A live grid: 200 resources by one day of 15-minute slots, a pinned header row and a pinned label column, hit-testing the cell under the pointer. Full source at https://github.com/naso/schedgrid/blob/main/apps/docs/components/ScheduleDemo.tsx; the abridged version follows.
</div>

Everything is absolutely positioned in content space inside a box the size
of `frame.total`, and the browser scrolls it natively. Pinned rows and
columns are the one exception: they are translated by the current scroll so
they stay put.

The setup first — the axes, the item type and the data. Column 0 is a pinned
label column and row 0 a pinned header, so slot `i` lives in column `i + 1`
and resource `n` in row `n + 1`:

```tsx
import { useState } from 'react';

import { viewportPoint } from 'schedgrid/dom';
import { useFrame, useGrid, useScrollContainer } from 'schedgrid/react';
import { timeAxis } from 'schedgrid/time';
import type { Item } from 'schedgrid/core';

const MIN = 60_000;
const RESOURCES = 200;

const dayStart = Date.UTC(2026, 8, 7);
const time = timeAxis({
  start: dayStart,
  end: dayStart + 24 * 60 * MIN,
  slot: 15 * MIN,
  size: 20,
});

const rows = {
  count: RESOURCES + 1,
  size: (i: number) => (i === 0 ? 32 : 36),
  pinned: 1,
};

const columns = {
  count: time.count + 1,
  size: (i: number) => (i === 0 ? 120 : 20),
  pinned: 1,
};

interface Event extends Item {
  title: string;
}

const items: Event[] = [
  {
    id: 'standup',
    rows: [3, 4],
    // `+ 1` for the label column; `toRange` returns fractional indices.
    columns: time
      .toRange(dayStart + 9 * 60 * MIN, dayStart + 10 * 60 * MIN)
      .map((c) => c + 1) as [number, number],
    title: 'Standup',
  },
];

/** A label on the hour, nothing in between. */
function columnLabel(column: number): string | null {
  const date = time.fromIndex(column - 1);
  if (date.getUTCMinutes() !== 0) return null;
  return `${String(date.getUTCHours()).padStart(2, '0')}:00`;
}
```

Then the component. Styles are inline here to keep the shape visible; the
real one uses classes:

```tsx
export function ScheduleDemo() {
  const grid = useGrid<Event>({ rows, columns, items });
  const frame = useFrame(grid);
  const ref = useScrollContainer<Event, HTMLDivElement>(grid);
  const [hover, setHover] = useState<string>('–');

  const pinnedRows = frame.rows.filter((r) => r.pinned);
  const bodyRows = frame.rows.filter((r) => !r.pinned);
  const pinnedColumns = frame.columns.filter((c) => c.pinned);
  const bodyColumns = frame.columns.filter((c) => !c.pinned);
  const { scroll, total } = frame;

  return (
    <div
      ref={ref}
      style={{ height: 320, overflow: 'auto' }}
      onPointerMove={(e) => {
        const cell = grid.cellAt(viewportPoint(e.currentTarget, e));
        setHover(cell ? `${cell.row}, ${cell.column}` : '–');
      }}
      onPointerLeave={() => setHover('–')}
    >
      <div
        style={{
          position: 'relative',
          width: total.width,
          height: total.height,
        }}
      >
        {frame.items.map((it) => (
          <div
            key={it.id}
            style={{
              position: 'absolute',
              transform: `translate(${it.x}px, ${it.y}px)`,
              width: it.width,
              height: it.height,
            }}
          >
            {it.item.title}
          </div>
        ))}

        {/* Pinned label column: follows horizontal scroll. */}
        {pinnedColumns.map((col) =>
          bodyRows.map((row) => (
            <div
              key={`${col.index}:${row.index}`}
              style={{
                position: 'absolute',
                left: scroll.x + col.start,
                top: row.start,
                width: col.size,
                height: row.size,
              }}
            >
              Resource {row.index}
            </div>
          )),
        )}

        {/* Pinned header row: follows vertical scroll. */}
        {pinnedRows.map((row) =>
          bodyColumns.map((col) => (
            <div
              key={`${row.index}:${col.index}`}
              style={{
                position: 'absolute',
                left: col.start,
                top: scroll.y + row.start,
                width: col.size,
                height: row.size,
              }}
            >
              {columnLabel(col.index)}
            </div>
          )),
        )}
      </div>
    </div>
  );
}
```

That is the whole renderer. The version running above adds the corner cell,
300 generated events and the site's colours; its source is
[`apps/docs/components/ScheduleDemo.tsx`](https://github.com/naso/schedgrid/blob/main/apps/docs/components/ScheduleDemo.tsx).

For the same frame drawn to a canvas instead, see [Examples](/examples).