StencilJS Two-Way Binding with @Prop and @Event
A practical architecture note for synchronizing child input values to parent state in StencilJS using @Prop and @Event, including trust boundaries and failure checks.
11 Jul 2026, 21:11 UTC

The Problem: Child Components Cannot Mutate Parent State
StencilJS enforces a "props down, events up" data flow. A child component receives data through a @Prop, but that property is not owned by the child. If a custom input tries to assign this.value = newValue directly, the change stays inside the child and the parent never sees it. The useful takeaway is to treat the child as a read-only display layer: it emits an @Event, and the parent updates the source of truth, which then flows back down as a new prop value.
Requirements
This design assumes a parent component owns a value and a child component edits it. The child must display the current value, report edits to the parent, and avoid holding a private copy that can drift out of sync. Stencil version 2.x or later is assumed; earlier releases required manual event wiring outside the reactive property system.
Smallest Suitable Design
The minimum viable structure has three parts: a @Prop on the child for the displayed value, a @Event for change notifications, and a parent handler that accepts or rejects the emitted value before updating its own @State.
Child Component
import { Component, Prop, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'stencil-text-input',
shadow: true,
})
export class StencilTextInput {
@Prop() value: string = '';
@Event() valueChange: EventEmitter<{ value: string }>;
handleInput(event: Event) {
const target = event.target as HTMLInputElement;
// Notify the parent instead of mutating this.value
this.valueChange.emit({ value: target.value });
}
render() {
return (
this.handleInput(event)}
/>
);
}
}
Parent Component
import { Component, State, h } from '@stencil/core';
@Component({
tag: 'stencil-parent',
shadow: true,
})
export class StencilParent {
@State() username: string = '';
handleValueChange(event: CustomEvent<{ value: string }>) {
// Trust boundary: the parent validates before adopting the value
const next = String(event.detail.value ?? '').trim();
if (next.length > 0 && next.length <= 100) {
this.username = next;
}
}
render() {
return (
this.handleValueChange(event)}
/>
);
}
}
Stencil converts @Event() valueChange into the onValueChange attribute in JSX. The event detail carries the new value, while this.value inside the child remains a read-only snapshot passed from the parent.
Trust and Data Boundaries
The trust boundary is the component's public API: props and events. Internal implementation details stay inside the component class or shadow DOM.
- Child boundary: The child treats
@Propas an immutable snapshot. It must not assign tothis.valueor keep a separate local copy of the same data. - Parent boundary: The parent is the sole authority. It receives the event detail, validates it, and decides whether to update
@State. The child never forces a state change.
Operational Checks
Run these checks after building the component. Build with npx stencil build from the project root as a standard user; a failed build usually indicates a type mismatch between @Event detail and the parent handler.
| Check | Method | Expected Result |
|---|---|---|
| Prop reactivity | Update @State from a parent button and observe the child input. |
The child input shows the new value without a page reload. |
| Event propagation | Type inside the child input. | The parent state updates on each input event and the value flows back down. |
| Listener cleanup | Mount and unmount the parent repeatedly in browser DevTools. | No growth in detached DOM nodes or orphaned event listeners. |
| Rapid updates | Type quickly into the child input. | The parent keeps pace without dropping characters or lagging behind. |
To verify listener cleanup, open the DevTools Elements panel, detach the component, and confirm the custom element and its listeners disappear from memory after unmount.
Failure Modes
Object and Array Reference Mutation
Stencil detects prop changes by reference identity. If the parent passes an object or array and mutates a property inside it without creating a new reference, the child does not re-render and the UI shows stale data.
Fix: Use immutable updates in the parent, for example this.data = { ...this.data, field: newValue }, so Stencil sees a new reference.
Async Timing and Stale Props
If the parent updates state asynchronously, the child may emit an event based on an older prop value before the parent's update flows back down. Always derive the next value from event.detail in the parent handler rather than reading this.value inside the child.
Event Storm in Large Lists
Emitting an @Event on every keystroke across many components can cause excessive render cycles. For lists or sliders, debounce the emission in the child handler or update the parent state on onBlur instead of onInput.
Conditions That Change the Design
This prop-and-event loop works well for direct parent-child pairs. Move to a different architecture when:
- Prop drilling: Data crosses three or more component layers. A store or observable service avoids passing props through intermediate components that do not use them.
- Shared global state: Multiple unrelated components must read and write the same value. Centralized state is more reliable than chaining events between siblings.
- High-frequency synchronization: A slider driving a canvas at 60 frames per second. Bypassing the Stencil render cycle with a shared mutable reference or direct DOM update may be necessary.
Practical Verification
After implementing the component, run npm start or npx stencil build --watch from the project root. Open the rendered page in a browser, type into the child input, and confirm the parent displays the updated value. Then change the value from the parent and confirm the child input reflects it. If either direction fails, inspect the browser console for Stencil warnings about mismatched event types or missing prop bindings.
Limitation: This pattern does not scale to deeply nested or globally shared state by itself. It also does not protect against malicious consumer input; treat all @Event detail as untrusted and validate it in the parent before use.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.