January 2026

Scoped state in a singleton world

The initial version of agentic search only supported one page instance at a time. It needed shared state, and LWC doesn’t offer a native solution for that, so we used Redux. A singleton store was enough for that first version.

Later, product requirements evolved to support multiple search pages open as separate tabs in the CRM app. Each tab had to keep its own state, but every page instance still pointed to the singleton. A change in one tab showed up in the others.

I set out to scope the store to a page instance while keeping the Redux layer we already had. The reducers, actions, and selectors could stay the same. Each page would own a store, and child components would resolve that store from their position in the DOM.

The original store setup

Consumers imported the module-level singleton directly. A search bar, for example, subscribed during its component lifecycle and dispatched query updates to that store:

TypeScript</>
import { LightningElement } from 'lwc';import { store, queryChanged } from 'results/state'; class SearchBar extends LightningElement {  private unsubscribe: () => void;   connectedCallback() {    this.unsubscribe = store.subscribe(() => {      const state = store.getState();      // ...    });  }   disconnectedCallback() {    this.unsubscribe();  }   updateQuery(event: Event) {    store.dispatch(queryChanged(event.target.value));  }}

With multiple pages mounted, every import still resolved to the same store instance. A dispatch from one tab notified subscribers on all other tabs.

Creating one store per page solved ownership. The remaining problem was discovery: each consumer needed to find the store for the page it belonged to.

Scoping the store to a page

React’s context API already solves this kind of problem, so I used it as a model. A Context provider owns a value for a subtree, and useContext resolves the closest provider above. One provider per tab would give each page its own store.

LWC doesn’t offer an equivalent context API for components, so I couldn’t turn to the framework for the answer. Prop-drilling was also a non-starter because it tightly couples discovery to a specific parent chain.

I adapted React’s context pattern for store propagation. The implementation has two parts:

  • StoreProvider takes the role of React’s Context provider. It owns one page’s store and makes it available to its descendants.
  • getStore takes the role of useContext. It lets child components resolve the nearest provider’s store in their tree.

Each search page mounts a StoreProvider at its root:

HTML</>
<template>  <store-provider>    <search-bar></search-bar>  </store-provider></template>

Consumers replace the singleton import with a getStore(this) call. The subscription and dispatch logic stays the same:

TypeScript</>
import { LightningElement } from 'lwc';import { getStore, queryChanged } from 'results/state'; class SearchBar extends LightningElement {  private store: Store<State>;  private unsubscribe: () => void;   connectedCallback() {    this.store = getStore(this);    this.unsubscribe = this.store.subscribe(() => {      const state = this.store.getState();      // ...    });  }   disconnectedCallback() {    this.unsubscribe();  }   updateQuery(event: Event) {    this.store.dispatch(queryChanged(event.target.value));  }}

Two tabs now mount two providers, which create two stores. A dispatch from one tab only reaches subscribers connected to that store. The reducers, actions, selectors, and components stay shared.

Finding the nearest provider

When a consumer calls getStore(this), the utility dispatches a StoreRequestEvent from that consumer’s instance. The event is configured to bubble up through shadow DOM boundaries and carries a callback for a provider to call with its store.

Because event dispatch is synchronous, the callback passes the store back before getStore continues. The utility returns that store to the consumer; if no provider is found, it throws an error instead.

TypeScript</>
type StoreCallback = (store: Store<State>) => void; class StoreRequestEvent extends CustomEvent<{ callback: StoreCallback }> {  static readonly NAME = 'storerequest';   constructor(callback: StoreCallback) {    super(StoreRequestEvent.NAME, {      detail: { callback },      bubbles: true,      composed: true    });  }} function getStore(element: Element): Store<State> {  let resolved: Store<State> | undefined;   element.dispatchEvent(    new StoreRequestEvent((store) => {      resolved = store;    })  );   if (!resolved) {    throw new Error('No StoreProvider found above this component.');  }   return resolved;}

On the other side, StoreProvider listens for the event, stops propagation so an outer provider cannot also respond, and invokes the callback with its store:

TypeScript</>
import { LightningElement } from 'lwc';import { StoreRequestEvent } from 'results/state'; class StoreProvider extends LightningElement {  private store = createStore(reducer);  private storeRequestHandler = this.handleStoreRequest.bind(this);   connectedCallback() {    this.addEventListener(      StoreRequestEvent.NAME,      this.storeRequestHandler    );  }   disconnectedCallback() {    this.removeEventListener(      StoreRequestEvent.NAME,      this.storeRequestHandler    );  }   private handleStoreRequest(event: StoreRequestEvent) {    event.stopPropagation();    event.detail.callback(this.store);  }}

The StoreRequestEvent is the internal protocol between getStore and StoreProvider. State consumers only depend on getStore; the event and provider stay behind that API. This keeps store discovery centralized and decoupled from a consumer’s exact parent chain.

What came next

This architecture was transitional, but it directly unblocked a major product milestone: multi-tab search. Each tab could keep its own state without rewriting the existing Redux layer.

The cost was in the consumer setup. Components still had to look up the store, subscribe, and unsubscribe by hand. Store discovery also depended on a custom event-and-callback convention rather than an LWC primitive.

The provider model gave me a useful starting point for the next iteration of this work. LWC doesn’t natively support context sharing between components, but its wire service can propagate context into wire adapters, which are conceptually similar to React hooks.

That insight became the basis for declarative state access, where I revisited store discovery and the manual subscription work left in each consumer.