React Integration with generator-workbench
This guide explains the recommended way to integrate a React generator into the official generator-workbench shell.
For a new standard generator, the target architecture is:
generator-sdkprovides platform capabilities such as auth, export, cloud save, history, billing, and template helpers.- your React code implements the generator runtime contract
generator-workbenchrenders the official shell around that runtime
This is different from the older "React page directly calls SDK APIs" approach. That raw SDK path still works for fully custom shells, but it is no longer the recommended default for standard generator integration.
What React Actually Integrates
React does not need a separate React-native workbench package.
In this repository, the intended model is:
- React implements the runtime
- the host page renders one
<generator-workbench>custom element - the host assigns
sdk,runtime, andconfigto that element - the workbench mounts the runtime into the correct DOM container
So the key question is not "can React use a Vue shell?" but:
Can your React generator expose the runtime contract expected by the workbench?
The answer is yes.
When To Use This Guide
Use this guide when:
- your generator is written in React
- you want the official shell
- you want login / credits / export / template / cloud / history UI to be owned by the workbench
- you want the generator to converge toward the standard runtime model
Do not use this guide as the primary path when:
- you intentionally own a fully custom shell
- you only need a few SDK APIs in an existing React page
- you do not want to expose
mount(),getState(),getPanelSchema(), and related runtime methods
Architecture
Recommended responsibility split:
- React runtime owns:
- business state
- canvas rendering
- parameter rendering
- runtime export data
PanelSchema
generator-workbenchowns:- top bar
- login / avatar / logout entry
- credits / billing shell UI
- template import / publish entry
- cloud save / history shell actions
generator-sdkowns:- auth
- export actions
- cloud APIs
- history APIs
- billing and template helpers
Recommended Project Structure
src/
sdk.js
main.jsx
workbench-host.jsx
runtime/
state.js
panel-schema.js
runtime.jsStep 1: Create a React Project
Use Vite:
npm create vite@latestor:
pnpm create vite@latestSuggested choices:
- Framework:
React - Variant:
JavaScript
Then:
cd your-project-name
npm installStep 2: Install the SDK and Workbench
npm install @atomm-developer/generator-sdk @atomm-developer/generator-workbenchgenerator-workbench is a custom-element shell. Your React app hosts that element; the workbench then hosts your runtime.
About atomm-ui and atomm-pro
generator-workbench internally depends on atomm-ui and, for some flows, atomm-pro.
That does not mean your runtime must be rewritten in Vue.
The practical boundary is:
- the workbench uses those dependencies to render shell-owned UI
- your React runtime can stay fully React-based
- if your runtime business area also wants to reuse the same visual system, evaluate that separately instead of assuming shell dependencies automatically become runtime dependencies
Step 3: Initialize the SDK Once
Create src/sdk.js:
import { GeneratorSDK } from '@atomm-developer/generator-sdk'
export const sdk = GeneratorSDK.init({
appKey: import.meta.env.VITE_ATOMM_APP_KEY,
env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})Create .env.local:
VITE_ATOMM_APP_KEY=your_app_keyKeep the SDK instance outside React component state. Treat it as a singleton service.
Step 4: Define Serializable Runtime State
Create src/runtime/state.js:
export function createInitialState() {
return {
meta: {
schemaVersion: '1.0.0',
generatorId: 'react-pendant',
title: 'React Pendant Generator',
},
document: {
width: 520,
height: 360,
unit: 'px',
},
params: {
text: 'generator',
width: 96,
height: 128,
accentColor: '#111111',
},
}
}
export function cloneState(value) {
return JSON.parse(JSON.stringify(value))
}
export function setByPath(target, path, value) {
const keys = path.split('.')
let current = target
for (let index = 0; index < keys.length - 1; index += 1) {
const key = keys[index]
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key]
}
current[keys[keys.length - 1]] = value
}The runtime state should stay JSON-serializable because the host may restore, save, export, or template it later.
Step 5: Define PanelSchema
Create src/runtime/panel-schema.js:
export function getPanelSchema() {
return {
version: '1.0.0',
generatorId: 'react-pendant',
groups: [
{
id: 'content',
title: 'Content',
fields: [
{
id: 'text',
label: 'Text',
type: 'text',
bind: { path: 'params.text' },
},
],
},
{
id: 'size',
title: 'Size',
fields: [
{
id: 'width',
label: 'Width',
type: 'number',
bind: { path: 'params.width' },
},
{
id: 'height',
label: 'Height',
type: 'number',
bind: { path: 'params.height' },
},
],
},
],
}
}
export function applyPanelFilter(schema, panelFilter) {
if (!panelFilter) {
return schema
}
const includeGroups = panelFilter.includeGroups || null
const excludeGroups = new Set(panelFilter.excludeGroups || [])
const includeFields = panelFilter.includeFields || null
const excludeFields = new Set(panelFilter.excludeFields || [])
const readonlyFields = new Set(panelFilter.readonlyFields || [])
const groups = schema.groups
.filter((group) => !excludeGroups.has(group.id))
.filter((group) => !includeGroups || includeGroups.includes(group.id))
.map((group) => ({
...group,
fields: group.fields
.filter((field) => !excludeFields.has(field.id) && !excludeFields.has(field.bind.path))
.filter((field) => !includeFields || includeFields.includes(field.id) || includeFields.includes(field.bind.path))
.map((field) => ({
...field,
readonly:
field.readonly ||
readonlyFields.has(field.id) ||
readonlyFields.has(field.bind.path),
})),
}))
.filter((group) => group.fields.length > 0)
return {
...schema,
groups,
}
}This is what allows template hosts and workbench-owned template flows to consume partial parameters instead of hardcoding a separate form.
Step 6: Wrap React UI as a Runtime
Create src/runtime/runtime.js:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInitialState, cloneState, setByPath } from './state'
import { getPanelSchema, applyPanelFilter } from './panel-schema'
function CanvasView({ state }) {
return (
<div style={{ display: 'grid', placeItems: 'center', minHeight: '100%' }}>
<div
style={{
width: 520,
height: 360,
borderRadius: 20,
border: '1px solid #dbe3f0',
background: '#fff',
display: 'grid',
placeItems: 'center',
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, color: state.params.accentColor }}>
{state.params.text}
</div>
<div style={{ marginTop: 8, color: '#64748b' }}>
{state.params.width} x {state.params.height} mm
</div>
</div>
</div>
</div>
)
}
function PanelView({ schema, state, onFieldChange }) {
return (
<div style={{ padding: 16 }}>
{schema.groups.map((group) => (
<section key={group.id} style={{ marginBottom: 16 }}>
<h3>{group.title}</h3>
{group.fields.map((field) => (
<label
key={field.id}
style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}
>
<span>{field.label}</span>
<input
disabled={field.readonly}
type={field.type === 'number' ? 'number' : 'text'}
value={String(resolveFieldValue(state, field.bind.path) ?? '')}
onChange={(event) => {
const nextValue =
field.type === 'number'
? Number(event.target.value)
: event.target.value
onFieldChange(field.bind.path, nextValue)
}}
/>
</label>
))}
</section>
))}
</div>
)
}
function FullWorkspace(props) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '320px minmax(0, 1fr)',
minHeight: '100%',
}}
>
<aside style={{ borderRight: '1px solid #e5e7eb', background: '#fff' }}>
<PanelView {...props} />
</aside>
<main style={{ padding: 24, background: '#f5f7fb' }}>
<CanvasView state={props.state} />
</main>
</div>
)
}
function resolveFieldValue(state, path) {
return path.split('.').reduce((current, key) => current?.[key], state)
}
export function createGeneratorRuntime() {
let state = createInitialState()
const listeners = new Set()
const mounts = new Map()
function emit(event) {
listeners.forEach((listener) => listener(event))
}
function getState() {
return cloneState(state)
}
function renderMount(record) {
const schema = applyPanelFilter(getPanelSchema(), record.panelFilter)
const onFieldChange = (path, value) => {
patchState(
{ [path]: value },
{
source: 'user',
},
)
emit({
type: 'params_change',
data: {
field: path.replace(/^params\./, ''),
value,
params: cloneState(state.params),
},
})
}
if (record.mode === 'embed' && record.target === 'canvas') {
record.root.render(<CanvasView state={state} />)
return
}
if (record.mode === 'embed' && record.target === 'panel') {
record.root.render(
<PanelView
schema={schema}
state={state}
onFieldChange={onFieldChange}
/>,
)
return
}
record.root.render(
<FullWorkspace
schema={schema}
state={state}
onFieldChange={onFieldChange}
/>,
)
}
function rerender() {
mounts.forEach((record) => renderMount(record))
}
function setState(nextState, options = {}) {
state = cloneState(nextState)
rerender()
if (!options.silent) {
emit({
type: 'state-change',
state: getState(),
source: options.source || 'host',
})
}
}
function patchState(patch, options = {}) {
Object.entries(patch || {}).forEach(([path, value]) => {
setByPath(state, path, value)
})
rerender()
if (!options.silent) {
emit({
type: 'state-change',
state: getState(),
patch,
source: options.source || 'host',
})
}
}
return {
version: '1.0.0',
generatorId: 'react-pendant',
capabilities: {
modes: ['full', 'embed'],
mountTargets: ['full', 'canvas', 'panel'],
supportsPatchState: true,
supportsReadonly: true,
supportsPanelFilter: true,
supportsPartialPanel: true,
exports: ['cover', 'download-svg', 'open-in-studio'],
},
mount({
mode,
target = 'full',
container,
state: incomingState,
panelFilter,
}) {
if (incomingState) {
state = cloneState(incomingState)
}
const existing = mounts.get(container)
const root = existing?.root || createRoot(container)
const record = {
root,
mode,
target,
container,
panelFilter,
}
mounts.set(container, record)
renderMount(record)
emit({ type: 'ready' })
return {
unmount() {
const current = mounts.get(container)
current?.root.unmount()
mounts.delete(container)
},
}
},
getState,
setState,
patchState,
getPanelSchema({ panelFilter } = {}) {
return applyPanelFilter(getPanelSchema(), panelFilter)
},
async export(action) {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="360" viewBox="0 0 520 360">
<rect width="520" height="360" rx="20" fill="white" stroke="#dbe3f0" />
<text x="260" y="170" text-anchor="middle" font-size="28" fill="${state.params.accentColor}">
${state.params.text}
</text>
<text x="260" y="210" text-anchor="middle" font-size="14" fill="#64748b">
${action}
</text>
</svg>`
return {
dataUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
mimeType: 'image/svg+xml',
ext: 'svg',
width: 520,
height: 360,
}
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}This is the key adaptation layer.
The workbench does not mount your React application directly. It mounts a runtime object. That runtime object is where React is attached to the DOM containers passed by the host.
Step 7: Host the Workbench Custom Element from React
Create src/workbench-host.jsx:
import { useEffect, useRef } from 'react'
import { GeneratorWorkbench } from '@atomm-developer/generator-workbench'
import { sdk } from './sdk'
import { createGeneratorRuntime } from './runtime/runtime'
GeneratorWorkbench.defineGeneratorWorkbench()
export default function WorkbenchHost() {
const elementRef = useRef(null)
const runtimeRef = useRef(null)
if (!runtimeRef.current) {
runtimeRef.current = createGeneratorRuntime()
window.__GENERATOR_RUNTIME__ = runtimeRef.current
}
useEffect(() => {
const element = elementRef.current
if (!element) {
return undefined
}
element.sdk = sdk
element.runtime = runtimeRef.current
element.config = {
title: 'React Pendant Generator',
mode: 'shell',
readyPolicy: 'runtime-ready',
exportEnabled: true,
studioEnabled: true,
templateEnabled: true,
}
let disposed = false
Promise.resolve(element.mount()).catch((error) => {
console.error('Failed to mount workbench', error)
})
return () => {
if (!disposed) {
element.unmount?.()
disposed = true
}
}
}, [])
return <generator-workbench ref={elementRef} />
}Then update src/main.jsx:
import React from 'react'
import ReactDOM from 'react-dom/client'
import WorkbenchHost from './workbench-host'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<WorkbenchHost />
</React.StrictMode>,
)This is the recommended React-side host pattern:
- React renders the custom element
- React assigns object properties through a ref
- the custom element mounts the runtime
Step 8: Understand shell, full, and embed
Most React generators should start with:
element.config = {
mode: 'shell',
}In shell mode:
- the workbench keeps the official top bar
- your runtime owns the internal workspace layout
- your runtime usually mounts as a full workspace
If you need template-host-style separation, your runtime must also support:
runtime.mount({
mode: 'embed',
target: 'canvas',
container,
})
runtime.mount({
mode: 'embed',
target: 'panel',
container,
panelFilter,
})That is why the runtime adapter above supports:
mode: 'full'mode: 'embed'target: 'full' | 'canvas' | 'panel'
Step 9: Let the Workbench Own Platform UI
Once you adopt generator-workbench, do not rebuild shell features inside the React runtime unless you intentionally want custom behavior.
Typical ownership after integration:
- login button: workbench
- avatar / logout entry: workbench
- credits badge: workbench
- export entry: workbench
- template publish / import entry: workbench
- cloud save / history entry: workbench
The React runtime should stay focused on:
- state
- rendering
- schema
- export payload
Step 10: Turn On Optional Shell Capabilities
For cloud save and history:
element.config = {
title: 'React Pendant Generator',
mode: 'shell',
cloudEnabled: true,
historyEnabled: true,
autoSaveEnabled: true,
getCloudSaveOptions: ({ state }) => ({
title: 'Draft',
snapshot: state,
}),
}These features require the runtime to expose stable state and preferably subscribe(listener).
For Open in Studio and download, the runtime should provide export(action), and the SDK/workbench combination decides how that export is consumed.
TypeScript Note
If your React project uses TypeScript, add JSX typing for the custom element:
declare namespace JSX {
interface IntrinsicElements {
'generator-workbench': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
}
}Common Mistakes
- Treating
generator-workbenchas if it replaces runtime business logic - Rendering login, credits, and template shell UI inside the React runtime again
- Building only one React page instead of exposing a runtime object
- Skipping
getPanelSchema()and then expecting template flows to work - Making state non-serializable
- Implementing only a full page and not supporting
embedtargets when template scenarios are needed
If You Still Want Raw SDK-Only React Integration
That path still works when you intentionally own a custom shell.
In that case:
- React renders the full page itself
- you call
sdk.auth.login(),sdk.export.openInStudio(), and other APIs directly - you do not use
generator-workbenchas the shell host
Use that path only when shell customization is more important than standard generator integration.
Upload Resources
- Run the production build, for example
npm run build. - Compress the generated
distdirectory into a zip package. - Make sure the zip root contains
index.html. - Upload it to https://www.atomm.com/generator-upload.
One-Sentence Summary
The recommended React integration path is:
React implements the runtime contract, generator-workbench hosts the official shell, and generator-sdk provides the platform capabilities underneath.