March 2026

Declarative primitives for global state access

In earlier work on scoped stores, I gave each agentic search page instance its own Redux store and built a utility for components to resolve the right store for their tree.

After that lookup, every component still had to work against the low-level store API directly: subscribe to listen for changes, getState to read, dispatch to write, and remember to unsubscribe by hand.

The same lifecycle wiring kept getting copied into new components until it showed up across more than twenty of them. Looking at a component didn’t make its state dependencies obvious. They were scattered across lifecycle code and subscription callbacks.

At its core, this problem is the cost of LWC lacking a higher-level API for Redux. React already solves that with React Redux hooks, so I set out to bring the same API to LWC in an idiomatic way. The result was a small abstraction layer modeled on those hooks: a component declares what state it needs, and the framework handles the subscription work.

Doing it by hand

Here’s an example of how components looked before. Consider a simple search bar that reads the query from the store and writes updates back:

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

Even in a component this small, a few issues show up:

  • Reading one value costs three fields. The value itself, a store reference, and an unsubscribe handle all end up on the class.
  • A manual store lookup is required. That means calling getStore, a utility from earlier work on scoped stores, to resolve the right store instance.
  • One callback holds everything. subscribe fires on every dispatch, no matter what changed. Assignments and side effects need manual comparisons. Each new value joins the same callback until it becomes a wall of if checks.
  • Unsubscribe is easy to miss. Forget the unsubscribe() call and the subscription leaks.

Borrowing from React Redux

React Redux solves this problem with three hooks: useSelector, useDispatch, and useStore. Together, they give components a declarative way to work with the store while abstracting away the manual subscription and cleanup.

LWC doesn’t support those hooks, but its wire service allows us to achieve a similar effect. An LWC component uses the @wire decorator to bind a property or function to a wire adapter, which is just a reusable data provider. That binding stays up to date as new data arrives.

The key realization here was that React Redux hooks and wire adapters solve the same problem in different shapes. A hook keeps a value in sync inside a function body; an adapter keeps a class member in sync from the outside.

Adapters can also receive context from a provider higher in the DOM, so the store can come from a parent instead of a lookup in every consumer.

With that, I modeled each React Redux hook as a wire adapter:

HookWire AdapterWhat it provides
useSelectorStoreValueA single value from the store. Updates when the selected value changes.
useDispatchStoreDispatchA dispatch function already bound to the right store instance.
useStoreStoreReferenceThe store itself, for the cases that don’t fit a selector.

Reading a value

StoreValue takes a selector function and returns the selected value from the store. It handles the subscription, cleanup, and value diffing behind the scenes.

TypeScript</>
@wire(StoreValue, { selector: (state) => state.query })query: string;

The property only updates when the selected value changes. A strict equality check is used by default, but a custom comparison function can be specified if needed.

TypeScript</>
@wire(StoreValue, {  selector: (state) => state.query,  equalityFn: (a, b) => a.toLowerCase() === b.toLowerCase()})query: string;

The adapter can also be wired to a method, which is useful for running side effects when the selected value changes.

TypeScript</>
@wire(StoreValue, { selector: (state) => state.query })onQueryChange(query: string) {  this.runSearch(query);}

Dispatching an action

StoreDispatch gives the component a dispatch function already bound to the right store instance, so there’s no need to look up the store manually:

TypeScript</>
@wire(StoreDispatch)dispatch: (action: unknown) => void; updateQuery(event: Event) {  this.dispatch(queryChanged(event.target.value));}

The escape hatch

Not every case needs a reactive selector. Sometimes a component needs to read several values at once, or manage its own subscription for something more complicated.

Enriching a log payload is a good example. The component needs a few pieces of state at the moment it logs, not a live binding for each one.

StoreReference provides the store instance directly, so the low-level getState and subscribe APIs are still available for that kind of work:

TypeScript</>
@wire(StoreReference)store: Store<State>; logSearchPerformed(event: CustomEvent<{ query: string }>) {  const { query } = event.detail;  const { viewMode, currentPage } = this.store.getState();   const context = {    viewMode,    currentPage,    timestamp: Date.now()  };   this.logger.log({ ...context, query });}

That freedom has a cost. A component that leans on it excessively can end up back in the lifecycle pitfalls this work was meant to remove. I kept it as a deliberate escape hatch, not as the default path.

The same shape as React

Compare SearchBar before and after. With StoreValue and StoreDispatch, the refactored version declares the value it reads and the action it dispatches up front. The lifecycle code from before is gone.

