How to set up a React plugin, mount your component tree, and wire the plain JS SDK into React hooks
The Plugin SDK is framework-agnostic, but React works well for plugins that need rich interactive UIs. This page shows how to scaffold a React plugin project, mount the component tree inside the sandboxed iframe, and wire the SDK's lifecycle and state APIs into clean React patterns.
Project Setup
Scaffold a React plugin using Vite:
npm create internote-plugin@latest my-plugin -- --template react
cd my-plugin
The React template configures Vite to bundle your plugin into a single index.html that the sandbox iframe loads.
If you are adding React to an existing plugin project manually:
npm install react react-dom
npm install --save-dev @vitejs/plugin-react vite
Entry Point
Your src/main.jsx mounts the React root and wires internote.ready() to fire after the first render:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { internote } from '@internote/plugin-sdk'
import { App } from './App'
internote.onInitialize(({ attrs, width, height, animation }) => {
const root = createRoot(document.getElementById('root'))
root.render(
<StrictMode>
<App
initialAttrs={attrs}
width={width}
height={height}
initialAnimation={animation}
/>
</StrictMode>
)
})
internote.onUnload(() => {
})
Do not call internote.ready() in main.jsx. Call it from inside your root component after the first render using useEffect — this guarantees the frame is actually painted before the host reveals it.
Signalling Ready
Call internote.ready() inside a useEffect with an empty dependency array so it fires exactly once after the initial paint:
import { useEffect } from 'react'
import { internote } from '@internote/plugin-sdk'
export function App({ initialAttrs, width, height }) {
useEffect(() => {
internote.ready()
}, [])
return <div style={{ width, height }}>...</div>
}
Syncing Attributes
Use useState to hold the current attrs and update it in onAttrsUpdate. Initialise from initialAttrs so the first render has the correct values:
import { useState, useEffect } from 'react'
import { internote } from '@internote/plugin-sdk'
export function useAttrs(initialAttrs) {
const [attrs, setAttrs] = useState(initialAttrs)
useEffect(() => {
internote.onAttrsUpdate(setAttrs)
}, [])
return attrs
}
Usage:
export function App({ initialAttrs, width, height }) {
const attrs = useAttrs(initialAttrs)
useEffect(() => {
internote.ready()
}, [])
return (
<Simulation
particles={attrs.particles}
gravity={attrs.gravity}
width={width}
height={height}
/>
)
}
Syncing Animation State
The same pattern applies to animation state:
import { useState, useEffect } from 'react'
import { internote } from '@internote/plugin-sdk'
export function useAnimationState(initialAnimation) {
const [state, setState] = useState(initialAnimation)
useEffect(() => {
internote.animation.onState(setState)
}, [])
return state
}
export function App({ initialAttrs, initialAnimation, width, height }) {
const attrs = useAttrs(initialAttrs)
const animation = useAnimationState(initialAnimation)
useEffect(() => {
internote.ready()
}, [])
return (
<Simulation
gravity={attrs.gravity}
isPaused={animation?.isPaused ?? false}
cutIndex={animation?.cutIndex ?? null}
width={width}
height={height}
/>
)
}
Resize
Forward resize events into React state using the same pattern:
import { useState, useEffect } from 'react'
import { internote } from '@internote/plugin-sdk'
export function useSize(initialWidth, initialHeight) {
const [size, setSize] = useState({ width: initialWidth, height: initialHeight })
useEffect(() => {
internote.onResize(setSize)
}, [])
return size
}
Canvas Inside React
For canvas-based rendering, use a ref to access the canvas element imperatively while keeping the rest of your UI declarative:
import { useRef, useEffect } from 'react'
export function SimulationCanvas({ gravity, particles, width, height, isPaused }) {
const canvasRef = useRef(null)
const rafRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
const loop = () => {
if (!isPaused) draw(ctx, gravity, particles, width, height)
rafRef.current = requestAnimationFrame(loop)
}
rafRef.current = requestAnimationFrame(loop)
return () => cancelAnimationFrame(rafRef.current)
}, [gravity, particles, width, height, isPaused])
return <canvas ref={canvasRef} width={width} height={height} />
}
Heavy simulation logic (physics steps, vector field calculations) should run in a Worker spawned from the canvas effect, not in the React render cycle. Pass computed frame data back via postMessage and write it to the canvas in the RAF callback.
Handler Registration Order
Register all SDK handlers (onInitialize, onAttrsUpdate, animation.onState, onResize, onUnload) at the top level of your entry file — not inside React components or effects. The SDK queues messages until handlers are registered; handlers inside effects may miss the init message.
import { internote } from '@internote/plugin-sdk'
import { createRoot } from 'react-dom/client'
import { App } from './App'
internote.onInitialize(({ attrs, width, height, animation }) => {
const root = createRoot(document.getElementById('root'))
root.render(<App initialAttrs={attrs} width={width} height={height} initialAnimation={animation} />)
})
internote.onUnload(() => {
})