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(): booleanTrue 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): numberEased 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): numberA 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">): TuseAnimatedValue 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 objectWhen 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.
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,useChartHoveranduseAsyncData, plus the sizing wrappers. - Data model - the
StatementLineand scenario datum shapes these hooks operate on. - Components - the built-ins, each assembled from exactly these hooks.
Report cookbook
97 ready-made business reports and dashboards across seven domains - each a live mini-report built from one to four ibcs-react components, with copy-pasteable source.
Interaction & data
Tooltips, onHover and onSelect, controlled tables, async data with useAsyncData - and the ChartBox sizing wrappers that fit a chart to its space.