npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

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.

Readme

Signal Context

A lightweight, beginner-friendly protocol for sharing reactive state between custom elements using signals and context.

npm version License: MIT

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-context

Or 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 changes

    • callback(value) - Called with new value when it changes
    • autorun - 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 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 (usually this in custom element)
  • signalName - The name of the signal to request
  • callback(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:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new features
  4. Submit a pull request

License

MIT © catpea

Links