Widget Registry & Multi-Query Orchestration

Frontend Engineering advanced Building with AI ~22 min read

Overview

Modern search interfaces often involve multiple independent UI components — filters, facets, suggestion panels, result lists — each with their own data requirements. Naively, each component fires its own API call whenever its internal state changes, causing a waterfall of redundant network requests, race conditions, and inconsistent loading states across the page.

The Widget Registry pattern solves this by inverting control. Instead of each component owning its own fetch logic, components register themselves and their query fragments into a shared context. A single coordinating Listener component observes all registered queries, debounces rapid changes, and batches everything into one multiquery API call. Results are distributed back to each widget through the same context.

This pattern is particularly powerful in search-heavy applications built with React where a sidebar might host a dozen independent filter widgets — date ranges, category pickers, free-text fields, geolocation inputs — all of which need to contribute to the same underlying search query without knowing about each other.

Core Insight

The widget registry is a form of query aggregation via inversion of control. Widgets declare intent (their query fragment); the Listener decides when and how to execute. This decouples UI components from network orchestration entirely.

Core Concepts

The Widget Registry

A widget registry is a React Context that holds a Map of widget IDs to their current query fragments. Each search UI component — a date filter, a keyword input, a category selector — registers itself in the context when it mounts and deregisters when it unmounts. It then writes its current query state into the registry whenever its local state changes.

The registry context exposes registerWidget, unregisterWidget, and updateQuery functions. Widgets call these directly; they have no awareness of other widgets or of the network layer.

Widget Registration Lifecycle

Widgets register on mount with an initial (possibly empty) query fragment and unregister on unmount. Between mount and unmount, they call updateQuery on every state change. The registry is the single source of truth for the collective search state across all active widgets.

Query Fragments

A query fragment is a partial description of what one widget contributes to the final search request. For a keyword widget it might be { q: "react hooks" }. For a date-range widget it might be { dateFrom: "2024-01-01", dateTo: "2024-12-31" }. Fragments are typed objects that the Listener knows how to merge into a single request payload.

Fragments can be marked optional or required. An optional fragment with empty state contributes nothing to the merged payload; a required fragment with invalid state can block the multiquery from firing until it resolves.

Fragment Merging Strategy

Merging query fragments is intentionally shallow by default: Object.assign({}, ...fragments). If two widgets contribute the same key, the last-registered widget wins. For complex overlap scenarios (e.g., multiple tag filters), fragments can use array fields and the merger can perform a union instead of overwrite.

The Listener Component

The Listener is a non-visual component that subscribes to the registry context. It watches the aggregate query state and uses a debounced effect to fire the actual API call. Once results arrive, it writes them back into the context so any widget can read its relevant slice.

Crucially, the Listener is the only component that touches the network. This makes it straightforward to add features like request deduplication, cancellation via AbortController, optimistic updates, or switching from REST to GraphQL — all without touching any individual widget.

Single Network Authority

By making the Listener the sole network authority, you gain a natural place to enforce cross-cutting concerns: authentication headers, rate limiting, loading state aggregation, and error boundaries. Widgets become pure view components that translate user interaction into query fragments and render result slices.

Debouncing Strategies

Debouncing in this context serves two purposes: it prevents excessive API calls during fast user input (typing in a search box), and it batches simultaneous widget updates that arrive in the same event loop tick when the page first loads and all widgets initialize at once.

A leading-edge debounce (fire immediately, then suppress for N ms) gives fast perceived responsiveness for the first keypress. A trailing-edge debounce (fire after N ms of silence) is better for typeahead scenarios. A combined debounce with a max-wait ceiling (like Lodash’s debounce with maxWait) provides the best of both: fast initial response and guaranteed eventual consistency.

Debounce vs. Throttle in Search UIs

Debounce waits for a pause in changes — ideal for text input where you want to wait until the user stops typing. Throttle fires at a fixed interval regardless — better for scroll-position-based triggers. For widget registries, debounce is almost always correct because the trigger is discrete UI interactions, not continuous streams.

How It Works

Building the Registry Context

The foundation is a typed React Context with a reducer that manages the widget map. Each entry in the map holds the widget’s ID, its current query fragment, and any metadata like whether the fragment is currently valid.

// widgetRegistry.tsx
import React, {
  createContext,
  useContext,
  useReducer,
  useCallback,
  useMemo,
  useRef,
  useEffect,
  type ReactNode,
} from "react";

