Skip to content

Shared State Controller

A very common controller use case is subscribing a component to a shared store so that multiple, unrelated components stay automatically in sync. This lesson walks through building one from scratch.

The Store Subscription Pattern

A 'store' here just means any plain object exposing a way to get the current state, subscribe to changes, and (optionally) update the state — it doesn't require any particular state management library. A StoreController bridges that store's subscription mechanism to Lit's own reactive update system, so any component hosting it re-renders automatically whenever the store's state changes.

This is especially useful for state that genuinely needs to be shared across sibling components that don't have a direct parent/child relationship — a shopping cart count shown in both a header badge and a product page, for example.

class SimpleStore {
  #state;
  #listeners = new Set();

  constructor(initialState) { this.#state = initialState; }
  getState() { return this.#state; }
  setState(partial) {
    this.#state = { ...this.#state, ...partial };
    this.#listeners.forEach((fn) => fn(this.#state));
  }
  subscribe(fn) {
    this.#listeners.add(fn);
    return () => this.#listeners.delete(fn); // returns an unsubscribe function
  }
}

export const cartStore = new SimpleStore({ items: [] });

A minimal store: current state, a way to update it, and a subscribe method returning its own unsubscribe function — the shape most state libraries share.

The StoreController

class StoreController {
  #unsubscribe;
  constructor(host, store) {
    this.host = host;
    this.store = store;
    host.addController(this);
  }
  get state() { return this.store.getState(); }
  hostConnected() {
    this.#unsubscribe = this.store.subscribe(() => this.host.requestUpdate());
  }
  hostDisconnected() {
    this.#unsubscribe();
  }
}
  • hostConnected() subscribes to the store and stores the returned unsubscribe function.
  • hostDisconnected() calls that unsubscribe function, preventing a leaked subscription when the component is removed.
  • The state getter always reads the store's live current value — no separate cached copy that could go stale.
  • Every component hosting this same controller (pointed at the same store instance) re-renders automatically together whenever the store changes.

Shared State Controller Cheat Sheet

The essential pieces of a working store controller.

Piece Responsibility
The store Holds state, notifies listeners on change
hostConnected() Subscribes to the store, keeps the unsubscribe function
hostDisconnected() Calls the unsubscribe function
requestUpdate() in the listener Tells every hosting component to re-render on change
state getter Always reads the live current value from the store

Using the Same Controller from Two Unrelated Components

The real payoff of this pattern is visible once two components, with no parent/child relationship, both host a controller pointed at the same store instance — updating the store from either one keeps both automatically in sync.

class CartBadge extends LitElement {
  #cart = new StoreController(this, cartStore);
  render() { return html`<span>${this.#cart.state.items.length}</span>`; }
}

class AddToCartButton extends LitElement {
  #cart = new StoreController(this, cartStore);
  #add = () => cartStore.setState({ items: [...this.#cart.state.items, { id: crypto.randomUUID() }] });
  render() { return html`<button @click=${this.#add}>Add to Cart</button>`; }
}
// Clicking the button anywhere on the page updates CartBadge everywhere, automatically.

Using This Pattern with a Real State Library

The exact same controller shape works with Redux, Zustand, nanostores, or any store exposing a subscribe-style API — you're not limited to hand-rolled stores. Community packages like @lit-labs/observers and various @lit/context-based patterns build on this same fundamental idea.

Common Mistakes

  • Forgetting to unsubscribe in hostDisconnected(), leaking a subscription every time a component using this controller is removed and re-added.
  • Caching the store's state in a separate field instead of always reading it live through a getter, risking a stale value.
  • Creating a new store instance per component instead of sharing one module-level instance, defeating the entire purpose of shared state.
  • Not calling requestUpdate() inside the subscription callback, so the host never actually re-renders when the store changes.

Key Takeaways

  • A store controller bridges any subscribe-based store to Lit's reactive update system.
  • Subscribe in hostConnected(), unsubscribe in hostDisconnected() — the same pairing discipline as any other resource.
  • Multiple unrelated components can host the same controller pointed at one shared store instance, staying automatically in sync.
  • This pattern works with hand-rolled stores as well as real libraries like Redux or Zustand.

Pro Tip

Before reaching for a full state management library, try this pattern with a genuinely minimal hand-rolled store first — a large number of real Lit applications never need more than a subscribe/getState/setState object this small.