Skip to content

Server-side windowed data (recipe) ​

For hundreds of thousands of rows: the component only computes which slice it needs; fetching, cancelling and merging are the host's job. This page gives a four-step recipe plus a runnable example (oas-table demoed; gantt / resource / tree / list share the contract — differences below).

Contract ​

  • Attributes: windowed (enable) + total (full row count) + window-start (absolute row index of data[0]); data carries only [window-start, window-start + data.length).
  • Event: oas-window-request with detail: { start, end, reason } (half-open, absolute row indices; reason = init / scroll / resize / data). The same expected window is never emitted twice; it resets after the window is covered.
  • Prerequisite: a measurable scroll viewport (height or a measurable max-height); otherwise no request is emitted (one devWarn). A windowed table slice must be flat (children are not recursed).

Four steps ​

  1. Fetch by segment: align requests to fixed segments (e.g. 200 rows) so one response covers many windows — typically an order of magnitude fewer requests.
  2. Keep only the latest: abort in-flight fetches (AbortController) or drop stale responses with a request sequence number; otherwise an old response arriving late makes the view jump back.
  3. Merge by id into the full dataset: edits/oas-tasks-change carry only the slice; the host must merge by id and never replace the full dataset wholesale.
  4. Refill atomically: write data first, then window-start (within the same synchronous step) to avoid a "new slice + old start" intermediate state.
ts
// Real project: segment fetch + latest-only + atomic refill
let seq = 0
let controller: AbortController | null = null
const SEG = 200

el.addEventListener('oas-window-request', async (event) => {
  const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
  const my = ++seq
  controller?.abort()
  controller = new AbortController()

  const segFrom = Math.floor(start / SEG)
  const segTo = Math.floor((Math.max(start, end - 1)) / SEG)
  const from = segFrom * SEG
  const to = Math.min(total, (segTo + 1) * SEG)

  const rows = await fetch(`/api/rows?from=${from}&to=${to}`, {
    signal: controller.signal,
  }).then((r) => r.json())
  if (my !== seq) return // stale response, drop it

  el.setAttribute('window-start', String(from))
  el.setAttribute('data', JSON.stringify(rows))
})

Runnable example (local mock server) ​

The demo simulates a server with local data: 200 rows per segment, 120ms latency, per-segment cache and latest-only. Scroll the table and watch the counters — scrolling within a loaded segment issues no new request (one response covers many windows).

windowed + segment cache + latest-only (mock 50k rows)

Waiting for the first window request…

Gantt / Resource windowed (host-managed axis) ​

Beyond row windows, timeline components support a host-managed axis via range-start / range-end; when the visible time window approaches an axis edge, oas-range-request is emitted so the host can extend the range. The two demos below show "segmented rows + extend the axis on demand".

Gantt (8000 tasks / 1-year axis / segments of 200) ​

gantt windowed + auto-extend the axis at the edge

Waiting for the first window request…

Resource (400 resources / half-year axis / segments of 50) ​

resource windowed + auto-extend the axis at the edge

Waiting for the first window request…

React / Vue host wrappers ​

Custom-element bridging notes: Vue needs isCustomElement: (tag) => tag.startsWith('oas-') in the compiler options; React 19+ supports custom elements natively (below 19, use a ref + addEventListener). Do not write attributes synchronously inside the event handler (it re-enters update) — refill after the fetch resolves.

React ​

tsx
import { useEffect, useRef } from 'react'

const SEG = 200

export function WindowedTable({ total }: { total: number }) {
  const ref = useRef<HTMLElement | null>(null)

  useEffect(() => {
    const el = ref.current
    if (!el) return
    let seq = 0
    let controller: AbortController | null = null

    const onRequest = async (event: Event) => {
      const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
      const my = ++seq
      controller?.abort()
      controller = new AbortController()
      const from = Math.floor(start / SEG) * SEG
      const to = Math.min(total, from + SEG)
      const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
        .then((r) => r.json())
        .catch(() => null)
      if (!rows || my !== seq) return
      el.setAttribute('data', JSON.stringify(rows))
      el.setAttribute('window-start', String(from))
    }

    el.addEventListener('oas-window-request', onRequest)
    return () => {
      controller?.abort()
      el.removeEventListener('oas-window-request', onRequest)
    }
  }, [total])

  return (
    <oas-table
      ref={ref}
      windowed=""
      total={String(total)}
      window-start="0"
      height="320"
      row-key="id"
      columns={JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: 'Name' }])}
    />
  )
}

