signal-context
v1.0.2
Published
A lightweight, robust protocol for passing reactive values (signals) between custom elements in the DOM, with strong support for nesting, code safety, clear errors, and ideal for learning or rapid development.
Maintainers
Readme
Signal Context
A lightweight, beginner-friendly protocol for sharing reactive state between custom elements using signals and context.
What is Signal 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
Features
- 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
Installation
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>Quick Start
Basic Example
<!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.
Core Concepts
1. Signal
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"2. signal-context Element
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>3. requestSignal Function
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?.();
}
}Complete Examples
Example 1: Counter App
<!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>Example 2: Theme Switcher with Nested Contexts
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>Example 3: Form with Multiple Signals
<!DOCTYPE html>
<html>
<body>
<signal-context data-firstname="John" data-lastname="Doe" data-email="[email protected]">
<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>Example 4: Todo List
<!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>API Reference
Signal Class
Constructor
const signal = new Signal(initialValue);Creates a new signal with the given initial value.
Properties
signal.value- Gets or sets the current value. Setting a new value notifies all subscribers.
Methods
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 subscribersignal.notify()- Manually notify all subscribers with current valuesignal.dispose()- Clean up all subscribers and disposablessignal.collect(...disposables)- Track cleanup functions to call on dispose
signal-context Element
Usage
<signal-context data-signalname="value">
<!-- children -->
</signal-context>Creates a signal for each data-* attribute. The signal name is the part after data-.
Methods
element.getSignal(name)- Get a signal by name (from this context or parent contexts)
requestSignal Function
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
Best Practices
1. Always Clean Up Subscriptions
class MyElement extends HTMLElement {
connectedCallback() {
requestSignal(this, 'data', (signal) => {
// Store unsubscribe function
this._unsubscribe = signal.subscribe(value => {
// handle value
});
});
}
disconnectedCallback() {
// Clean up!
this._unsubscribe?.();
}
}2. Parse Non-String Values
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
});
});3. Use Nested Contexts for Scoping
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>4. Handle Errors Gracefully
try {
requestSignal(this, 'optional-signal', (signal) => {
// use signal
});
} catch (err) {
console.warn('Optional signal not available:', err);
// use default behavior
}Error Messages
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
Browser Support
Works in all modern browsers that support:
- Custom Elements (v1)
- ES Modules
- Private class fields (#)
For older browsers, use a transpiler like Babel.
FAQ
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}'
Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new features
- Submit a pull request
License
MIT © catpea