TypeScript
import { LightningElement, wire } from 'lwc';import { StoreValue, StoreDispatch, queryChanged } from 'results/state'; class SearchBar extends LightningElement {  @wire(StoreValue, { selector: (state) => state.query })  private query: string;   @wire(StoreDispatch)  private dispatch: (action: unknown) => void;   updateQuery(event: Event) {    this.dispatch(queryChanged(event.target.value));  }}

Here’s the same component in React. A selector pulls one value out of the store, and dispatch sends actions back in.

TypeScript (TSX)</>
import { useSelector, useDispatch } from 'react-redux';import { queryChanged } from 'results/state'; function SearchBar() {  const query = useSelector((state) => state.query);  const dispatch = useDispatch();   return (    <input      value={query}      onChange={(event) => dispatch(queryChanged(event.target.value))}    />  );}

The LWC version is supposed to resemble the React one and feel familiar beside it. That was by design.

Under the hood

None of that lifecycle work went away. It moved into two places: the adapter a component wires to, and the provider that hands out the store.

The adapter

At a technical level, a wire adapter is any class that implements the WireAdapter interface. That’s the whole contract, and it’s only three methods, which map closely to the lifecycle methods a component already has:

  • connect: invoked when the consumer connects to the DOM
  • disconnect: invoked when the consumer disconnects
  • update: invoked with the config from the @wire decorator and the context from a provider

To push data into a component, an adapter invokes a dataCallback with the value to provision. This callback is provided by the wire service when the adapter is instantiated.

TypeScript
type Selector = (state: State) => unknown;type EqualityFn = (a: unknown, b: unknown) => boolean; interface StoreValueConfig {   selector: Selector;  equalityFn?: EqualityFn;} interface StoreContext {  store: Store<State>;} const defaultEqualityFn: EqualityFn = (a, b) => a === b; class StoreValue implements WireAdapter {  private store: Store<State>;  private selector: Selector;  private isEqual = defaultEqualityFn;  private value?: unknown;  private unsubscribe?: () => void;   constructor(private dataCallback: (value: unknown) => void) {}   connect() {    this.subscribe();  }   disconnect() {    this.unsubscribe?.();  }   update(config: StoreValueConfig, context: StoreContext) {    this.selector = config.selector;    this.isEqual = config.equalityFn ?? defaultEqualityFn;    this.store = context.store;    this.subscribe();  }   private subscribe() {    this.unsubscribe?.();    this.provide();    this.unsubscribe = this.store.subscribe(() => this.provide());  }   private provide() {    const next = this.selector(this.store.getState());     if (this.isEqual(this.value, next)) return;     this.value = next;    this.dataCallback(next);  }}

The subscription logic that used to live in a component’s lifecycle callbacks has a natural home in connect and disconnect. The wire service handles context propagation, so the adapter can reach the store without looking it up.

From there, each adapter pushes its result back through dataCallback: a selected value, a bound dispatch function, or the store itself.

The provider

In earlier work on scoped stores, I built a StoreProvider that creates a store for each search page and makes it available to components in its subtree. For this work, I repurposed that same provider as the context source for the new adapters.

LWC exposes a lesser-known API for this: createContextProvider. It takes an adapter and returns a contextualizer: a function that a component calls to register itself as the source of context for that adapter.

Each adapter needs its own contextualizer, so StoreProvider loops over all three and provides its store to each of them:

TypeScript</>
import { LightningElement, createContextProvider } from 'lwc';import { StoreValue, StoreDispatch, StoreReference } from 'results/state'; const contextualizers = [StoreValue, StoreDispatch, StoreReference].map(  createContextProvider); class StoreProvider extends LightningElement {  private store = createStore(reducer);   connectedCallback() {    for (const contextualize of contextualizers) {      contextualize(this, {        consumerConnectedCallback: (consumer) =>          consumer.provide({ store: this.store })      });    }  }}

The adapters declare what a component needs, StoreProvider owns the store, and createContextProvider threads that store into the adapters at runtime. No prop drilling, and no manual store lookup in each consumer.

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

Tradeoffs

None of this is part of LWC or Redux. There’s no upstream maintainer to fix bugs and ship new features for free. It’s a custom layer my team owns, so we’re responsible for its upkeep.

The other cost is StoreReference. A selector isn’t always the right tool, so the escape hatch has to exist. Used carefully, it stays rare. If components use it as the default, they end up back in the lifecycle pitfalls this work was meant to remove.

In practice, those costs have been manageable. Components no longer subscribe and unsubscribe by hand. They don’t hold monolithic subscribe callbacks or look up the store themselves. State dependencies show up on the class declaration instead of lifecycle code.

That logic now sits in three wire adapters. We can reuse them across components and test each one in isolation. The adapters themselves are just idiomatic LWC primitives applied to Redux.