// --- Types ---

export type QueryFragment = Record<string, unknown>;

export interface WidgetRegistration {
  id: string;
  fragment: QueryFragment;
  isValid: boolean;
}

export interface SearchResults {
  hits: unknown[];
  total: number;
  facets?: Record<string, unknown>;
}

interface RegistryState {
  widgets: Map<string, WidgetRegistration>;
  results: SearchResults | null;
  isLoading: boolean;
  error: string | null;
}

type RegistryAction =
  | { type: "REGISTER"; payload: WidgetRegistration }
  | { type: "UNREGISTER"; payload: { id: string } }
  | { type: "UPDATE_FRAGMENT"; payload: { id: string; fragment: QueryFragment; isValid?: boolean } }
  | { type: "SET_RESULTS"; payload: SearchResults }
  | { type: "SET_LOADING"; payload: boolean }
  | { type: "SET_ERROR"; payload: string | null };

// --- Reducer ---

function registryReducer(state: RegistryState, action: RegistryAction): RegistryState {
  switch (action.type) {
    case "REGISTER": {
      const next = new Map(state.widgets);
      next.set(action.payload.id, action.payload);
      return { ...state, widgets: next };
    }
    case "UNREGISTER": {
      const next = new Map(state.widgets);
      next.delete(action.payload.id);
      return { ...state, widgets: next };
    }
    case "UPDATE_FRAGMENT": {
      const existing = state.widgets.get(action.payload.id);
      if (!existing) return state;
      const next = new Map(state.widgets);
      next.set(action.payload.id, {
        ...existing,
        fragment: action.payload.fragment,
        isValid: action.payload.isValid ?? true,
      });
      return { ...state, widgets: next };
    }
    case "SET_RESULTS":
      return { ...state, results: action.payload, isLoading: false, error: null };
    case "SET_LOADING":
      return { ...state, isLoading: action.payload };
    case "SET_ERROR":
      return { ...state, error: action.payload, isLoading: false };
    default:
      return state;
  }
}

// --- Context ---

interface RegistryContextValue {
  state: RegistryState;
  registerWidget: (registration: WidgetRegistration) => void;
  unregisterWidget: (id: string) => void;
  updateQuery: (id: string, fragment: QueryFragment, isValid?: boolean) => void;
}

const RegistryContext = createContext<RegistryContextValue | null>(null);

export function useWidgetRegistry(): RegistryContextValue {
  const ctx = useContext(RegistryContext);
  if (!ctx) throw new Error("useWidgetRegistry must be used inside WidgetRegistryProvider");
  return ctx;
}

// --- Provider ---

interface WidgetRegistryProviderProps {
  children: ReactNode;
  onQuery: (mergedQuery: QueryFragment) => Promise<SearchResults>;
  debounceMs?: number;
}

export function WidgetRegistryProvider({
  children,
  onQuery,
  debounceMs = 300,
}: WidgetRegistryProviderProps) {
  const [state, dispatch] = useReducer(registryReducer, {
    widgets: new Map(),
    results: null,
    isLoading: false,
    error: null,
  });

  const registerWidget = useCallback((registration: WidgetRegistration) => {
    dispatch({ type: "REGISTER", payload: registration });
  }, []);

  const unregisterWidget = useCallback((id: string) => {
    dispatch({ type: "UNREGISTER", payload: { id } });
  }, []);

  const updateQuery = useCallback(
    (id: string, fragment: QueryFragment, isValid = true) => {
      dispatch({ type: "UPDATE_FRAGMENT", payload: { id, fragment, isValid } });
    },
    []
  );

  const value = useMemo(
    () => ({ state, registerWidget, unregisterWidget, updateQuery }),
    [state, registerWidget, unregisterWidget, updateQuery]
  );

  return (
    <RegistryContext.Provider value={value}>
      {children}
      <QueryListener
        state={state}
        dispatch={dispatch}
        onQuery={onQuery}
        debounceMs={debounceMs}
      />
    </RegistryContext.Provider>
  );
}

The QueryListener Component

The QueryListener is a render-less component placed inside the provider. It watches the registry state, merges all valid fragments, and fires the onQuery callback after debouncing. It uses an AbortController ref to cancel in-flight requests when new ones supersede them.

// QueryListener.tsx (continued from widgetRegistry.tsx)