TypeScript needs JSX intrinsic-element augmentation for custom elements (or // @ts-expect-error on the tag).

Vue 3 ​

vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const SEG = 200
const table = ref<HTMLElement | null>(null)
let seq = 0
let controller: AbortController | null = null

async function onWindowRequest(event: Event) {
  const el = table.value
  if (!el) return
  const { start, end } = (event as CustomEvent<{ start: number; end: number }>).detail
  const my = ++seq
  controller?.abort()
  controller = new AbortController()
  const from = Math.floor(start / SEG) * SEG
  const to = Math.min(50000, from + SEG)
  const rows = await fetch(`/api/rows?from=${from}&to=${to}`, { signal: controller.signal })
    .then((r) => r.json())
    .catch(() => null)
  if (!rows || my !== seq) return
  el.setAttribute('data', JSON.stringify(rows))
  el.setAttribute('window-start', String(from))
}

onMounted(() => table.value?.addEventListener('oas-window-request', onWindowRequest))
onBeforeUnmount(() => {
  controller?.abort()
  table.value?.removeEventListener('oas-window-request', onWindowRequest)
})
</script>

<template>
  <oas-table
    ref="table"
    windowed
    total="50000"
    window-start="0"
    height="320"
    row-key="id"
    :columns="JSON.stringify([{ key: 'id', title: 'ID' }, { key: 'name', title: 'Name' }])"
  />
</template>

In templates you can also write @oas-window-request="onWindowRequest" (Vue 3 forwards oas-window-request as a custom event).

Component differences ​

Componentwindowed semanticsExtra prerequisites
oas-tablerow slice; total reused as the full row countheight; flat slice; local sort/filter/group/pagination are the host's job
oas-ganttrow sliceheight; flat slice; range-start/range-end for a host-managed axis; auto-schedule/critical path/row reorder skipped
oas-resourceresource-row sliceheight; range-start/range-end for a host-managed axis; load stats cover the loaded slice only
oas-treeroot-node slice (children travel with the root)height; local filter/check-all skipped; refill by widening
oas-listrow sliceheight or a measurable max-height; list semantics are owned by the component (no host role needed)

gantt / resource also emit oas-range-request (detail: { startMs, endMs, reason }) when the visible time window approaches an axis edge — follow the same "latest-only + merge" rules.

Common pitfalls ​

  • Refilling less than the expected window: the component keeps requesting. Return a segment covering [start, end) (or a superset).
  • Replacing the whole dataset: edit write-backs (oas-tasks-change) contain only the slice; a wholesale replace loses unloaded rows.
  • Forgetting window-start: data and absolute indices drift (wrong rows rendered, wrong ARIA indices).
  • Writing other attributes synchronously in the handler (e.g. loading): re-enters update (scrollTop may be transiently clamped to 0 mid-render, causing extra requests). Refill after fetching; if you need a loading state, set it in a microtask or after the fetch resolves.
  • Fetching per window: request count grows linearly with scrolling; segment fetching cuts it by an order of magnitude.
  • No height: no requests are emitted (no scroll viewport) — not a bug, the viewport must exist first.
  • Property writes before upgrade: before assigning properties such as el.tasks / el.resources from page scripts, await customElements.whenDefined('oas-gantt') (or the matching tag) — on an unupgraded element the property becomes a plain own field that shadows the upgraded setter, silently losing data (the demos on this page already do this; the attribute channel avoids the issue).

See also ​

Released under the MIT OR Apache-2.0 License.