Skip to content
D
Documentation

React: custom content

how-to
4 min readUpdated

Use React components when a node or dashboard widget needs application UI, context or local state. You paint the inside of the box; the engine owns its geometry and gestures. For a different silhouette or fill without application UI, use the shipped shapes instead; see JavaScript: elements and content.

The examples below run in your React application's browser entry. Install the binding and its peers:

bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom

1. Render components inside connected nodes

GrafloriaFlow maps nodeTypes keys to node type strings. Your component receives NodeProps:

  • id: the node's id.
  • data: the node's payload, not a separate set of component props.
  • selected: the live selection state.
  • node: the live NodeModel, for queries or tracked setters beyond the other props.

Type the registry with NodeTypes, the nodes with NodeSpec, and the connections with EdgeSpec. Each custom spec below carries custom: true; for the HTML rendering opt-in, see elements and content.

Replace your application's App.tsx with this example. It renders Build and Deploy cards connected by an edge. Each card reads the surrounding React context; the checkbox changes the owner line in both cards.

tsx
import { createContext, useContext, useState } from 'react';
import {
  GrafloriaFlow,
  type NodeProps,
  type NodeTypes,
} from '@grafloria/react';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';

type CardData = { title: string; owner: string };
const ShowOwners = createContext(true);

function Card({ id, data, selected }: NodeProps<CardData>) {
  const showOwners = useContext(ShowOwners);
  return (
    <div
      aria-label={`${data.title} (${id})`}
      style={{
        width: '100%',
        height: '100%',
        boxSizing: 'border-box',
        padding: '12px 16px',
        background: '#fff',
        color: '#232a3d',
        borderRadius: 12,
        border: selected ? '2px solid #3b52d9' : '2px solid #94a5f0',
        font: '14px/1.5 system-ui, sans-serif',
      }}
    >
      <strong>{data.title}</strong>
      {showOwners && <div>Owner: {data.owner}</div>}
    </div>
  );
}

const nodeTypes: NodeTypes = { card: Card };
const nodes: NodeSpec[] = [
  {
    id: 'build', type: 'card', custom: true,
    position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Build', owner: 'CI' },
  },
  {
    id: 'deploy', type: 'card', custom: true,
    position: { x: 430, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Deploy', owner: 'CD' },
  },
];
const edges: EdgeSpec[] = [
  { id: 'build-deploy', source: 'build', target: 'deploy' },
];

export default function App() {
  const [showOwners, setShowOwners] = useState(true);
  return (
    <ShowOwners.Provider value={showOwners}>
      <label>
        <input
          type="checkbox"
          checked={showOwners}
          onChange={(event) => setShowOwners(event.target.checked)}
        />
        Show owners
      </label>
      <div style={{ height: 400 }}>
        <GrafloriaFlow
          defaultNodes={nodes}
          defaultEdges={edges}
          nodeTypes={nodeTypes}
          fitView
        />
      </div>
    </ShowOwners.Provider>
  );
}
Build and Deploy cards connected by an arrow, with owner lines and the Show owners checkbox.

Click a card to change its selection border, or drag it to move its box. The binding uses React portals, so context and hooks remain part of your application tree rather than a separate React root. Removing a node drops its portal and unmounts the component.

Give each node a size and fill that box with width: '100%', height: '100%' and boxSizing: 'border-box'. Padding and borders then stay inside the geometry used for hit-testing and connections. Declare ports on the spec, not as elements inside the card; see port connections.

When node content refreshes

The binding refreshes custom components on nodes:change and selection:change, reading node.isSelected() again. It also observes the node's data and metadata writes and supplies a fresh data object. Use node.setData() rather than assigning payload properties directly.

Position changes during a drag move the host rather than triggering a content render on every frame. Keep positioning out of your component's styles. Your own React state and context still render normally.

These examples use uncontrolled defaults. For controlled data and its change-event return path, follow the React quick start.

2. Mix custom widgets with shipped painters

GrafloriaDashboard uses widgetTypes to map a widget's kind to a component. WidgetProps supplies { widget, data }: the full widget spec and its payload. Unlike nodes, dashboard widgets need no custom flag.

Use the shipped kpi, line, bar, donut, funnel and table painters for those kinds. Register a component only where you need your own UI; unmatched kinds pass to the built-in renderer.

Known issue: A painter supplied through options.renderWidget never runs in the React binding: the wrapper overwrites that option with its portal callback. Until this is fixed, supply custom content through widgetTypes and keep options for board behavior and geometry.

The kit's intended custom-painting option is options.renderWidget; the React equivalent is widgetTypes={{ note: Note }}, used below. Type that mapping with WidgetTypes and the data with DashboardWidgetSpec.

Replace App.tsx with this independent example. It renders a shipped Revenue KPI beside a React release note. The note reads application context and keeps an acknowledgement count in local state. The toolbar switches the mounted board between fit and grow sizing.

tsx
import { createContext, useContext, useState } from 'react';
import {
  GrafloriaDashboard,
  type WidgetProps,
  type WidgetTypes,
} from '@grafloria/react';
import type { DashboardWidgetSpec } from '@grafloria/element';

const Team = createContext('Release team');

function Note({ widget, data }: WidgetProps) {
  const team = useContext(Team);
  const [acknowledgements, setAcknowledgements] = useState(0);
  const text = typeof data.text === 'string' ? data.text : '';
  return (
    <section style={{
      width: '100%', height: '100%', boxSizing: 'border-box',
      padding: 16, background: '#eef2ff', color: '#232a3d',
      borderRadius: 10, font: '14px/1.5 system-ui, sans-serif',
    }}>
      <strong>{widget.title}</strong>
      <div>{team}</div>
      <p>{text}</p>
      <button onClick={() => setAcknowledgements((count) => count + 1)}>
        Acknowledge ({acknowledgements})
      </button>
    </section>
  );
}

const widgetTypes: WidgetTypes = { note: Note };
const widgets: DashboardWidgetSpec[] = [
  {
    id: 'revenue', kind: 'kpi', span: 3, rows: 1,
    data: { label: 'Revenue', value: '$6.81M', delta: 12.4 },
  },
  {
    id: 'release', kind: 'note', span: 3, rows: 1,
    title: 'Release note', data: { text: 'Deployment approved for Friday.' },
  },
];

export default function App() {
  const [sizing, setSizing] = useState<'fit' | 'grow'>('fit');
  return (
    <Team.Provider value="Release team">
      <button onClick={() => setSizing('fit')}>Fit rows</button>
      <button onClick={() => setSizing('grow')}>Grow rows</button>
      <span> Sizing: {sizing}</span>
      <div style={{ height: 400 }}>
        <GrafloriaDashboard
          widgets={widgets}
          widgetTypes={widgetTypes}
          options={{ columns: 6, gap: 8, rowHeight: 180 }}
          sizing={sizing}
        />
      </div>
    </Team.Provider>
  );
}
Revenue KPI beside the release note, with Acknowledge (0), Fit rows and Grow rows controls and Sizing: fit.

Custom widgets also mount through portals. Switching sizing calls the board handle rather than remounting the board, so the note's acknowledgement state remains intact.

3. Mount a spec without React content registrations

Use GrafloriaDiagram when you already have a RenderSpec rather than React node or widget components. It accepts plain diagram specs as well as kit specs. This independent App.tsx renders two connected, shipped silhouettes without a nodeTypes registry.

tsx
import { GrafloriaDiagram } from '@grafloria/react';
import type { RenderSpec } from '@grafloria/element';

const spec: RenderSpec = {
  nodes: [
    {
      id: 'ingest', position: { x: 60, y: 80 },
      size: { width: 180, height: 80 }, label: 'Ingest',
      shape: { type: 'terminal', fill: '#ecfdf5', stroke: '#059669' },
    },
    {
      id: 'publish', position: { x: 380, y: 80 },
      size: { width: 180, height: 80 }, label: 'Publish',
      shape: { type: 'document', fill: '#fdf4ff', stroke: '#9333ea' },
    },
  ],
  edges: [{ id: 'ingest-publish', source: 'ingest', target: 'publish' }],
};

export default function App() {
  return (
    <div style={{ height: 400 }}>
      <GrafloriaDiagram spec={spec} options={{ fitView: true }} />
    </div>
  );
}

Unlike the mount-once dashboard inputs, a changed spec or options value replaces this component's diagram. An equal value rebuilt on a React render keeps the existing instance. For React components inside boxes, keep using GrafloriaFlow or GrafloriaDashboard above.

Options that matter

Board geometry lives in DashboardOptions. The following defaults come from the kit; the example explicitly selects fit sizing at first render.

OptionTypeDefaultWhat it does
columnsnumber12Sets the column count for views without an override.
gapnumber8Sets the widget gap and board padding in pixels.
rowHeightnumber130Sets row height in grow mode.
sizing'fit' | 'grow'Grow for fluid boards; fit for fixed boardsFit keeps the board height and squeezes rows; grow extends downward at rowHeight.
layout'grid' | 'split''grid'Selects cells or a splitter tree; split always uses fit sizing.
mode'fluid' | 'fixed'Fluid unless width is suppliedFluid uses the container's CSS dimensions; fixed uses authored world dimensions.

Respect the mount-once board props

views, widgets and options seed the dashboard at mount. Replacing those props later does not rebuild or reconcile the board. Use either views for multiple boards or widgets for one board, not both.

The live props are activeView, layout, sizing and static. Changes call the handle's showView(), setLayout(), setSizing() and setStatic() respectively. Top-level layout, sizing and static override the corresponding options fields at mount.

For runtime widget edits, capture the DashboardHandle with onReady and use its widget methods rather than replacing the initial array. Use onLayoutChange to receive the affected view's widgets after committed gestures. See Build a dashboard for handle-driven editing.

Was this page helpful?

React: custom content — Grafloria