React previews
The react adapter renders one of your React components on the Workbench
canvas, with the preview’s inputs as its props. Each named state is a set of
props, and you can edit them in Preview controls while you review.
Use it for components and pages that render in the browser. To review your whole application as it runs, use a URL lens. For Storybook stories, see Storybook.
This guide covers what is specific to React. The definition keys, states, controls, lifecycle hooks, command-line tools, and portable exports are the same for every adapter and are described in TypeScript Workbench previews.
Requirements
reactandreact-dom18 or later, installed in your project. Workbench compiles your TypeScript and JSX itself but uses your project’s React. It findsreactandreact-domfrom the definition’s folder, the way Node does, and never installs or replaces them.workbench.yamlat the project root.name: Acmeis enough.- A trusted workspace, since previews run project code.
Write a first preview
acme-web/
├── workbench.yaml
├── package.json react and react-dom
└── src/components/
├── Button.tsx
├── Button.css
└── Button.workbench.ts
src/components/Button.tsx:
import type { ReactNode } from 'react';
import './Button.css';
export interface ButtonProps {
label: string;
tone?: 'primary' | 'secondary';
disabled?: boolean;
icon?: ReactNode;
onClick?: () => void;
}
export function Button({ label, tone = 'primary', disabled = false, icon, onClick }: ButtonProps) {
return (
<button className={`acme-button acme-button--${tone}`} disabled={disabled} onClick={onClick}>
{icon}
{label}
</button>
);
}
src/components/Button.css:
.acme-button {
font: 600 16px/1 system-ui, sans-serif;
padding: 12px 20px;
border: 0;
border-radius: 8px;
}
.acme-button--primary { background: #2563eb; color: white; }
.acme-button--secondary { background: #e5e7eb; color: #111827; }
.acme-button:disabled { opacity: 0.5; }
src/components/Button.workbench.ts:
import { definePreview } from '@canonic2/workbench';
export default definePreview({
id: 'components/button',
title: 'Components/Button',
adapter: 'react',
source: { entry: './Button.tsx', export: 'Button' },
inputs: { label: 'Continue', tone: 'primary', disabled: false },
controls: {
label: { type: 'text' },
tone: { type: 'select', options: ['primary', 'secondary'] },
disabled: { type: 'boolean' },
},
states: {
primary: {},
secondary: { inputs: { tone: 'secondary' } },
disabled: { inputs: { disabled: true } },
},
});
Run Workbench: Refresh Pages. Button appears in a Components
collection of the sidebar, with the states Primary, Secondary, and
Disabled. To build it without the canvas, run the
checker on the project; it prints
Built components/button.
source.export names the component’s export and defaults to default. Each
state renders in a fresh React root, and the previous root is unmounted first.
Name the component in source rather than importing it into the definition.
Workbench runs the definition in Node to list your previews, so a definition
that imports a component which imports an image fails with
No loader is configured for ".png" files. Importing types with
import type is fine.
Props, children, and callbacks
The merged inputs of the preview, the state, and any edits in
Preview controls are passed as the component’s props. Inputs must be plain
data that can be copied, such as strings, numbers, booleans, arrays, and
objects.
-
Text children: set a
childreninput, such asinputs: { children: 'Everything your team needs.' }. Give it atextcontrol to edit it. -
Elements, such as icons or slots: an element can’t be an input. Write a small preview-only module that supplies it, and render that module in a state with the state’s
source:// src/components/Button.preview.tsx import { Button, type ButtonProps } from './Button'; function StarIcon() { return <span aria-hidden="true">★ </span>; } export function WithIcon(props: ButtonProps) { return <Button {...props} icon={<StarIcon />} />; }states: { primary: {}, 'with-icon': { source: { entry: './Button.preview.tsx', export: 'WithIcon' } }, },The module’s name doesn’t end in
.workbench.ts, so it isn’t listed as a preview of its own. -
Callbacks: a function can’t be an input either. Supply callbacks from an environment and report each call with
context.action, so it appears under Actions in Preview controls.
Add providers and context
Name an environment module in the definition to wrap the component in the
providers it needs: a theme, a router, translations, or a data client. The
module’s wrap(element, context) receives the component’s element and returns
the tree to render. It runs on every render, so read the current state from
context.
src/preview/environment.tsx:
import { cloneElement, type ReactElement } from 'react';
import type { PreviewContext } from '@canonic2/workbench';
import { ThemeProvider, type Theme } from '@/theme';
export function setup(context: PreviewContext) {
document.documentElement.lang = String(context.globals.locale ?? 'en');
}
export function wrap(element: ReactElement<Record<string, unknown>>, context: PreviewContext) {
const withActions = cloneElement(element, {
onSelect: (id: string) => context.action('select', id),
});
return <ThemeProvider theme={(context.globals.theme as Theme) ?? 'light'}>{withActions}</ThemeProvider>;
}
src/components/Card.workbench.ts:
import { definePreview } from '@canonic2/workbench';
export default definePreview({
id: 'components/card',
title: 'Components/Card',
adapter: 'react',
source: { entry: './Card.tsx' },
environment: '../preview/environment.tsx',
styles: ['../styles/global.css'],
inputs: { title: 'Acme Pro', children: 'Everything your team needs.' },
globals: { theme: 'light', locale: 'en' },
controls: { title: { type: 'text' }, children: { type: 'text', label: 'Body' } },
states: {
light: {},
dark: { globals: { theme: 'dark' } },
},
});
@/themestands for your own theme module, resolved throughtsconfig.jsonpaths.cloneElementadds props to the component, here anonSelectcallback that logsselectwith its argument. Use the same pattern foronClick,onChange, or any other callback prop.globalscarry settings for the environment, such as the theme and locale, and each state can override them.- The environment can also export
setupandready, which run with the lifecycle hooks of the preview and its states. - Several definitions can share one environment module. For providers every preview needs, name it once as the project-wide environment instead.
- A data client such as Apollo, React Query, or SWR can stay real: wrap the
component in it as usual and answer its requests with
requests, so each state shows its own data. To seed the client’s cache instead, do it inwraporsetupfromcontext.fixtures.
Workbench doesn’t add React.StrictMode. Return it from wrap if you want
it.
Styles, fonts, and images
| What you write | What Workbench does |
|---|---|
import './Button.css' in a component |
Bundles the CSS and loads it with the preview |
import styles from './Card.module.css' |
Treats it as a CSS module, with locally scoped class names |
styles: ['../styles/global.css'] in the definition |
Loads global CSS, such as resets, tokens, and @font-face rules, that no component imports |
url(...) in CSS |
Copies the image or font and rewrites the URL |
import logo from './logo.svg' |
Copies the file; logo is its URL, for an img src |
import copy from './copy.json' |
Imports the parsed JSON |
Files you can import or reference from CSS are .svg, .png, .jpg,
.jpeg, .gif, .webp, .avif, .woff, .woff2, .ttf, .mp4, and
.mp3, plus CSS, JSON, and script files. A preview waits for its fonts and
visible images to load before it counts as ready.
The <html> element carries data-wb-state, so a global stylesheet can
change with the state, for example
:root[data-wb-state='dark'] body { background: #111827; }.
These need more than the built-in compiler:
- Sass, Less, and Stylus fail with
No loader is configured for ".scss" files. - PostCSS and Tailwind don’t run.
@tailwindand@applyare left unprocessed, so utility classes have no styles. Build the CSS with your own tooling and list the output file instyles. - SVG as a React component, such as SVGR’s
import { ReactComponent as Logo } from './logo.svg', fails withNo matching export in "logo.svg" for import "ReactComponent". An SVG import is a URL. - Other file types, such as
.otffonts or.webmvideo, fail withNo loader is configured.
For any of these, add a compiler plugin
in workbench.config.ts.
Imports, JSX, and environment variables
- JSX compiles with React’s automatic runtime, so components don’t need
import React. AjsxorjsxImportSourcesetting in the nearesttsconfig.jsontakes precedence. With"jsx": "react", each file that uses JSX needsimport React from 'react', or the preview fails withReact is not defined. pathsintsconfig.json, such as"@/*": ["./src/*"], resolve as they do in your editor.aliasesinworkbench.config.tsreplace an exact import specifier with a package or a project file. Use them to swap a module for a mock. An alias for@acme/analyticsdoesn’t apply to@acme/analytics/track; add that specifier as well, or usetsconfig.jsonpathsfor a prefix.process.env.NODE_ENVisdevelopmenton the canvas andproductionin exports. Any otherprocess.envorimport.meta.envvalue is undefined in the browser and fails withprocess is not definedorCannot read properties of undefined. Set it withdefine.
workbench.config.ts at the project root:
import { defineConfig } from '@canonic2/workbench';
export default defineConfig({
aliases: { '@acme/analytics': './src/preview/analytics-mock.ts' },
define: {
'process.env.API_URL': JSON.stringify('https://api.example.com'),
'import.meta.env': JSON.stringify({ VITE_API_URL: 'https://api.example.com' }),
},
});
Each define value is code, so wrap strings in JSON.stringify. Relative
alias paths start from the project root. Workbench doesn’t read Vite, webpack,
or Next.js configuration; repeat what your previews need here.
Use one copy of React in a monorepo
The adapter loads React from the definition’s folder, and each component
loads React from its own folder. When those resolve to two installations,
hooks fail with an error such as
Cannot read properties of null (reading 'useState'). List the packages in
dedupe so every import of them resolves from the definition’s folder:
import { defineConfig } from '@canonic2/workbench';
export default defineConfig({
dedupe: ['react', 'react-dom'],
});
Add any other package that must have a single copy, such as a context-based design system.
Document components with a docs page
A page’s docs show a component’s examples in Markdown, each
in its own panel with its code. Render the examples with React through a
docs lens whose adapter is react:
implementations:
web:
kind: docs
label: Web
adapter: react
styles:
- src/styles/global.css
collections:
- name: Components
items:
- label: Button
src: docs/button.md
implementations:
web: src/components/button-examples/
Each example is a React component:
- A folder, written with a trailing
/: each file directly inside it is one example, its default export.src/components/button-examples/secondary.tsxis the examplesecondary, and Show code shows the whole file. - A file: each named export is one example.
export function WithIcon()insrc/components/Button.examples.tsxis the examplewith-icon, and Show code shows that export’s statement.
docs/button.md places each example by its ID, in a fenced block whose info
string is example and the ID, such as example secondary; see
Write the Markdown.
src/components/button-examples/secondary.tsx:
import { Button } from '../Button';
export default function Secondary() {
return <Button label="Cancel" tone="secondary" />;
}
Examples receive no props and have no controls: write the props in the
example. The project’s environment for react, and the lens’s environment,
wrap each example with wrap, so the providers your previews use apply to the
examples too; see
Examples on docs pages. To render
the same Markdown with React Native Web, add a second lens with
adapter: react-native-web. To read the docs beside a component’s design or
preview, give that page docs: docs/button.md in place of a Markdown src.
A *.workbench.ts file can declare a Markdown page and its lenses with
defineDocs instead; see
Docs.
What isn’t supported
- The adapter renders a component in the browser. It doesn’t run your application’s server, server components, server actions, or framework data loaders. Supply data through inputs, providers in an environment, request mocks, or mocked modules.
- Inputs can’t hold functions or elements. Use a preview-only module or an environment.
- Saving a file reloads the preview; component state isn’t preserved.
Errors and fixes
| Error | Cause and fix |
|---|---|
Could not resolve "react", "react-dom/client", or "react/jsx-runtime" |
React isn’t installed where the definition and component can find it. Install react and react-dom in the project. |
React is not defined |
tsconfig.json sets "jsx": "react". Import React in the file, or use "jsx": "react-jsx". |
Cannot read properties of null (reading 'useState'), or React’s invalid hook call error |
Two copies of React. Add them to dedupe. |
The selected source export does not exist for <id> — <state> |
source.export doesn’t match an export of the entry. A named export needs export: 'Button'. |
DataCloneError: … could not be cloned |
An input holds a function or another value that can’t be copied. See Props, children, and callbacks. |
process is not defined, or Cannot read properties of undefined (reading 'VITE_…') |
An environment variable isn’t defined. Add it to define. |
No loader is configured for ".<ext>" files |
An unsupported file type, or a definition that imports a component. See Styles, fonts, and images and Write a first preview. |
Could not resolve "<package>/<path>" with an alias set |
Aliases match whole specifiers only. Alias that specifier too. |
For previews that don’t appear in the list, see A TypeScript preview is missing or broken.