Stencil's React Output Target: What the Generated Wrapper Actually Does
Stencil's React output target generates thin wrapper components so React can pass real props and listen to custom events. Here's what it does, where it fails, and how to verify it.
14 Mar 2026, 07:21 UTC

React treats your custom element as an unknown tag
Drop a Stencil-built web component into a React app and React renders it as an unknown HTML element. String attributes survive that trip. Objects, arrays and callbacks do not: for unknown elements React sets attributes rather than DOM properties, so a function passed as an attribute becomes the string "function () { ... }". Custom events need a ref and a manual addEventListener call, plus cleanup on unmount.
@stencil/react-output-target exists to remove that friction. It is a Stencil output target, meaning it runs at build time and writes files into your React project. The important thing to understand before adopting it: it does not rewrite your component in React. It generates a thin wrapper that renders the real custom element and wires props and events for you.
What actually lands in your React project
After a build you get, per component, a wrapper module and a type declaration. The wrapper typically does four things: renders the tag, assigns non-primitive props as DOM properties, subscribes to the custom events your component declares with @Event(), and exposes a typed ref so you can call @Method() functions. Your @State() and rendering logic stay inside the custom element — React never sees them.
Configuration, with a version caveat
Add the target to stencil.config.ts in the Stencil package. Option names have changed across major releases of the package (older versions used proxiesFile and componentCorePackage), so treat the snippet below as a shape rather than a copy-paste contract, and confirm the current names against the installed package's README and type definitions.
// stencil.config.ts — run from the Stencil package root
import { Config } from '@stencil/core';
import { reactOutputTarget } from '@stencil/react-output-target';
export const config: Config = {
namespace: 'rf-components',
outputTargets: [
// keep the custom-elements build so the tag can be registered
{ type: 'dist-custom-elements' },
reactOutputTarget({
outDir: './react/src/components',
}),
],
};You also need the custom element definitions loaded in the React app — usually a single defineCustomElements() call in your entry file. Without it the wrapper renders the tag but nothing upgrades, and you get an empty box with no error.
A worked example
A Stencil component with one prop, one event and one method:
import { Component, Prop, Event, EventEmitter, Method, h } from '@stencil/core';
@Component({ tag: 'rf-counter', shadow: true })
export class RfCounter {
@Prop() start = 0;
@Prop() step = 1;
@Event() valueChange!: EventEmitter<number>;
private value = 0;
componentWillLoad() {
this.value = this.start;
}
@Method()
async reset() {
this.value = this.start;
this.valueChange.emit(this.value);
}
private bump = () => {
this.value += this.step;
this.valueChange.emit(this.value);
};
render() {
return (
<button onClick={this.bump}>
<slot /> {this.value}
</button>
);
}
}From React, the generated wrapper is consumed like any other component. The event arrives as a DOM CustomEvent, so the payload is on event.detail:
import { useRef } from 'react';
import { RfCounter } from './components/rf-counter'; // generated wrapper
export function Demo() {
const counter = useRef<HTMLRfCounterElement>(null);
return (
<>
<RfCounter
start={10}
step={2}
onValueChange={(event) => console.log(event.detail)}
/>
<button onClick={() => counter.current?.reset()}>Reset</button>
</>
);
}Two names in that snippet are generator-dependent and worth checking in the file you actually built: the event prop casing (onValueChange versus a lowercased variant) and the ref element type name. Open the generated wrapper before you write app code against it.
Trade-offs and limitations
- The React tree does not own the state. Because the component's internals live in the custom element, React DevTools shows a wrapper, not your state. Transitions and Suspense cannot coordinate with updates that happen inside the element.
- Version alignment matters. The output target declares a peer range against
@stencil/core. A mismatch is a common cause of missing wrappers or runtime errors, so pin both packages together. - StrictMode double-invocation. React 18 and later mount effects twice in development. If the generated wrapper registers listeners in an effect, confirm it cleans up correctly; duplicate
valueChangelogs are the symptom to look for. - Shadow DOM styling. React CSS-in-JS and global stylesheets cannot reach inside a shadow root. Expose CSS custom properties and
::partnames deliberately, and document them as part of the component's public API. - React's own custom-element support is moving. Recent React majors have been adding native handling for properties and events on custom elements. If you are on a current React version, check the release notes for your version before assuming the wrapper is still necessary — the answer may differ from when this pattern was first adopted.
How to verify it in your project
- Run
npm ls @stencil/core @stencil/react-output-targetin the repo root. Confirm the installed output target lists your Stencil version inside its peer range. - Build with
npx stencil build, then open the configuredoutDir. You should see one wrapper file per component plus type declarations. - Read one wrapper end to end. Note the exact event prop names and the ref type; these are what your app code must import.
- In the browser console, run
customElements.get('rf-counter'). A constructor means the element is registered;undefinedmeans yourdefineCustomElements()call is missing or ran after render. - Render the component with a non-string prop and inspect the element in the Elements panel. The value should be set as a property, not serialized into an attribute.
If the wrapper approach does not fit — for example, if you need the component's internals inside React's render tree — the fallback is a hand-written wrapper: a ref, an effect that assigns properties and attaches listeners, and a cleanup function. That is more code, but it is entirely under your control. To back out of this change, remove the reactOutputTarget entry from stencil.config.ts, delete the generated directory, and drop the defineCustomElements() call if nothing else depends on it.
Start with one component. If the generated wrapper's types and event names match what your React code needs, the pattern scales; if you find yourself fighting the wrapper on the first component, stop there rather than converting the whole library.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.