import { useDebounce } from "./useDebounce"; // shown below

interface QueryListenerProps {
  state: RegistryState;
  dispatch: React.Dispatch<RegistryAction>;
  onQuery: (query: QueryFragment) => Promise<SearchResults>;
  debounceMs: number;
}

function mergeFragments(widgets: Map<string, WidgetRegistration>): QueryFragment {
  const merged: QueryFragment = {};
  for (const [, widget] of widgets) {
    if (widget.isValid) {
      // Shallow merge; array values are unioned
      for (const [key, value] of Object.entries(widget.fragment)) {
        if (Array.isArray(merged[key]) && Array.isArray(value)) {
          merged[key] = [...(merged[key] as unknown[]), ...value];
        } else {
          merged[key] = value;
        }
      }
    }
  }
  return merged;
}

function QueryListener({ state, dispatch, onQuery, debounceMs }: QueryListenerProps) {
  const mergedQuery = useMemo(
    () => mergeFragments(state.widgets),
    [state.widgets]
  );

  const debouncedQuery = useDebounce(mergedQuery, debounceMs);

  // Track in-flight requests for cancellation
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Skip if no widgets are registered yet
    if (state.widgets.size === 0) return;

    // Cancel any previous request
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    dispatch({ type: "SET_LOADING", payload: true });

    onQuery(debouncedQuery)
      .then((results) => {
        if (!controller.signal.aborted) {
          dispatch({ type: "SET_RESULTS", payload: results });
        }
      })
      .catch((err: unknown) => {
        if (!controller.signal.aborted) {
          const message = err instanceof Error ? err.message : "Unknown error";
          dispatch({ type: "SET_ERROR", payload: message });
        }
      });

    return () => {
      controller.abort();
    };
  }, [debouncedQuery, state.widgets.size, dispatch, onQuery]);

  return null; // Render nothing
}

// --- useDebounce hook ---

function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = React.useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

// --- useWidget hook for individual widgets ---

export function useWidget(id: string, initialFragment: QueryFragment = {}) {
  const { registerWidget, unregisterWidget, updateQuery, state } = useWidgetRegistry();

  useEffect(() => {
    registerWidget({ id, fragment: initialFragment, isValid: true });
    return () => unregisterWidget(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id]); // intentionally only on id change

  const setFragment = useCallback(
    (fragment: QueryFragment, isValid = true) => {
      updateQuery(id, fragment, isValid);
    },
    [id, updateQuery]
  );

  return {
    setFragment,
    results: state.results,
    isLoading: state.isLoading,
    error: state.error,
  };
}

Implementing Individual Widgets

With the registry and useWidget hook in place, building a new search widget is straightforward. The widget manages only its own local UI state, translates changes into a query fragment, and calls setFragment. It has no network code of its own.

// KeywordWidget.tsx
import { useState, useCallback } from "react";
import { useWidget } from "./widgetRegistry";

export function KeywordWidget() {
  const [value, setValue] = useState("");
  const { setFragment, isLoading } = useWidget("keyword-widget", { q: "" });

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      setValue(next);
      setFragment({ q: next }, next.length === 0 || next.length >= 2);
    },
    [setFragment]
  );

  return (
    <div className="widget keyword-widget">
      <label htmlFor="kw">Search</label>
      <input
        id="kw"
        type="text"
        value={value}
        onChange={handleChange}
        placeholder="Type to search..."
        aria-busy={isLoading}
      />
    </div>
  );
}

// DateRangeWidget.tsx
import { useState, useCallback } from "react";
import { useWidget } from "./widgetRegistry";

interface DateRange {
  from: string;
  to: string;
}

export function DateRangeWidget() {
  const [range, setRange] = useState<DateRange>({ from: "", to: "" });
  const { setFragment } = useWidget("date-range-widget", {});

  const handleChange = useCallback(
    (field: keyof DateRange, val: string) => {
      const next = { ...range, [field]: val };
      setRange(next);
      const isValid = !next.from || !next.to || next.from <= next.to;
      const fragment = next.from && next.to ? { dateFrom: next.from, dateTo: next.to } : {};
      setFragment(fragment, isValid);
    },
    [range, setFragment]
  );

  return (
    <div className="widget date-range-widget">
      <label>From</label>
      <input type="date" value={range.from} onChange={(e) => handleChange("from", e.target.value)} />
      <label>To</label>
      <input type="date" value={range.to} onChange={(e) => handleChange("to", e.target.value)} />
    </div>
  );
}

