Skip to content

feat: v5 signals#6733

Open
johnjenkins wants to merge 6 commits into
v5from
v5-feat-signals
Open

feat: v5 signals#6733
johnjenkins wants to merge 6 commits into
v5from
v5-feat-signals

Conversation

@johnjenkins
Copy link
Copy Markdown
Contributor

@johnjenkins johnjenkins commented May 23, 2026

What is the new behavior?

Signals land in Stencil v5 as a fully opt-in, zero-migration feature. Existing components work exactly as before - nothing breaks, and new feature usage can be added incrementally.

Enabling

One flag in stencil.config.ts:

export const config: Config = {
  extras: {
    signalBacking: true,
  },
};

@Prop and @State become signal-backed, all existing decorators, lifecycle hooks, and @Watch callbacks continue to work and all attributes / text-content within your JSX (that's signal backed) will receive fine-grained updates; not full component re-renders.

All used features are fully tree-shaken with the total cost being no more than ~2kb Gzipped

Computed values and reactive side effects

Import from the new @stencil/core/signals subpath:

import { Component, State } from '@stencil/core';
import { computed, Effect } from '@stencil/core/signals';

@Component({ tag: 'my-stats' })
export class MyStats {
  @State() count = 0;

  doubled = computed(() => this.count * 2);

  @Effect()
  logChange() {
    console.log('count is now', this.count);
  }

  render() {
    return <div>{this.count} x 2 = {this.doubled}</div>;
  }
}

@Effect() auto-tracks dependencies, runs when they change, and cleans itself up on disconnect. computed() is lazy and cached - it only recalculates when its dependencies change.

Shared state across a component library

Create a signal outside any component:

// shared-signals.ts
import { signal } from '@stencil/core/signals';

export const cartCount = signal(0);

Use it in any number of components - they all update in-sync without events or context:

// cart-icon.tsx
import { cartCount } from '../shared-signals';

@Component({ tag: 'cart-icon' })
export class CartIcon {
  render() {
    return <span class="badge">{cartCount}</span>;
  }
}

Write to the signal anywhere and every subscriber updates immediately.

Subscribing to a component's props from outside

getSignal lets you observe any @Prop reactively from outside the component, typed and null-safe:

import { getSignal, computed } from '@stencil/core/signals';

const countSig = getSignal<number>(document.querySelector('my-counter'), 'count');
countSig?.subscribe(v => console.log('count changed to', v));

// Derive across multiple components without polling:
const cartEl = document.querySelector('cart-icon');
const wishlistEl = document.querySelector('wishlist-icon');

const totalItems = computed(() =>
  getSignal<number>(cartEl, 'count')!.value +
  getSignal<number>(wishlistEl, 'count')!.value
);

Vanilla usage / in environments where you cannot import from @stencil/core, the same signals are accessible via a well-known symbol:

const sigs = document.querySelector('cart-icon')[Symbol.for('stencil.signals')];
sigs?.get('count')?.subscribe(v => console.log(v));

vdom bypass

When a signal is used directly as a JSX child or attribute value, Stencil skips the vdom diff entirely and patches the DOM node in place:

import { signal } from '@stencil/core/signals';
const label = signal('hello');

// In JSX:
<span>{label}</span>           // updated directly
<div class={label}>...</div>  // patched directly

Documentation

Does this introduce a breaking change?

  • Yes
  • No

Testing

Other information

@johnjenkins johnjenkins requested a review from a team as a code owner May 23, 2026 01:00
@johnjenkins johnjenkins changed the title V5 feat signals feat: v5 signals May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant