react-webview-architecture
Architecture patterns for React-based webviews in the vscode-documentdb extension. Use when creating new webview components, modifying existing views…
npx skills add https://github.com/microsoft/vscode-documentdb --skill react-webview-architectureReact Webview Architecture
Patterns and conventions for React webviews in vscode-documentdb.
Related skills (do not duplicate):
- webview-trpc-messaging — tRPC routers, procedures, telemetry, AbortSignal, subscriptions, WebviewController
- accessibility-aria-expert — ARIA labels, Announcer, focus management, screen reader patterns
Full reference: See references/REACT_ARCHITECTURE_GUIDELINES.md
When to Use
- Creating or modifying a webview (DocumentView, CollectionView)
- Adding new components inside
src/webviews/ - Working with CollectionView context or state management
- Integrating Monaco Editor or SlickGrid
- Debugging stale closure issues in event handlers
Rendering Pipeline
Every webview boots through src/webviews/index.tsx:
root.render(
<DynamicThemeProvider useAdaptive={true}>
<WithWebviewContext vscodeApi={vscodeApi}>
<Component />
</WithWebviewContext>
</DynamicThemeProvider>,
);
DynamicThemeProvider— adapts Fluent UI theming to VS Code's active color themeWithWebviewContext— providesvscodeApi(postMessage) via React ContextWebviewRegistry— maps webview names → React components (in_integration/WebviewRegistry)
Configuration from the extension host is read via useConfiguration<T>().
File Organization
viewName/
├── ViewName.tsx # Main component
├── viewName.scss # Styles
├── viewNameContext.ts # Context + state types (if complex)
├── viewNameController.ts # WebviewController subclass (extension-side)
├── viewNameRouter.ts # tRPC router (extension-side, see webview-trpc-messaging skill)
├── constants.ts
├── components/ # Sub-components
├── hooks/ # Custom React hooks
├── types/ # TypeScript types
└── utils/ # Helpers
Component Hierarchy
DocumentView (simpler, good reference pattern):
DocumentView
├── ProgressBar (conditional: isLoading)
├── ToolbarDocuments
└── MonacoEditor
CollectionView (complex, multi-tab):
CollectionView
├── ProgressBar (conditional)
├── ToolbarMainView
├── QueryEditor
│ └── MonacoAutoHeight (multiple: filter, project, sort)
├── TabList (Results | Query Insights [PREVIEW])
├── Results Tab:
│ ├── ToolbarViewNavigation + ToolbarDocumentManipulation + ViewSwitcher
│ ├── DataViewPanelTable / DataViewPanelTree / DataViewPanelJSON
│ └── ToolbarTableNavigation (Table View only)
└── Query Insights Tab:
└── QueryInsightsMain (3-stage progressive loading)
State Management
Simple views (DocumentView): local useState + props
Complex views (CollectionView): React Context with [state, setState] tuple
export const CollectionViewContext = createContext<
[CollectionViewContextType, React.Dispatch<React.SetStateAction<CollectionViewContextType>>]
>([DefaultCollectionViewContext, () => {}]);
// Provider in parent
const [currentContext, setCurrentContext] = useState(DefaultCollectionViewContext);
<CollectionViewContext.Provider value={[currentContext, setCurrentContext]}>
// Consumer in child
const [currentContext, setCurrentContext] = useContext(CollectionViewContext);
Always use functional updates when state depends on previous value:
setCurrentContext((prev) => ({
...prev,
isLoading: true,
activeQuery: { ...prev.activeQuery, pageNumber: 1 },
}));
Stale Closure Pattern (CRITICAL)
Third-party components (SlickGrid) bind event handlers at initialization — they don't update when state changes. Always use refs to access current data in those handlers:
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
const onCellDblClick = useCallback((event) => {
const item = dataRef.current[event.detail.args.row]; // ✅ always current
// NOT: data[event.detail.args.row]; ❌ stale closure
}, []); // stable deps only
Why: SlickGrid binds handlers once at init time. Without refs, handlers see the data from initialization, not the latest state. This caused multiple hard-to-debug issues.
Monaco Editor
Required patterns:
- Manual layout — Monaco doesn't auto-resize:
useEffect(() => {
const handler = debounce(() => editorRef.current?.layout(), 200);
window.addEventListener('resize', handler);
handleResize(); // initial layout
return () => window.removeEventListener('resize', handler);
}, []);
- Dispose on unmount:
return () => {
editorRef.current?.dispose();
};
- MonacoAutoHeight — self-sizing editor for query fields:
<MonacoAutoHeight
adaptiveHeight={{ enabled: true, maxLines: 10, minLines: 1, lineHeight: 19 }}
onExecuteRequest={() => onExecuteRequest()}
onMount={(editor, monaco) => handleEditorDidMount(editor, monaco)}
/>
- JSON Schema delay — Monaco's JSON worker may not be ready immediately after mount. An AbortController-guarded delay is used (see QueryEditor for the pattern).
Fluent UI Integration
Use @fluentui/react-components (v9), themed via DynamicThemeProvider:
| Component | Usage |
|---|---|
ProgressBar | Loading states |
Button, ToggleButton | Toolbar actions |
Tab, TabList | View switching |
Dropdown, Option | Selection (ViewSwitcher) |
Badge | Status/preview indicators |
MessageBar | Info/warning messages |
Skeleton, SkeletonItem | Loading placeholders |
Animations: Collapse from @fluentui/react-motion-components-preview
Styling
- Each component gets its own
.scssfile, imported directly - Shared styles in
sharedStyles.scss, applied via@extend - Consistent spacing unit:
10pxwith flexboxrow-gap/column-gap - No inline styles — move to SCSS files
- Avoid negative margins — fix layout with proper flexbox
.documentView {
display: flex;
flex-direction: column;
height: 100vh;
row-gap: 10px;
}
Custom Hooks
| Hook | Purpose |
|---|---|
useSelectiveContextMenuPrevention() | Prevents browser context menu everywhere except Monaco editors. Call once in top-level view. |
useHideScrollbarsDuringResize() | Returns a function that temporarily hides scrollbars during layout transitions (500ms). |
Conditional Rendering Patterns
Object-based switch:
{{
'Table View': <DataViewPanelTable {...props} />,
'Tree View': <DataViewPanelTree {...props} />,
'JSON View': <DataViewPanelJSON {...props} />,
}[currentContext.currentView]}
Loading State
const [isLoading, setIsLoading] = useState(false);
setIsLoading(true);
try {
await op();
} finally {
setIsLoading(false);
}
// In render:
{
isLoading && <ProgressBar thickness="large" shape="square" className="progressBar" />;
}
Common Pitfalls
- Forgetting refs with third-party components → stale data in event handlers
- Not cleaning up event listeners, Monaco instances, AbortControllers in
useEffectreturn - Not calling
editor.layout()after resize → blank Monaco panels - Using
any→ use proper types orunknownwith type guards - Missing
l10n.t()on user-facing strings - Inline styles instead of SCSS files
- Negative margins to fix spacing — restructure layout with flexbox gap instead