| name | stenciljs-component-development |
| description | Use when creating or modifying Stencil.js web components. Ensures components follow Stencil best practices, proper decorator usage, lifecycle methods, and TypeScript conventions. |
| allowed-tools | ["Read","Write","Edit","Bash","Grep","Glob"] |
Stencil.js - Component Development
Build scalable, enterprise-ready web components using Stencil.js with TypeScript and Web Component standards. Stencil components are framework-agnostic and can be distributed to React, Angular, Vue, and traditional web applications.
Key Concepts
Component Structure
A Stencil component is a TypeScript class decorated with @Component():
import { Component, Prop, State, Event, EventEmitter, Watch, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
@Prop() name: string;
@State() isActive: boolean = false;
render() {
return (
<div>
<p>Hello, {this.name}!</p>
</div>
);
}
}
Component Decorator Options
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
styleUrls: {
ios: 'my-component.ios.css',
md: 'my-component.md.css'
},
shadow: true,
scoped: false,
assetsDirs: ['assets'],
formAssociated: false,
})
Decorators
@Prop() - Component Properties
Public properties exposed as attributes:
@Prop() name: string;
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
@Prop({ mutable: true }) value: string;
@Prop({ reflect: true }) active: boolean;
@Prop({ attribute: 'data-id' }) dataId: string;
Best Practices:
- Always type your props
- Use JSDoc comments for public API documentation
- Set sensible defaults when appropriate
- Use
reflect: true sparingly (performance cost)
@State() - Internal State
Private state that triggers re-renders when changed:
@State() isOpen: boolean = false;
@State() items: string[] = [];
this.isOpen = true;
Important:
- State is internal only, not exposed as attributes
- Mutating arrays/objects directly won't trigger re-render
- Use immutable patterns for complex state
@Watch() - Property Change Handlers
Watch for prop or state changes:
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
console.log(`Value changed from ${oldValue} to ${newValue}`);
this.validateValue(newValue);
}
@Prop() swipeEnabled: boolean = true;
@Watch('swipeEnabled')
swipeEnabledChanged(newSwipeEnabled: boolean, oldSwipeEnabled: boolean) {
this.updateState();
}
@Event() - Custom Events
Emit custom events:
@Event() itemSelected: EventEmitter<string>;
@Event() formSubmit: EventEmitter<{value: string}>;
handleClick() {
this.itemSelected.emit('item-1');
}
Best Practices:
- Use descriptive event names
- Type the event payload
- Document event details with JSDoc
@Listen() - Event Listeners
Listen to DOM events:
@Listen('click')
handleClick(event: MouseEvent) {
console.log('Component clicked', event);
}
@Listen('scroll', { target: 'window' })
handleScroll(event: Event) {
console.log('Window scrolled');
}
@Listen('resize', { target: 'window', passive: true })
handleResize() {
this.updateDimensions();
}
@Element() - Host Element Reference
Reference to the host element:
@Element() el: HTMLElement;
componentDidLoad() {
console.log('Host element:', this.el);
this.el.classList.add('loaded');
}
@Method() - Public Methods
Expose public async methods:
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async getValue(): Promise<string> {
return this.value;
}
Important:
- All public methods must be async
- Document with JSDoc for public API
Lifecycle Methods
Lifecycle methods in order of execution:
export class MyComponent {
connectedCallback() {
console.log('Component connected to DOM');
}
componentWillLoad() {
console.log('Component will load');
}
componentDidLoad() {
console.log('Component loaded');
}
componentWillRender() {
console.log('Component will render');
}
componentDidRender() {
console.log('Component rendered');
}
componentWillUpdate() {
console.log('Component will update');
}
componentDidUpdate() {
console.log('Component updated');
}
componentShouldUpdate(newVal: any, oldVal: any, propName: string): boolean {
return newVal !== oldVal;
}
disconnectedCallback() {
console.log('Component disconnected');
}
}
Lifecycle Best Practices:
- Use
componentWillLoad() for async data fetching
- Use
componentDidLoad() for DOM manipulation
- Use
connectedCallback() for logic that runs every time element is attached
- Clean up in
disconnectedCallback() (remove listeners, clear timers)
JSX and Rendering
render() Method
The render() method returns JSX:
import { h } from '@stencil/core';
render() {
return (
<div class="container">
<h1>Hello, {this.name}!</h1>
{this.isActive && <p>Active!</p>}
<button onClick={() => this.handleClick()}>
Click me
</button>
</div>
);
}
Host Element
Use <Host> to set attributes on the host element:
import { Host } from '@stencil/core';
render() {
return (
<Host
class={{
'is-active': this.isActive,
'is-disabled': this.disabled
}}
aria-label={this.label}
>
<slot></slot>
</Host>
);
}
Slots
Use slots for content projection:
render() {
return (
<div>
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> {/* Default slot */}
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
);
}
Conditional Rendering
render() {
return (
<div>
{this.loading ? (
<div class="spinner">Loading...</div>
) : (
<div class="content">{this.content}</div>
)}
{this.error && <div class="error">{this.error}</div>}
</div>
);
}
Lists
render() {
return (
<ul>
{this.items.map(item => (
<li key={item.id}>
{item.name}
</li>
))}
</ul>
);
}
File Structure & Naming
Directory Structure
One component per directory:
src/
├── components/
│ ├── my-button/
│ │ ├── my-button.tsx # Component implementation
│ │ ├── my-button.css # Styles
│ │ ├── my-button.spec.ts # Unit tests
│ │ ├── my-button.e2e.ts # E2E tests
│ │ └── readme.md # Component documentation
│ ├── my-card/
│ │ ├── my-card.tsx
│ │ ├── my-card.ios.css # iOS-specific styles
│ │ ├── my-card.md.css # Material Design styles
│ │ └── my-card.css # Base styles
Naming Conventions
- Tag names: kebab-case with hyphen (required):
my-component, app-header
- Component class: PascalCase:
MyComponent, AppHeader
- File names: Match tag name:
my-component.tsx
- Props: camelCase:
firstName, isActive
- Events: camelCase:
itemSelected, formSubmit
- CSS classes: kebab-case:
button-primary, is-active
Code Organization
Follow this order in component class:
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
private internalValue: number;
someText = 'default';
@Element() el: HTMLElement;
@State() isValidated: boolean;
@State() status = 0;
@Prop() content: string;
@Prop() enabled: boolean;
@Prop() type = 'default';
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
this.validate(newValue);
}
@Event() itemSelected: EventEmitter<string>;
@Event() statusChanged: EventEmitter<number>;
connectedCallback() {}
disconnectedCallback() {}
componentWillLoad() {}
componentDidLoad() {}
componentShouldUpdate() {}
componentWillRender() {}
componentDidRender() {}
componentWillUpdate() {}
componentDidUpdate() {}
@Listen('click')
onClick(event: MouseEvent) {
console.log('Clicked');
}
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async close(): Promise<void> {
this.isOpen = false;
}
private validate(value: string): boolean {
return value.length > 0;
}
private updateState() {
}
render() {
return (
<Host>
<div class="container">
<slot></slot>
</div>
</Host>
);
}
}
Best Practices
1. Use TypeScript Strictly
@Prop() items: Array<{id: string; name: string}>;
@State() count: number = 0;
@Prop() items: any;
@State() count;
2. Immutable State Updates
this.items.push(newItem);
this.items = [...this.items, newItem];
this.user.name = 'New Name';
this.user = { ...this.user, name: 'New Name' };
3. Use Shadow DOM When Possible
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
4. Document Public API
@Component({
tag: 'my-button',
})
export class MyButton {
@Prop() label: string;
@Event() buttonClick: EventEmitter<void>;
@Method()
async openMenu(): Promise<void> {
}
}
5. Handle Async Operations Properly
async componentWillLoad() {
try {
this.data = await this.fetchData();
} catch (error) {
console.error('Failed to load data:', error);
this.error = 'Failed to load';
}
}
6. Clean Up Resources
private intervalId: number;
componentDidLoad() {
this.intervalId = window.setInterval(() => {
this.updateTime();
}, 1000);
}
disconnectedCallback() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
7. Use Functional Components for Simple Cases
import { h, FunctionalComponent } from '@stencil/core';
interface IconProps {
name: string;
size?: number;
}
export const Icon: FunctionalComponent<IconProps> = ({ name, size = 24 }) => (
<svg width={size} height={size}>
<use xlinkHref={`#icon-${name}`} />
</svg>
);
Anti-Patterns
❌ Don't Mutate Props
@Prop() value: string;
handleChange() {
this.value = 'new value';
}
@Prop({ mutable: true }) value: string;
@Event() valueChange: EventEmitter<string>;
handleChange() {
this.valueChange.emit('new value');
}
❌ Don't Use Constructor for Initialization
constructor() {
this.data = this.fetchData();
}
async componentWillLoad() {
this.data = await this.fetchData();
}
❌ Don't Access DOM in render()
render() {
const width = this.el.offsetWidth;
return <div style={{ width: `${width}px` }}></div>;
}
componentDidLoad() {
this.width = this.el.offsetWidth;
}
render() {
return <div style={{ width: `${this.width}px` }}></div>;
}
❌ Don't Forget Keys in Lists
render() {
return (
<ul>
{this.items.map(item => <li>{item.name}</li>)}
</ul>
);
}
render() {
return (
<ul>
{this.items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
❌ Don't Use Arrow Functions in render() for Handlers
render() {
return <button onClick={() => this.handleClick()}>Click</button>;
}
private handleClick = () => {
console.log('Clicked');
}
render() {
return <button onClick={this.handleClick}>Click</button>;
}
Testing
Unit Tests
import { newSpecPage } from '@stencil/core/testing';
import { MyComponent } from './my-component';
describe('my-component', () => {
it('renders', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component>
<mock:shadow-root>
<div>
Hello, World!
</div>
</mock:shadow-root>
</my-component>
`);
});
it('renders with props', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component name="Stencil"></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component name="Stencil">
<mock:shadow-root>
<div>
Hello, Stencil!
</div>
</mock:shadow-root>
</my-component>
`);
});
});
E2E Tests
import { newE2EPage } from '@stencil/core/testing';
describe('my-component', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const element = await page.find('my-component');
expect(element).toHaveClass('hydrated');
});
it('emits event on click', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const itemSelected = await page.spyOnEvent('itemSelected');
const button = await page.find('my-component >>> button');
await button.click();
expect(itemSelected).toHaveReceivedEvent();
});
});
Common Patterns
Form-Associated Components
@Component({
tag: 'my-input',
formAssociated: true,
})
export class MyInput {
@Element() el: HTMLElement;
private internals: ElementInternals;
componentWillLoad() {
this.internals = (this.el as any).attachInternals();
}
@Prop() value: string = '';
@Watch('value')
valueChanged(newValue: string) {
this.internals.setFormValue(newValue);
}
}
Loading States
@State() loading: boolean = false;
@State() error: string | null = null;
@State() data: any = null;
async componentWillLoad() {
await this.loadData();
}
private async loadData() {
this.loading = true;
this.error = null;
try {
this.data = await fetch('/api/data').then(r => r.json());
} catch (e) {
this.error = 'Failed to load data';
} finally {
this.loading = false;
}
}
render() {
if (this.loading) return <div>Loading...</div>;
if (this.error) return <div class="error">{this.error}</div>;
return <div>{JSON.stringify(this.data)}</div>;
}
Controlled vs Uncontrolled
@Prop() value: string;
@Event() valueChange: EventEmitter<string>;
handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.valueChange.emit(value);
}
render() {
return <input value={this.value} onInput={e => this.handleInput(e)} />;
}
@State() internalValue: string = '';
handleInput(event: Event) {
this.internalValue = (event.target as HTMLInputElement).value;
}
render() {
return <input value={this.internalValue} onInput={e => this.handleInput(e)} />;
}
Performance Tips
- Use
componentShouldUpdate() to prevent unnecessary renders
- Avoid complex computations in render()
- Use
memoize for expensive calculations
- Lazy load components with
import()
- Use Shadow DOM for style encapsulation
- Minimize prop changes from parent
- Use event delegation for lists
Related Resources