Skip to main content

Shadow Views — React

This page covers the React-specific implementation. For shared concepts — @View, @InjectView, lifecycle control, and anchor selection — see Shadow Views.

Complete example​

A Shadow View has two files: the view class (.ts) and the React component (.tsx). They always live side by side.

The view class​

The view class extends HexaView, holds domain state, and exposes lifecycle helpers. The component field points to a React function component.

// clipboard-overlay.view.ts
import { HexaView, View } from '@hexajs-dev/core';
import { ClipboardOverlayComponent } from './clipboard-overlay.component';
import styles from './clipboard-overlay.scss?inline';

@View({
id: 'clip-vault-overlay',
component: ClipboardOverlayComponent,
styles,
anchorSelector: 'body',
})
export class ClipboardOverlayView extends HexaView {
toggle(): void {
if (this.isMounted) {
this.unmount();
return;
}
this.mount();
}

closeOverlay(): void {
this.unmount();
}
}

The React component​

The component is a standard React function component. HexaJS passes the view instance as the controller prop.

// clipboard-overlay.component.tsx
import React, { useEffect, useRef } from 'react';
import { inject } from '@hexajs-dev/common';
import { HexaContentStore, select } from '@hexajs-dev/core';
import { ClipItem } from '../../../contract/messages';
import { ContentState } from '../../store/content.reducer';
import { selectConfig, selectFilteredClips } from '../../store/content.selectors';
import { ClipboardOverlayView } from './clipboard-overlay.view';
import { ClipVaultConfig } from '@contract/config';

export function ClipboardOverlayComponent({ controller }: { controller: ClipboardOverlayView }): JSX.Element | null {
const store = inject(HexaContentStore<ContentState>);
const [filteredClips, setFilteredClips] = React.useState<ClipItem[]>([]);
const [config, setConfig] = React.useState<ClipVaultConfig | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const subscription = store.pipe(select(selectFilteredClips)).subscribe(clips => {
setFilteredClips(clips);
});
return () => subscription.unsubscribe();
}, [store]);

useEffect(() => {
const subscription = store.pipe(select(selectConfig)).subscribe(cfg => {
setConfig(cfg);
});
return () => subscription.unsubscribe();
}, [store]);

return (
<div className={`cv-overlay cv-overlay--${config?.theme || 'dark'}`}>
<div className="cv-overlay__backdrop" />
<div className="cv-overlay__panel">
<div className="cv-overlay__search-bar">
<input
ref={inputRef}
className="cv-overlay__search-input"
type="text"
placeholder="Search your clips..."
onChange={() => {}}
/>
<button
className="cv-overlay__close-btn"
onClick={() => controller.closeOverlay()}
aria-label="Close"
>
Close
</button>
</div>
<div className="cv-overlay__list" role="listbox">
{filteredClips.map((clip) => (
<div key={clip.id}>{clip.text}</div>
))}
</div>
</div>
</div>
);
}

Patterns​

Type the controller prop against the view class​

Always type controller against the concrete view class, not a partial interface. This gives you full autocomplete on domain methods and catches renamed methods at compile time.

// correct — typed against the class
export function MyOverlay({ controller }: { controller: MyOverlayView }) { ... }

// avoid — loses type safety on domain methods
export function MyOverlay({ controller }: { controller: { mount: () => void } }) { ... }

Store subscriptions belong in effects​

Content store subscriptions are RxJS-based. Subscribe in useEffect and return the unsubscribe teardown so React cleans up correctly on unmount.

useEffect(() => {
const sub = store.pipe(select(mySelector)).subscribe(value => setState(value));
return () => sub.unsubscribe();
}, [store]);

Call controller methods from event handlers​

The controller reference is stable for the lifetime of the mounted view. Call domain methods on it directly from event handlers — no useCallback wrapping needed.

<button onClick={() => controller.closeOverlay()}>Close</button>

How ReactShadowRenderer works​

When mount() is called on the view, HexaJS runs this sequence:

  1. resolve document.querySelector(anchorSelector || 'body'),
  2. create a host element named hexa-${id},
  3. attach an open Shadow Root,
  4. inject a <style> tag with your inline CSS,
  5. create a React root element inside the Shadow Root,
  6. set reactRootElement.style.all = 'initial',
  7. render <YourComponent controller={viewInstance} />,
  8. return a teardown that calls root.unmount() and removes the host element.

The style.all = 'initial' reset on the React root container prevents inherited page styles from affecting your component before your own CSS loads.

Suggested file structure​

src/
content/
ui/
clipboard-overlay/
clipboard-overlay.view.ts ← HexaView subclass + @View decorator
clipboard-overlay.component.tsx ← React function component
clipboard-overlay.scss ← scoped styles (imported ?inline)