Skip to content

Context Protocol

The context protocol lets you share data across deeply nested components without threading attributes through every intermediate element. It uses DOM events under the hood, which means it works across shadow DOM boundaries, so a provider at the top of the tree can reach consumers buried many levels deep.

import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';

When to Use Context

Context is the right tool when:

  • Theme / dark mode: a setting at the app root that dozens of components read.
  • Auth state: the current user object needed by nav bars, comment forms, profile widgets, etc.
  • Locale / i18n: language preference that affects every text-rendering component.
  • Feature flags: runtime configuration that controls conditional rendering deep in the tree.

Context is not the right tool when:

  • Data changes on every render. Use component state instead.
  • Only one component needs the data. Pass it as an attribute or property.
  • Data is page-level. Use an async page function to fetch it on the server.

Creating a Context

createContext(name) returns a unique context key. The name is for debugging, while uniqueness comes from the object identity.

// contexts/theme.ts (or .js)
import { createContext } from '@webjsdev/core/context';

export const themeContext = createContext('theme');
export type Theme = 'light' | 'dark' | 'system';

Define your context keys in shared modules so both providers and consumers import the same object.

Providing a Value

ContextProvider is a reactive controller that provides a value to all descendants. Attach it to any component:

import { WebComponent, html, css } from '@webjsdev/core';
import { ContextProvider } from '@webjsdev/core/context';
import { themeContext } from '#contexts/theme.ts';

class AppShell extends WebComponent {

  static styles = css`
    :host { display: block; }
  `;

  #theme = new ContextProvider(this, {
    context: themeContext,
    initialValue: 'light',
  });

  toggleTheme() {
    const next = this.#theme.value === 'light' ? 'dark' : 'light';
    this.#theme.setValue(next);  // updates all subscribers
  }

  render() {
    return html`
      <header>
        <button @click=${() => this.toggleTheme()}>
          Toggle theme (current: ${this.#theme.value})
        </button>
      </header>
      <main><slot></slot></main>
    `;
  }
}
AppShell.register('app-shell');

When you call provider.setValue(newValue), every subscribed consumer is notified and its host component re-renders automatically.

Consuming a Value

ContextConsumer is a reactive controller that reads the nearest provider's value. Attach it to any descendant component:

import { WebComponent, html, css } from '@webjsdev/core';
import { ContextConsumer } from '@webjsdev/core/context';
import { themeContext } from '#contexts/theme.ts';

class ThemedCard extends WebComponent {

  static styles = css`
    :host { display: block; padding: 16px; border-radius: 8px; }
    :host(.dark) { background: #1a1a2e; color: #eee; }
    :host(.light) { background: #fff; color: #222; border: 1px solid #ddd; }
  `;

  #theme = new ContextConsumer(this, {
    context: themeContext,
    subscribe: true,  // re-render when the provider's value changes
  });

  render() {
    // Apply the theme as a class on the host element
    this.className = this.#theme.value ?? 'light';

    return html`
      <h3><slot name="title">Card</slot></h3>
      <p><slot></slot></p>
    `;
  }
}
ThemedCard.register('themed-card');

Subscribe vs One-Shot Mode

The subscribe option controls whether the consumer receives ongoing updates:

subscribe: true (default for most use cases)

The consumer re-renders whenever the provider calls setValue(). This is what you want for live data like theme, auth state, or locale.

// Consumer re-renders every time the provider's value changes
#theme = new ContextConsumer(this, {
  context: themeContext,
  subscribe: true,
});

subscribe: false (one-shot)

The consumer reads the provider's value once during connectedCallback and never updates afterward. This is useful for configuration that is set once and never changes, like a base URL or API key:

// Consumer reads the value once and ignores future changes
#apiBase = new ContextConsumer(this, {
  context: apiBaseContext,
  subscribe: false,
});

With subscribe: false, calling provider.setValue() does not notify this consumer.

How It Works Under the Hood

The context protocol uses standard DOM events:

  1. When a consumer connects, it dispatches a ContextRequestEvent that bubbles up through the DOM (including across shadow DOM boundaries, since it is composed).
  2. The nearest ancestor with a matching ContextProvider intercepts the event.
  3. If subscribe: true, the provider stores a callback reference and calls it whenever setValue() is invoked.
  4. If subscribe: false, the provider responds with the current value and the event stops.

This means context works with any DOM tree structure (including components from different libraries) as long as they follow the same context protocol. There is no framework-specific wiring.

Multiple Contexts

A single component can provide or consume multiple contexts:

import { createContext, ContextProvider } from '@webjsdev/core/context';

const themeContext = createContext('theme');
const localeContext = createContext('locale');
const authContext = createContext('auth');

class AppRoot extends WebComponent {

  #theme = new ContextProvider(this, {
    context: themeContext,
    initialValue: 'light',
  });

  #locale = new ContextProvider(this, {
    context: localeContext,
    initialValue: 'en',
  });

  #auth = new ContextProvider(this, {
    context: authContext,
    initialValue: null,
  });

  login(user) {
    this.#auth.setValue(user);
  }

  render() {
    return html`<slot></slot>`;
  }
}
AppRoot.register('app-root');

Nested Providers

Providers can be nested. A consumer resolves to the nearest ancestor provider with a matching context key:

<app-root>                  <!-- provides theme: 'light' -->
  <themed-card>             <!-- consumes theme: 'light' -->
  </themed-card>

  <dark-section>             <!-- provides theme: 'dark' (overrides) -->
    <themed-card>           <!-- consumes theme: 'dark' -->
    </themed-card>
  </dark-section>
</app-root>

This mirrors how CSS custom properties cascade. Inner values shadow outer ones.

Full Example: Auth Context

A complete provider + consumer pair for authentication state:

// contexts/auth.ts
import { createContext } from '@webjsdev/core/context';
export const authContext = createContext('auth');

// components/auth-provider.ts
import { WebComponent, html } from '@webjsdev/core';
import { ContextProvider } from '@webjsdev/core/context';
import { authContext } from '#contexts/auth.ts';

class AuthProvider extends WebComponent {

  #auth = new ContextProvider(this, {
    context: authContext,
    initialValue: { user: null, loading: true },
  });

  async connectedCallback() {
    super.connectedCallback();
    try {
      const res = await fetch('/api/me');
      const user = res.ok ? await res.json() : null;
      this.#auth.setValue({ user, loading: false });
    } catch {
      this.#auth.setValue({ user: null, loading: false });
    }
  }

  render() {
    return html`<slot></slot>`;
  }
}
AuthProvider.register('auth-provider');

// components/user-menu.ts
import { WebComponent, html } from '@webjsdev/core';
import { ContextConsumer } from '@webjsdev/core/context';
import { authContext } from '#contexts/auth.ts';

class UserMenu extends WebComponent {

  #auth = new ContextConsumer(this, {
    context: authContext,
    subscribe: true,
  });

  render() {
    const { user, loading } = this.#auth.value ?? {};
    if (loading) return html`<span>...</span>`;
    if (!user) return html`<a href="/login">Sign in</a>`;
    return html`<span>Hi, ${user.name}</span>`;
  }
}
UserMenu.register('user-menu');

Next Steps