ibcs-react

Hooks

Every React hook the library exports - animation primitives, statement and filter state, a live feed, async data, selection, hover and element measurement.

The library exports the same hooks its own components are built on: zero-dependency, SSR-safe and tree-shakeable. Import any of them from ibcs-react. They fall into four families - animation primitives, data helpers, interaction models and one layout measurement hook.

Nothing here is required to render a chart. Each hook exists so you can build a custom view - your own statement, your own mark, your own toolbar - and have it behave exactly like the built-in components.

Animation

The library does not sell animation as a feature. It ships small requestAnimationFrame + easing primitives so you can add motion - an entrance growth, a value tween - without a motion library. Every one of them collapses to its final value when the user prefers reduced motion, and every one renders the finished value on the server, so SSR markup carries real geometry instead of a collapsed first frame.

The easing curves are exported too: easeOutCubic (the default), easeOutQuart and easeInOutCubic, plus the Easing type ((t: number) => number) if you want to pass your own.

usePrefersReducedMotion

usePrefersReducedMotion(): boolean

True when the OS asks for reduced motion, kept live through matchMedia and read via useSyncExternalStore - so the very first client render already reflects the real preference instead of flashing one frame of animation at exactly the users who asked not to see one. On the server it returns false (the other hooks render their finished value there anyway).

import { usePrefersReducedMotion } from "ibcs-react";

function Pulse() {
  const reduced = usePrefersReducedMotion();
  return <div className={reduced ? "static" : "pulsing"} />;
}

When to use: gate any custom animation you add outside the library so it respects the same accessibility preference as the built-ins.

useMountGrow

useMountGrow(duration = 600, delay = 0, key?: unknown): number

Eased progress from 0 to 1, played once on mount and replayed when key changes shape. Multiply a bar height or a path length by it to drive an entrance. The hook starts finished (1) and rewinds in a layout effect, so the server, the first paint and any client whose effects never run all show the final frame. Reduced motion, duration <= 0 or a missing requestAnimationFrame keep it at 1 with no frame loop at all.

key is reduced to a cheap structural signature rather than compared by identity, and numbers are erased from that signature. An inline data={[…]} literal therefore does not restart the entrance on every parent render, and a live feed re-emitting the same rows with new values does not re-grow the chart from its baseline - value updates glide via useDataTween. What replays the entrance is a genuinely new dataset: rows added or removed, categories renamed.

import { useMountGrow } from "ibcs-react";

function GrowingBar({ data, fullHeight, base }) {
  // Replays when the data changes shape - not on value ticks or re-renders.
  const p = useMountGrow(600, 0, data);
  return <rect height={fullHeight * p} y={base - fullHeight * p} />;
}

When to use: a one-shot entrance for a chart you draw yourself, replayed only when a genuinely different dataset arrives.

useAnimatedValue

useAnimatedValue(target: number, opts?: AnimateOptions): number

interface AnimateOptions {
  duration?: number;  // ms, default 600; <= 0 renders the target instantly
  easing?: Easing;    // default easeOutCubic
  delay?: number;     // ms, default 0 (staggering)
  from?: number;      // start value for the FIRST run only (e.g. 0)
}

Tweens a number toward target whenever it changes, gliding from the value currently on screen - retargeting mid-flight is supported. The first render (server included) shows target, so static output is always correct; pass from to opt into a real mount animation. Reduced motion jumps straight to the target.

import { useAnimatedValue, easeOutQuart } from "ibcs-react";

function Gauge({ value }) {
  const v = useAnimatedValue(value, { duration: 800, easing: easeOutQuart });
  return <Needle angle={v} />;
}

When to use: a value that changes over time and should glide rather than snap - a live gauge, an axis maximum that retargets.

useCountUp

useCountUp(target: number, opts?: AnimateOptions): number

A thin alias of useAnimatedValue with a clearer name at the call site: an animated number you render through your own formatter. This is what powers KpiCard's headline, which passes { duration: 700, from: 0 }. Without from, the first render shows the target and only later changes tween - pass from: 0 when you want the figure to count up on mount.

import { useCountUp, formatValue } from "ibcs-react";

function Figure({ amount }) {
  const n = useCountUp(amount, { duration: 700, from: 0 });
  return <strong>{formatValue(n, { compact: true })}</strong>;
}

When to use: a headline KPI or metric that should count up to its value.

useDataTween

useDataTween<T>(target: T, opts?: Omit<AnimateOptions, "from">): T

useAnimatedValue for a whole data structure: every numeric leaf glides from where it currently sits to its new value, everything else switches instantly. This is the hook behind live charts - every chart runs its data through it and recomputes layout from the interpolated rows each frame, so a feed's tick moves bars from their previous heights, stretches scales smoothly and slides variance pins, instead of replaying the entrance from zero.