// CategoryWidget.tsx
import { useState, useCallback } from "react";
import { useWidget } from "./widgetRegistry";

const CATEGORIES = ["News", "Products", "People", "Events"] as const;
type Category = (typeof CATEGORIES)[number];

export function CategoryWidget() {
  const [selected, setSelected] = useState<Category[]>([]);
  const { setFragment } = useWidget("category-widget", {});

  const toggle = useCallback(
    (cat: Category) => {
      setSelected((prev) => {
        const next = prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat];
        setFragment(next.length ? { categories: next } : {});
        return next;
      });
    },
    [setFragment]
  );

  return (
    <div className="widget category-widget">
      {CATEGORIES.map((cat) => (
        <label key={cat}>
          <input
            type="checkbox"
            checked={selected.includes(cat)}
            onChange={() => toggle(cat)}
          />
          {cat}
        </label>
      ))}
    </div>
  );
}

// Wiring it all together
import { WidgetRegistryProvider } from "./widgetRegistry";

async function searchAPI(query: QueryFragment) {
  const response = await fetch("/api/search", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(query),
  });
  if (!response.ok) throw new Error(`Search failed: ${response.status}`);
  return response.json();
}

export function SearchPage() {
  return (
    <WidgetRegistryProvider onQuery={searchAPI} debounceMs={350}>
      <div className="search-layout">
        <aside className="filters">
          <KeywordWidget />
          <DateRangeWidget />
          <CategoryWidget />
        </aside>
        <main>
          <ResultsWidget />
        </main>
      </div>
    </WidgetRegistryProvider>
  );
}

Practical Applications

Dashboard Search Interfaces

Analytics dashboards with multiple filter panels are the canonical use case. Each panel — date picker, metric selector, dimension filter, comparison period — registers as a widget. When the user adjusts any filter, the Listener picks up the change, debounces, and fires a single batched query to the analytics API. All charts and tables update from the same result set, guaranteeing consistency.

Without the registry pattern, coordinating N filter components typically requires lifting state to a page-level component, which quickly becomes a sprawling mess as the number of filters grows. The registry allows unlimited widget addition without modifying the page-level component at all.

Product search pages with dozens of facets (brand, price range, rating, color, size, availability) are another strong fit. Each facet widget is self-contained. The Listener batches them and calls a search endpoint that supports multiple filter parameters simultaneously. Result counts per facet can be returned in the same response and distributed back to each facet widget for display.

Dynamic Plugin-Based UIs

When the set of available filter widgets is not known at build time — for example, a configurable search UI where administrators add filter types from a CMS — the registry pattern shines. Widgets can be rendered dynamically from configuration, and each one registers itself on mount. The Listener handles whatever combination appears without needing to be updated.

Multi-Tenant Search Experiences

In applications where different user roles see different filter sets, the registry naturally adapts: widgets for restricted functionality simply do not mount, so they never register, and their query fragments are never included. Access control becomes a matter of conditional rendering rather than conditional query logic.

Implementation Patterns

Stable Query Fragment References

A subtle performance trap: if widgets create new fragment objects on every render (e.g., setFragment({ q: value }) inside the render body rather than in a handler), the Listener’s useMemo for mergeFragments will see a new reference on every render even when the actual data has not changed. This causes spurious debounce resets and unnecessary API calls.

The fix is to ensure widgets only call setFragment inside event handlers or effects, never directly in the render pass. Using useCallback for handlers and comparing previous fragment values before dispatching are additional safeguards.

Deep Equality for Debounce Stability

The useDebounce hook shown above uses referential equality by default (React’s useState setter batches same-reference values). To avoid double-firing when fragment content is identical but the object reference changed, replace the simple debounce with one that performs deep equality comparison before updating state. Libraries like use-deep-compare-effect or a custom JSON.stringify comparison work well here.

Max-Wait Ceiling

For interactive search where users type continuously, a pure trailing-edge debounce means results only appear when the user pauses. Adding a maxWait ceiling (e.g., fire at most every 1000 ms even if changes keep arriving) provides a better user experience. Lodash’s debounce with maxWait is the standard implementation:

import { debounce } from "lodash-es";
import { useRef, useEffect, useMemo } from "react";

