A lightweight, beginner-friendly protocol for sharing reactive state between custom elements using signals and context.
Signal Context makes it easy to share reactive values (called "signals") between web components without messy "prop drilling." Think of it like React's Context API, but lighter and built on web standards.
Perfect for:
- Learning web components and reactive programming
- Building small to medium web apps without a framework
- Teaching clean state management patterns
- Rapid prototyping with custom elements
- Simple API - Just 3 concepts: Signal, signal-context, and requestSignal
- Reactive - Values update automatically across all subscribers
- Type-safe - Clear error messages when things go wrong
- Zero dependencies - Pure vanilla JavaScript
- Tiny - Under 5KB total
- Beginner-friendly - Extensive examples and great error messages
npm install signal-contextOr use directly in the browser:
<script type="module">
import { Signal } from 'https://unpkg.com/signal-context/src/signal.js';
import './node_modules/signal-context/src/signal-context.js';
</script><!DOCTYPE html>
<html>
<body>
<!-- Create a context with a signal named "username" -->
<signal-context data-username="Alice">
<user-greeting></user-greeting>
</signal-context>
<script type="module">
import { requestSignal } from './node_modules/signal-context/src/signal-context.js';
// Create a custom element that uses the signal
class UserGreeting extends HTMLElement {
connectedCallback() {
// Request the "username" signal from the nearest signal-context
requestSignal(this, 'username', (signal) => {
// Subscribe to changes
this._unsubscribe = signal.subscribe(name => {
this.textContent = `Hello, ${name}!`;
});
});
}
disconnectedCallback() {
// Clean up when element is removed
this._unsubscribe?.();
}
}
customElements.define('user-greeting', UserGreeting);
</script>
</body>
</html>That's it! The greeting will automatically update whenever the username signal changes.
A Signal is a reactive variable that notifies subscribers when its value changes.
import { Signal } from 'signal-context/src/signal.js';
// Create a signal
const count = new Signal(0);
// Subscribe to changes
count.subscribe(value => {
console.log('Count is now:', value);
}); // Immediately logs: "Count is now: 0"
// Update the value
count.value = 5; // Logs: "Count is now: 5"
count.value = 10; // Logs: "Count is now: 10"The <signal-context> custom element creates signals from its data-* attributes and shares them with descendant elements.
<signal-context data-theme="light" data-language="en">
<!-- All children can access "theme" and "language" signals -->
<my-app></my-app>
</signal-context>Use requestSignal() in your custom elements to access signals from ancestor contexts.
import { requestSignal } from 'signal-context/src/signal-context.js';
class MyComponent extends HTMLElement {
connectedCallback() {
requestSignal(this, 'theme', (signal) => {
this._unsubscribe = signal.subscribe(theme => {
this.className = theme; // Update class when theme changes
});
});
}
disconnectedCallback() {
this._unsubscribe?.();
}
}<!DOCTYPE html>
<html>
<body>
<signal-context data-count="0">
<counter-display></counter-display>
<counter-controls></counter-controls>
</signal-context>
<script type="module">
import { requestSignal } from './node_modules/signal-context/src/signal-context.js';
// Display component - just shows the count
class CounterDisplay extends HTMLElement {
connectedCallback() {
requestSignal(this, 'count', (signal) => {
this._unsubscribe = signal.subscribe(count => {
this.innerHTML = `<h1>Count: ${count}</h1>`;
});
});
}
disconnectedCallback() {
this._unsubscribe?.();
}
}
// Controls component - has buttons to change the count
class CounterControls extends HTMLElement {
connectedCallback() {
requestSignal(this, 'count', (signal) => {
this._signal = signal;
this.innerHTML = `
<button id="dec">-</button>
<button id="inc">+</button>
<button id="reset">Reset</button>
`;
this.querySelector('#inc').onclick = () => {
signal.value = Number(signal.value) + 1;
};
this.querySelector('#dec').onclick = () => {
signal.value = Number(signal.value) - 1;
};
this.querySelector('#reset').onclick = () => {
signal.value = 0;
};
});
}
}
customElements.define('counter-display', CounterDisplay);
customElements.define('counter-controls', CounterControls);
</script>
</body>
</html>Nested contexts allow you to override signals for specific parts of your page.
<!DOCTYPE html>
<html>
<body>
<!-- Root context with light theme -->
<signal-context data-theme="light">
<h2>Main App (Light Theme)</h2>
<themed-box></themed-box>
<!-- Nested context overrides theme to dark -->
<signal-context data-theme="dark">
<h2>Sidebar (Dark Theme)</h2>
<themed-box></themed-box>
</signal-context>
</signal-context>
<script type="module">
import { requestSignal } from './node_modules/signal-context/src/signal-context.js';
class ThemedBox extends HTMLElement {
connectedCallback() {
requestSignal(this, 'theme', (signal) => {
this._unsubscribe = signal.subscribe(theme => {
this.style.padding = '20px';
this.style.margin = '10px';
if (theme === 'dark') {
this.style.background = '#222';
this.style.color = '#fff';
} else {
this.style.background = '#fff';
this.style.color = '#222';
}
this.textContent = `Current theme: ${theme}`;
});
});
}
disconnectedCallback() {
this._unsubscribe?.();
}
}
customElements.define('themed-box', ThemedBox);
</script>
</body>
</html><!DOCTYPE html>
<html>
<body>
<signal-context data-firstname="John" data-lastname="Doe" data-email="john@example.com">
<user-form></user-form>
<user-preview></user-preview>
</signal-context>
<script type="module">
import { requestSignal } from './node_modules/signal-context/src/signal-context.js';
class UserForm extends HTMLElement {
connectedCallback() {
this.signals = {};
// Request multiple signals
['firstname', 'lastname', 'email'].forEach(field => {
requestSignal(this, field, (signal) => {
this.signals[field] = signal;
});
});
this.innerHTML = `
<div>
<label>First Name: <input id="firstname" /></label><br>
<label>Last Name: <input id="lastname" /></label><br>
<label>Email: <input id="email" /></label>
</div>
`;
// Set initial values and bind inputs
Object.keys(this.signals).forEach(field => {
const input = this.querySelector(`#${field}`);
input.value = this.signals[field].value;
input.oninput = (e) => {
this.signals[field].value = e.target.value;
};
});
}
}
class UserPreview extends HTMLElement {
connectedCallback() {
const signals = {};
const update = () => {
this.innerHTML = `
<h3>Preview</h3>
<p>Name: ${signals.firstname?.value} ${signals.lastname?.value}</p>
<p>Email: ${signals.email?.value}</p>
`;
};
['firstname', 'lastname', 'email'].forEach(field => {
requestSignal(this, field, (signal) => {
signals[field] = signal;
signal.subscribe(update);
});
});
}
}
customElements.define('user-form', UserForm);
customElements.define('user-preview', UserPreview);
</script>
</body>
</html><!DOCTYPE html>
<html>
<body>
<signal-context data-todos="[]">
<todo-app></todo-app>
</signal-context>
<script type="module">
import { requestSignal } from './node_modules/signal-context/src/signal-context.js';
class TodoApp extends HTMLElement {
connectedCallback() {
requestSignal(this, 'todos', (signal) => {
this._todosSignal = signal;
// Subscribe to changes
this._unsubscribe = signal.subscribe(todosJson => {
this.render(JSON.parse(todosJson || '[]'));
});
});
}
render(todos) {
this.innerHTML = `
<h2>Todo List</h2>
<input id="newTodo" placeholder="Add a todo..." />
<button id="addBtn">Add</button>
<ul id="todoList">
${todos.map((todo, i) => `
<li>
${todo}
<button data-index="${i}">Delete</button>
</li>
`).join('')}
</ul>
`;
// Add todo
this.querySelector('#addBtn').onclick = () => {
const input = this.querySelector('#newTodo');
if (input.value.trim()) {
const todos = JSON.parse(this._todosSignal.value || '[]');
todos.push(input.value.trim());
this._todosSignal.value = JSON.stringify(todos);
input.value = '';
}
};
// Delete todo
this.querySelectorAll('button[data-index]').forEach(btn => {
btn.onclick = () => {
const todos = JSON.parse(this._todosSignal.value || '[]');
todos.splice(btn.dataset.index, 1);
this._todosSignal.value = JSON.stringify(todos);
};
});
}
disconnectedCallback() {
this._unsubscribe?.();
}
}
customElements.define('todo-app', TodoApp);
</script>
</body>
</html>const signal = new Signal(initialValue);Creates a new signal with the given initial value.
signal.value- Gets or sets the current value. Setting a new value notifies all subscribers.
-
signal.subscribe(callback, autorun = true)- Subscribe to value changescallback(value)- Called with new value when it changesautorun- If true, calls callback immediately with current value- Returns: Unsubscribe function
-
signal.unsubscribe(callback)- Remove a subscriber -
signal.notify()- Manually notify all subscribers with current value -
signal.dispose()- Clean up all subscribers and disposables -
signal.collect(...disposables)- Track cleanup functions to call on dispose
<signal-context data-signalname="value">
<!-- children -->
</signal-context>Creates a signal for each data-* attribute. The signal name is the part after data-.
element.getSignal(name)- Get a signal by name (from this context or parent contexts)
requestSignal(node, signalName, callback);Requests a signal from the nearest ancestor signal-context.
Parameters:
node- The DOM node to dispatch from (usuallythisin custom element)signalName- The name of the signal to requestcallback(signal)- Called with the Signal when found
Throws: Error if signal is not found or parameters are invalid
class MyElement extends HTMLElement {
connectedCallback() {
requestSignal(this, 'data', (signal) => {
// Store unsubscribe function
this._unsubscribe = signal.subscribe(value => {
// handle value
});
});
}
disconnectedCallback() {
// Clean up!
this._unsubscribe?.();
}
}Signal values are always strings (from data attributes). Parse them as needed:
requestSignal(this, 'count', (signal) => {
signal.subscribe(value => {
const count = Number(value); // Convert to number
// use count
});
});Override signals for specific parts of your app:
<signal-context data-theme="light">
<main-content></main-content>
<!-- Modal with different theme -->
<signal-context data-theme="dark">
<modal-dialog></modal-dialog>
</signal-context>
</signal-context>try {
requestSignal(this, 'optional-signal', (signal) => {
// use signal
});
} catch (err) {
console.warn('Optional signal not available:', err);
// use default behavior
}Signal Context provides clear, helpful error messages:
"Signal.subscribe: subscriber must be a function"- You passed something other than a function to subscribe()"No signal 'foo' provided by any <signal-context> ancestor"- No context provides the signal you requested"data-foo was removed; dynamic removal unsupported"- Don't remove data-* attributes after the context is created"data-foo was added dynamically but initial signals are fixed"- Don't add new data-* attributes after creation
Works in all modern browsers that support:
- Custom Elements (v1)
- ES Modules
- Private class fields (#)
For older browsers, use a transpiler like Babel.
Q: Can I use this with React/Vue/other frameworks? A: Yes! Signal Context works with any framework, but you might not need it if your framework has its own state management.
Q: Can I store objects/arrays in signals?
A: Signals from <signal-context> data attributes are always strings. Parse them with JSON.parse() if needed. Direct Signal instances can hold any value.
Q: What happens if I mutate an object/array signal?
A: Mutations won't trigger subscribers (setter isn't called). Either reassign the signal or call signal.notify() manually.
Q: Can I have multiple signal-contexts? A: Yes! Nest them to override signals for different parts of your page.
Q: How do I pass complex data?
A: Use JSON for data attributes: data-user='{"name":"Alice","age":30}'
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new features
- Submit a pull request
MIT © catpea