A shape change does not morph: rows added or removed, categories renamed, or a value appearing where none was is a new dataset - it shows immediately, and the entrance replays for it. Retargeting mid-flight continues from the frame currently on screen. Reduced motion (or duration <= 0) jumps straight to the target, and the first render (server included) is always the target itself.

import { useDataTween } from "ibcs-react";

function CustomChart({ data }) {
  const live = useDataTween(data); // glides between live ticks
  const layout = useMemo(() => myLayout(live), [live]);
  // …
}

When to use: a custom chart or figure on a live source that should glide between updates the way the built-in charts do.

Data

These own a slice of report state or derive IBCS layout and variance from your model. They are pure logic over the same scenario-keyed data the components render, so you can compose custom views without re-implementing the notation.

useStatement

useStatement(lines: StatementLine[], opts?: UseStatementOptions): UseStatementResult

interface UseStatementOptions {
  mode?: "flow" | "stock";                            // default "flow"
  scenario?: ScenarioKey;                             // default "AC"
  defaultCollapsed?: readonly string[];               // uncontrolled seed
  collapsed?: ReadonlySet<string> | readonly string[]; // controlled value
  onCollapsedChange?: (collapsedIds: string[]) => void;
}

interface UseStatementResult {
  rows: WaterfallRow[];
  collapsed: Set<string>;
  toggle: (id: string) => void;
  isCollapsed: (id: string) => boolean;
  expandAll: () => void;
  collapseAll: () => void;
  groupIds: string[];      // every collapsible group, in document order
  allCollapsed: boolean;
  allExpanded: boolean;
  domainMin: number;       // most negative point on the value axis (<= 0)
  domainMax: number;       // most positive point on the value axis (>= 0)
}

Owns a statement's collapse/expand state and derives its waterfall (or, in "stock" mode, its levels) layout - literally the engine StatementTable runs on, so a custom view behaves exactly like the built-in one. Flatten plus layout are memoized against the model, the collapsed set, the scenario and the mode.

Uncontrolled by default (seed with defaultCollapsed, or with each line's own defaultCollapsed flag); pass collapsed + onCollapsedChange to own the state yourself, exactly like the tables. groupIds is empty for a flat statement - the cue to hide an expand/collapse toolbar entirely.

import { useStatement } from "ibcs-react";

function MyStatement({ lines }) {
  const { rows, toggle, isCollapsed, groupIds, domainMax } = useStatement(lines, {
    mode: "flow",
    scenario: "AC",
  });

  return rows.map((r) => (
    <Row key={r.id} row={r} collapsed={isCollapsed(r.id)} onToggle={() => toggle(r.id)} />
  ));
}

When to use: a custom statement or waterfall view that needs IBCS layout (running totals, a shared zero baseline, collapsible groups) without the stock table chrome.

useStatementBridge

useStatementBridge(
  lines: StatementLine[],
  comparison?: ScenarioKey,
  options?: { scenario?: ScenarioKey; expandGroups?: boolean },
): { data: WaterfallDatum[]; comparisonData?: WaterfallDatum[] }

Derives a WaterfallChart's data and comparisonData from one statement, so the bridge takes part in the same comparison toggle as every other chart. The siblings accept a scenario key (comparison="PY") while a bridge needs the other scenario's contributions spelled out as a dataset - this hook absorbs that asymmetry:

import { useStatementBridge, WaterfallChart, VarianceColumnChart } from "ibcs-react";

function Dashboard({ pnl, regions, comparison }) {
  return (
    <>
      {/* every other chart */}
      <VarianceColumnChart data={regions} comparison={comparison} />
      {/* the bridge - same toggle, no special-case branch */}
      <WaterfallChart {...useStatementBridge(pnl, comparison)} />
    </>
  );
}

Both datasets come from statementToWaterfall with the same options, so they stay structurally parallel. Pass no comparison for a bare bridge; the result is memoized on the inputs.

When to use: a dashboard-wide "vs PY / vs PL" switch that includes a bridge chart.

useFilters

useFilters<F extends object>(initial: F): UseFiltersResult<F>

interface UseFiltersResult<F> {
  filters: F;
  setFilter: <K extends keyof F>(key: K, value: F[K]) => void;
  patch: (partial: Partial<F>) => void;
  reset: () => void;
}

Generic, typed report-filter state - scenario, comparison, period or dimension selections - in one object with ergonomic setters. There is no schema: F is whatever you pass as initial, and reset() returns to the value captured on the first render.

import { useFilters } from "ibcs-react";

const { filters, setFilter, patch, reset } = useFilters({
  comparison: "PY",
  period: "FY",
  mode: "flow",
});

setFilter("comparison", "PL"); // type-checked against F
patch({ period: "Q4" }); // merge a partial
reset(); // back to the initial object

When to use: a dashboard or report toolbar with a handful of controls - one typed state object instead of several useState calls, with a free reset.

useLiveData

useLiveData<T>(producer: () => T, opts?: UseLiveDataOptions): UseLiveDataResult<T>

interface UseLiveDataOptions {
  intervalMs?: number; // default 2000
  enabled?: boolean;   // live switch, default true
  immediate?: boolean; // produce a value as soon as the feed starts, default false
}

interface UseLiveDataResult<T> {
  data: T;
  refresh: () => void; // produce one value now
  running: boolean;
  start: () => void;
  stop: () => void;    // data keeps its last value
}

Emits a fresh value on an interval - a zero-dependency live feed. SSR-safe: the producer seeds the first value synchronously, and the interval only ever runs inside an effect. enabled is a live switch rather than a seed, but between its transitions your own start() / stop() wins, so a manual pause is never undone by an unrelated re-render.

Total AC 30.4M
6.9MQ16.9MQ27.7MQ38.9MQ4+792.8K+511.8K+1M+2.5M
AC versus PY - data table
ACPYΔPYΔPY%
Q16.9M6.1M+792.8K+13.0%
Q26.9M6.4M+511.8K+8.0%
Q37.7M6.7M+1M+15.2%
Q48.9M6.4M+2.5M+39.4%
import { useLiveData, useCountUp, VarianceColumnChart } from "ibcs-react";

const feed = useLiveData(() => jitter(baseRevenue), { intervalMs: 2500, enabled: false });
const shown = useCountUp(feed.data.reduce((s, d) => s + d.AC, 0), { duration: 600 });

<button onClick={() => (feed.running ? feed.stop() : feed.start())}>
  {feed.running ? "Streaming" : "Go live"}
</button>
<VarianceColumnChart data={feed.data} comparison="PY" />;

When to use: demos, monitoring walls or anything that refreshes on a timer. Charts glide between ticks on their own (every chart tweens its rows through useDataTween); pair your own figures with useCountUp so they tween too.

useVariance and useVariances

useVariance(
  current: number | undefined,
  base: number | undefined,
  higherIsBetter = true,
): Variance | null

useVariances(
  data: ReadonlyArray<number | undefined>,
  comparison: ReadonlyArray<number | undefined>,
  higherIsBetter = true,
): Array<Variance | null>

Memoized wrappers over the core computeVariance. useVariance gives the delta for one pair - { abs, pct, favorable }, or null when either side is missing. useVariances applies the same comparison element-wise across two parallel arrays; the result has the length of data, with null wherever a pair is incomplete.

import { useVariance, useVariances } from "ibcs-react";

// One pair on a cost line - favorable colouring flips:
const v = useVariance(actual, plan, /* higherIsBetter */ false);
// v?.abs, v?.pct, v?.favorable

// Element-wise across two series:
const deltas = useVariances(acValues, pyValues);

When to use: IBCS-correct variances for your own cells or labels - coloured by favorable (impact), not by sign. See IBCS & ISO 24896 for why that distinction matters.

useAsyncData

useAsyncData<T>(
  fetcher: (signal?: AbortSignal) => Promise<T>,
  opts?: UseAsyncDataOptions<T>,
): UseAsyncDataResult<T>

interface UseAsyncDataOptions<T> {
  deps?: unknown[];           // extra reactive inputs; CONSTANT length
  enabled?: boolean;          // default true, reactive
  initialData?: T;            // seed / SSR value
  refreshMs?: number;         // poll interval; omit for one fetch per deps change
  keepPreviousData?: boolean; // default true
}

interface UseAsyncDataResult<T> {
  data: T | undefined;
  error: Error | null;
  loading: boolean;    // first load, nothing to show yet
  refreshing: boolean; // re-fetch with the previous data still on screen
  refetch: () => void;
  lastUpdated: number | null; // epoch ms of the last success
}

Drives a component off an API call, a DB query or anything returning a Promise. The fetcher runs on mount and whenever deps change - never during render, so the hook is SSR-safe. In-flight requests are aborted on unmount, on a manual refetch, when enabled flips to false and before each new run; aborts are ignored rather than surfaced as errors. Because deps is spliced into a real dependency array, its length must stay constant across renders (use null for "not applicable" slots).

import { useAsyncData, ChartState, StatementTable } from "ibcs-react";

const { data, loading, refreshing, error, refetch } = useAsyncData(
  (signal) => fetch("/api/pnl", { signal }).then((r) => r.json()),
  { refreshMs: 30_000 },
);

<ChartState loading={loading} error={error} empty={!data?.length} variant="table" onRetry={refetch}>
  <div style={{ opacity: refreshing ? 0.6 : 1 }}>
    <StatementTable lines={data} />
  </div>
</ChartState>;

When to use: feeding any component from a remote source with first-class loading and refresh state instead of hand-wiring useEffect + useState + abort handling. There is a live demo on Interaction & data.

Interaction

useChartSelection

useChartSelection<K = string>(initial?: Iterable<K>): UseChartSelectionResult<K>

interface UseChartSelectionResult<K> {
  selected: ReadonlySet<K>;
  isSelected: (key: K) => boolean;
  toggle: (key: K) => void;
  clear: () => void;
  set: (keys: Iterable<K>) => void; // replace the selection wholesale
}

A tiny selection model for click-to-filter: pair it with a chart's onSelect, which fires { category, scenario?, value, datum }. Generic over the key type (usually the category string) and SSR-safe - pure useState, no DOM access.

import { VarianceColumnChart, useChartSelection } from "ibcs-react";

const sel = useChartSelection<string>(["Q1"]); // optional initial selection

<VarianceColumnChart data={data} comparison="PY" onSelect={(info) => sel.toggle(info.category)} />;
// elsewhere: rows.filter((r) => sel.isSelected(r.category))

When to use: cross-filtering dashboards, drill-downs, or letting a user pick the categories a detail table should show.

useChartHover

useChartHover<D = unknown>(): UseChartHoverResult<D>

interface UseChartHoverResult<D> {
  hovered: ChartHover<D> | null;              // { category, scenario?, value, datum, x, y }
  tooltipRef: RefObject<HTMLDivElement | null>; // pass to <ChartTooltip ref=…>
  onMove: (info: ChartHoverInfo<D>, event: PointerLike) => void; // PointerLike = { clientX, clientY }
  onLeave: () => void;
  clear: () => void; // alias of onLeave
}

The hover model behind every built-in tooltip: it holds the hovered datum plus the pointer's viewport coordinates, and it wires Escape / outside-tap dismissal for you. Give tooltipRef to ChartTooltip and pointer moves are applied imperatively on an animation frame, so following the cursor costs no React re-render.

It pairs directly with a chart's onHover (the mirror of onSelect, plus x/y) once you have switched the built-in panel off with tooltip={false} - this is the canonical wiring:

import { VarianceColumnChart, ChartTooltip, useChartHover, type ColumnDatum } from "ibcs-react";

const hover = useChartHover<ColumnDatum>();

<VarianceColumnChart
  data={data}
  tooltip={false}
  onHover={(h) => (h ? hover.onMove(h, { clientX: h.x, clientY: h.y }) : hover.onLeave())}
/>;

{
  hover.hovered && (
    <ChartTooltip
      ref={hover.tooltipRef}
      x={hover.hovered.x}
      y={hover.hovered.y}
      title={hover.hovered.category}
      rows={[{ label: "AC", value: "30.1M", strong: true }]}
    />
  );
}

For a mark you draw yourself there is no onHover to borrow, so call onMove from the element's own onPointerMove and onLeave from onPointerLeave - any event with clientX / clientY satisfies PointerLike.

When to use: a fully custom tooltip panel, or the library's panel on a chart you drew yourself. Live demo on Interaction & data.

Layout

useElementSize

useElementSize<T extends HTMLElement = HTMLElement>(): [RefObject<T | null>, ElementSize]

interface ElementSize {
  width: number;
  height: number;
}

Observes an element's content-box size with a ResizeObserver and returns a [ref, size] tuple. The first real measurement lands in a layout effect, before the browser paints, so a consumer never flashes an empty box; later resizes are applied on the next animation frame, which keeps a component that reacts to its own measurement from tripping the browser's "ResizeObserver loop" warning. This is what ChartBox and ResponsiveChart are built on.

import { useElementSize, TrendChart } from "ibcs-react";

function Panel({ data }) {
  const [ref, { width }] = useElementSize<HTMLDivElement>();
  return <div ref={ref}>{width > 0 && <TrendChart width={width} height={260} data={data} />}</div>;
}

SSR-safe: nothing touches window or ResizeObserver at module load or during render. Without a ResizeObserver the element is measured once and then left static.

When to use: measuring your own container. For sizing a chart, reach for ChartBox first - it does this and the fit maths for you.

Theming

useIbcsTokens(override?) resolves a tokens prop against the nearest IbcsThemeProvider and the defaults - the hook every component calls internally, so a custom chart participates in the same theme. It is documented with the rest of the token system in Theming.

Where to next

  • Interaction & data - live demos of useChartSelection, useChartHover and useAsyncData, plus the sizing wrappers.
  • Data model - the StatementLine and scenario datum shapes these hooks operate on.
  • Components - the built-ins, each assembled from exactly these hooks.
ibcs-react is an independent open-source library and is not affiliated with, certified by, or endorsed by the IBCS Association or ISO. It follows the IBCS® notation rules (the basis of ISO 24896); IBCS® is a registered trademark of the IBCS Association.

On this page