function useMaxWaitDebounce<T>(
  callback: (value: T) => void,
  delayMs: number,
  maxWaitMs: number
) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  const debouncedFn = useMemo(
    () =>
      debounce((v: T) => callbackRef.current(v), delayMs, {
        leading: false,
        trailing: true,
        maxWait: maxWaitMs,
      }),
    [delayMs, maxWaitMs]
  );

  useEffect(() => () => debouncedFn.cancel(), [debouncedFn]);

  return debouncedFn;
}

Optimistic Loading State

When the Listener sets isLoading: true, individual widgets can show skeleton states or spinners without waiting for the response. Since loading state lives in the registry context, any widget can subscribe to it via useWidgetRegistry() and react accordingly. A shared &lt;LoadingBar /> at the top of the page that reads from the same context gives users a clear signal without each widget needing its own indicator.

Request Deduplication

If multiple widgets update in the same React render cycle (e.g., on page load when all widgets initialize simultaneously), the reducer will batch their state updates, but the debounce effect will still only fire once because debouncedQuery resolves to a single merged value. This is the natural deduplication provided by the debounce — no additional logic needed.

For more complex deduplication (e.g., suppressing a new request if the merged query is identical to the last successful request), track the last-executed query in a ref and compare before dispatching:

const lastQueryRef = useRef<string>("");

useEffect(() => {
  if (state.widgets.size === 0) return;

  const serialized = JSON.stringify(debouncedQuery);
  if (serialized === lastQueryRef.current) return; // identical to last request
  lastQueryRef.current = serialized;

  abortRef.current?.abort();
  const controller = new AbortController();
  abortRef.current = controller;

  dispatch({ type: "SET_LOADING", payload: true });

  onQuery(debouncedQuery)
    .then((results) => {
      if (!controller.signal.aborted) {
        dispatch({ type: "SET_RESULTS", payload: results });
      }
    })
    .catch((err: unknown) => {
      if (!controller.signal.aborted) {
        dispatch({ type: "SET_ERROR", payload: err instanceof Error ? err.message : "Error" });
      }
    });

  return () => controller.abort();
}, [debouncedQuery, state.widgets.size, dispatch, onQuery]);

Splitting Results by Widget

When the search API supports a multiquery format (e.g., Elasticsearch’s _msearch or Algolia’s multipleQueries), the Listener can send each widget’s fragment as a separate sub-query in a single HTTP request. Results come back as an array indexed by sub-query, and the Listener distributes each result slice back to the appropriate widget via a results map keyed by widget ID.

This is more complex to implement but enables per-widget result counts, per-widget facets, and independent pagination — all in a single round trip.

Common Pitfalls

Stale Closure in the Listener Effect

The most common bug is a stale closure over onQuery in the Listener’s useEffect. If onQuery is defined inline in the parent component without useCallback, it changes on every render, causing the effect to re-run on every render rather than only when the debounced query changes. Always wrap onQuery in useCallback at the call site, or use a ref to hold the latest version.

Context Re-Render Storms

Because the registry context state contains a Map that is replaced on every update, all consumers re-render on every fragment change — even if their specific fragment did not change. For pages with many widgets, this can cause noticeable jank. The standard mitigations are: split context into a dispatch context and a state context (consumers that only dispatch don’t need the state); use React.memo on widget components; or migrate to a state manager like Zustand that supports fine-grained subscriptions.

Widget ID Collisions

If two widget instances accidentally share the same ID — which can happen when widgets are rendered in loops without unique keys — one widget’s fragment will overwrite the other’s in the registry, and only one will deregister on unmount, leaving a ghost entry. Always generate IDs with a stable unique prefix plus a distinguishing value (e.g., `category-filter-${categoryType}`), or use useId() from React 18 for truly unique IDs.

Infinite Loops from Unstable Fragment Objects

Calling setFragment during the render phase — rather than in a useEffect or event handler — creates a dispatch-render-dispatch loop. React will throw in strict mode, but in production it silently hammers the reducer. Enforce the rule that setFragment is only called from effects (for initialization) and event handlers (for user interaction).

Missing Cleanup on Unmount

If a widget unmounts but fails to call unregisterWidget — for example because the cleanup function in useEffect was omitted — its stale fragment persists in the registry. Future query fires will include this ghost fragment, producing incorrect search results. The useWidget hook above handles this automatically, but custom widget implementations must be careful to always return a cleanup function from their useEffect that calls unregisterWidget.

Further Reading