---
url: https://chartbuddy.io/embed/docs/guides/react.md
---
# React

```bash
npm install @chartbuddy.io/embed react
```

`react` is an optional peer dependency — install it only if you use these
bindings. React 18 or 19.

```jsx
import { InsightChart } from '@chartbuddy.io/embed/react';

export function Revenue({ grid }) {
  return (
    <InsightChart
      chartData={{ chartType: 'clusteredBar', isDataTransposed: true, seriesData: grid }}
      style={{ height: 400 }}
    />
  );
}
```

The chart fills its container, so **give the container a height**. A container
with no height renders nothing visible.

## Without a bundler (CDN / single HTML file)

The bindings import `react` as a bare specifier, so a plain HTML page needs an
**import map** to tell the browser where React lives. This is the whole setup —
no build step:

```html
<script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/react@19",
      "react-dom": "https://esm.sh/react-dom@19",
      "react-dom/client": "https://esm.sh/react-dom@19/client",
      "@chartbuddy.io/embed": "https://unpkg.com/@chartbuddy.io/embed",
      "@chartbuddy.io/embed/react": "https://unpkg.com/@chartbuddy.io/embed/react.mjs"
    }
  }
</script>

<div id="root"></div>

<script type="module">
  import { createElement as h } from 'react';
  import { createRoot } from 'react-dom/client';
  import { InsightChart } from '@chartbuddy.io/embed/react';

  const chartData = {
    chartType: 'clusteredBar',
    isDataTransposed: true,
    seriesData: [['', 'Q1', 'Q2'], ['Revenue', 100, 112]],
  };

  createRoot(document.getElementById('root')).render(
    h(InsightChart, { chartData, style: { height: '400px' } }),
  );
</script>
```

Two things to get right:

* The import map must map **both** `react` and `@chartbuddy.io/embed/react`. The
  subpath needs the explicit `.mjs` file — bare-specifier subpath resolution does
  not work in browsers.
* Use `React.createElement` (aliased to `h` above) rather than JSX, since there is
  no build step to compile JSX.

Import maps need a modern browser. If you are generating a standalone artifact for
an AI host and JSX or import maps are awkward, skip React entirely and use the
plain [`Insight`](/api/) API or the
[custom element](/guides/angular#other-frameworks) — both work with a single
`<script type="module">` and no mapping.

## Why use the wrapper

`new Insight()` owns a DOM node and lives across renders, which is the shape
React is worst at. The wrapper handles four things you would otherwise write
yourself:

* **StrictMode double-mount.** React mounts, unmounts, and remounts every effect
  in development. The wrapper tears the throwaway instance down and ignores its
  late `ready` resolution, so you get one chart, not two.
* **Data changes patch instead of remount.** A new `chartData` calls `update()`
  on the live instance. Rebuilding the chart on every keystroke is the most
  common way to make an embedded editor feel broken.
* **Inline handlers don't remount.** `onChange={() => …}` is a new function every
  render. The wrapper reads handlers through a ref, so the chart never sees a
  changed dependency.
* **Unmount destroys.** Listeners and the instance are cleaned up, so navigating
  away does not leak.

## Editable charts with controlled data

```jsx
import { useState, useMemo } from 'react';
import { InsightChart } from '@chartbuddy.io/embed/react';

export function Editor({ initial }) {
  const [chartData, setChartData] = useState(initial);

  return (
    <>
      <InsightChart
        chartData={chartData}
        editable
        onChange={setChartData}
        style={{ height: 480 }}
      />
      <button onClick={() => save(chartData)}>Save</button>
    </>
  );
}
```

`onChange` fires after live edits in edit mode, after programmatic updates, and
on Done. Feeding it straight back into `chartData` is safe: the wrapper compares
by identity, and the object it hands you is the one it already applied.

## Memoize inline `chartData`

`chartData` is compared **by identity**, not deeply. An object literal built in
the render body is a new object every render, so it fires an `update()` every
render:

```jsx
// Fires update() on every render
<InsightChart chartData={{ chartType: 'line', seriesData: grid }} />

// Fires update() only when `grid` changes
const chartData = useMemo(() => ({ chartType: 'line', seriesData: grid }), [grid]);
<InsightChart chartData={chartData} />
```

Redundant updates are not incorrect — the chart redraws to the same result — but
on a dashboard of many charts you will feel it.

## `useInsight` for imperative access

When you need the instance itself — PNG export, edit mode, revision tracking —
use the hook and place the container yourself.

```jsx
import { useInsight } from '@chartbuddy.io/embed/react';

function Panel({ chartData }) {
  const { ref, insight, ready, error } = useInsight({
    chartData,
    instanceId: 'revenue',
    onReady: () => console.log('booted'),
  });

  return (
    <>
      <div ref={ref} style={{ height: 400 }} />
      <button disabled={!ready} onClick={() => insight.downloadPng()}>
        Download PNG
      </button>
      <button disabled={!ready} onClick={() => insight.enterEditMode()}>
        Edit
      </button>
      {error && <p role="alert">{error.message}</p>}
    </>
  );
}
```

`insight` is `null` until the mount resolves — guard imperative calls on `ready`
or on `insight` itself.

## What remounts, what patches

| Prop change | Effect |
|---|---|
| `chartData` | `update()` on the live instance |
| `instanceId`, `editable`, `assetBase`, `hostOverrides` | Full remount (destroy + construct) |
| `onChange`, `onReady`, `onModeChange`, `onError` | Nothing — handlers are swapped in place |
| `className`, `style` | Normal React DOM update |

Toggling `editable` remounting is deliberate: view mode and the full editor are
different mounts. If you toggle it often, prefer one mount plus
`insight.enterEditMode()` / `exitEditMode()`.

## Handling invalid data

Chart data is validated at the API boundary, so bad data throws during mount.
The wrapper catches it and gives you a choice:

```jsx
import { ChartDataValidationError } from '@chartbuddy.io/embed';

<InsightChart
  chartData={chartData}
  onError={(err) => {
    if (err instanceof ChartDataValidationError) {
      setIssues(err.errors); // [{ path, code, expected, suggestion }, …]
    }
  }}
/>
```

Without an `onError`, the error is re-thrown during render so a React
[error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
catches it — a chart that fails validation should never fail silently. See
[Validation](/quality-assurance/validation).

## Dashboards

One `<InsightChart>` per chart, each with a stable `instanceId`. Duplicate ids on
a page throw, so derive them from your data rather than the array index if the
list can reorder.

```jsx
{panels.map((panel) => (
  <InsightChart
    key={panel.id}
    instanceId={panel.id}
    chartData={panel.chartData}
    style={{ height: 300 }}
  />
))}
```

Each chart loads the engine once per page, not once per chart — but do read
[Installation](/getting-started/installation#bundle-size-and-many-charts) before
putting a lot of charts on one screen.

## Server-side rendering

The bindings are browser-only: the chart engine needs a real DOM. Under Next.js
or Remix the mount happens in an effect, so nothing runs during SSR, but the
module itself should not be imported on the server:

```jsx
const InsightChart = dynamic(
  () => import('@chartbuddy.io/embed/react').then((m) => m.InsightChart),
  { ssr: false },
);
```

There is no headless/Node render path today, so charts cannot be pre-rendered to
PNG on a server.
