Building a composable search widget system requires solving a state management challenge that simple prop drilling cannot handle: dozens of independent UI components — query input, results list, facet filters, active filter chips, pagination, sort controls — all need to read and write to a shared search state without creating tight coupling between them. One effective approach uses a layered React Context architecture where a root <SearchProvider> component establishes shared contexts for authentication, API configuration, and search state, and child widget components consume only the contexts they need.
The architecture uses useReducer rather than useState for search state management because search state transitions are inherently complex — a single user action like clicking a facet filter must simultaneously update the active filters, reset pagination to page 1, and trigger a new API request. Expressing these transitions as reducer actions (SET_QUERY, ADD_FILTER, REMOVE_FILTER, SET_PAGE, SET_RESULTS) makes the state machine explicit, testable, and debuggable via React DevTools.
The composable widget pattern means consumers can arrange search UI elements freely — placing the query box in a header, results in a main content area, and facets in a sidebar — without any widget needing to know about the others’ existence. Communication happens entirely through the shared context. This is the same pattern used by libraries like Algolia InstantSearch and Elastic Search UI, adapted here for a custom search backend.
Instead of a single monolithic context, the system uses three nested contexts: AuthContext holds authentication tokens and user identity, ApiConfigContext holds endpoint URLs, API keys, and request configuration, and SharedContext holds the search state (query, filters, results, pagination). This separation means components that only need auth information (like a user menu) don’t re-render when search results change, and components that only need API config (like a health check) are isolated from both auth and search state changes.
The search state reducer handles a finite set of actions that represent all possible state transitions. Unlike useState with multiple set calls (which batches in React 18+ but can still produce intermediate invalid states in event handlers), a reducer processes each action atomically — the state goes directly from “old state” to “new state” without passing through any intermediate form. This is critical for search UIs where multiple state properties must change in lockstep (e.g., changing a filter must also reset pagination).
The root <SearchProvider> component wraps the application’s search area and provides all necessary contexts. It accepts configuration props (API key, endpoint, initial query) and translates them into context values. Consumers never interact with Context.Provider directly — <SearchProvider> encapsulates the provider nesting order, default values, and initialization logic. This is a facade over the complexity of multiple nested providers.
Each widget (QueryBox, Results, Facet, ActiveFilters, Pagination) is a self-contained component that reads from SharedContext and dispatches actions to the reducer. Widgets have no props for passing search state — all state flows through context. This means widgets can be placed anywhere in the component tree below <SearchProvider> without threading props through intermediate components. The trade-off is that widgets are coupled to the context shape, not to their parent components.
The reducer produces new state objects on every action rather than mutating existing state. This guarantees that React detects changes via reference equality (===) and re-renders only the components whose consumed context values have actually changed. Mutation-based updates would bypass React’s change detection, causing components to display stale data. Immutable updates also make time-travel debugging possible — each action produces a complete, independent state snapshot.
The reducer defines the complete set of state transitions for the search UI. Each action type corresponds to a user interaction or an API response.
// Search state types
interface SearchState {
query: string;
filters: Record<string, string[]>;
results: SearchResult[];
totalHits: number;
page: number;
pageSize: number;
loading: boolean;
error: string | null;
facets: Record<string, FacetBucket[]>;
sortBy: string;
sortOrder: 'asc' | 'desc';
}
type SearchAction =
| { type: 'SET_QUERY'; query: string }
| { type: 'ADD_FILTER'; field: string; value: string }
| { type: 'REMOVE_FILTER'; field: string; value: string }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_PAGE'; page: number }
| { type: 'SET_SORT'; sortBy: string; sortOrder: 'asc' | 'desc' }
| { type: 'SEARCH_START' }
| { type: 'SEARCH_SUCCESS'; results: SearchResult[]; totalHits: number;
facets: Record<string, FacetBucket[]> }
| { type: 'SEARCH_ERROR'; error: string };
function searchReducer(state: SearchState, action: SearchAction): SearchState {
switch (action.type) {
case 'SET_QUERY':
return { ...state, query: action.query, page: 0 };
case 'ADD_FILTER': {
const existing = state.filters[action.field] ?? [];
if (existing.includes(action.value)) return state;
return {
...state,
filters: {
...state.filters,
[action.field]: [...existing, action.value],
},
page: 0, // reset pagination when filters change
};
}
case 'REMOVE_FILTER': {
const existing = state.filters[action.field] ?? [];
const updated = existing.filter(v => v !== action.value);
const newFilters = { ...state.filters };
if (updated.length === 0) {
delete newFilters[action.field];
} else {
newFilters[action.field] = updated;
}
return { ...state, filters: newFilters, page: 0 };
}
case 'CLEAR_FILTERS':
return { ...state, filters: {}, page: 0 };
case 'SET_PAGE':
return { ...state, page: action.page };
case 'SET_SORT':
return {
...state, sortBy: action.sortBy,
sortOrder: action.sortOrder, page: 0,
};
case 'SEARCH_START':
return { ...state, loading: true, error: null };
case 'SEARCH_SUCCESS':
return {
...state, loading: false, results: action.results,
totalHits: action.totalHits, facets: action.facets,
};
case 'SEARCH_ERROR':
return { ...state, loading: false, error: action.error, results: [] };
default:
return state;
}
}
The <SearchProvider> component nests the three context providers and wires up the reducer. It also handles the side effect of executing searches when state changes.
// Context definitions
const AuthContext = React.createContext<AuthState | null>(null);
const ApiConfigContext = React.createContext<ApiConfig | null>(null);
const SharedContext = React.createContext<{
state: SearchState;
dispatch: React.Dispatch<SearchAction>;
} | null>(null);
// Custom hooks for consuming contexts
function useAuth() {
const ctx = React.useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within <SearchProvider>');
return ctx;
}
function useSearchState() {
const ctx = React.useContext(SharedContext);
if (!ctx) throw new Error('useSearchState must be used within <SearchProvider>');
return ctx;
}
// Root provider component
interface SearchProviderProps {
apiKey: string;
endpoint: string;
children: React.ReactNode;
initialQuery?: string;
}
function SearchProvider({ apiKey, endpoint, children, initialQuery = '' }: SearchProviderProps) {
const [state, dispatch] = React.useReducer(searchReducer, {
query: initialQuery,
filters: {},
results: [],
totalHits: 0,
page: 0,
pageSize: 20,
loading: false,
error: null,
facets: {},
sortBy: '_score',
sortOrder: 'desc' as const,
});
const apiConfig = React.useMemo(() => ({ apiKey, endpoint }), [apiKey, endpoint]);
const auth = useAuthInit(apiKey); // custom hook for auth state
// Execute search when query, filters, page, or sort change
React.useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'SEARCH_START' });
executeSearch(apiConfig, state, controller.signal)
.then(response => {
dispatch({
type: 'SEARCH_SUCCESS',
results: response.hits,
totalHits: response.totalHits,
facets: response.facets,
});
})
.catch(err => {
if (!controller.signal.aborted) {
dispatch({ type: 'SEARCH_ERROR', error: err.message });
}
});
return () => controller.abort();
}, [state.query, state.filters, state.page, state.sortBy, state.sortOrder]);
return (
<AuthContext.Provider value={auth}>
<ApiConfigContext.Provider value={apiConfig}>
<SharedContext.Provider value={{ state, dispatch }}>
{children}
</SharedContext.Provider>
</ApiConfigContext.Provider>
</AuthContext.Provider>
);
}
Each widget is a focused component that reads the relevant slice of search state and dispatches actions.
function QueryBox() {
const { state, dispatch } = useSearchState();
const [localQuery, setLocalQuery] = React.useState(state.query);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SET_QUERY', query: localQuery });
};
return (
<form onSubmit={handleSubmit}>
<input
value={localQuery}
onChange={e => setLocalQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
function Facet({ field, title }: { field: string; title: string }) {
const { state, dispatch } = useSearchState();
const buckets = state.facets[field] ?? [];
const activeValues = state.filters[field] ?? [];
return (
<div className="facet">
<h4>{title}</h4>
{buckets.map(bucket => (
<label key={bucket.value}>
<input
type="checkbox"
checked={activeValues.includes(bucket.value)}
onChange={() => {
if (activeValues.includes(bucket.value)) {
dispatch({ type: 'REMOVE_FILTER', field, value: bucket.value });
} else {
dispatch({ type: 'ADD_FILTER', field, value: bucket.value });
}
}}
/>
{bucket.value} ({bucket.count})
</label>
))}
</div>
);
}
function ActiveFilters() {
const { state, dispatch } = useSearchState();
const entries = Object.entries(state.filters).flatMap(([field, values]) =>
values.map(v => ({ field, value: v }))
);
if (entries.length === 0) return null;
return (
<div className="active-filters">
{entries.map(({ field, value }) => (
<span key={`${field}:${value}`} className="filter-chip">
{field}: {value}
<button onClick={() =>
dispatch({ type: 'REMOVE_FILTER', field, value })
}>×</button>
</span>
))}
<button onClick={() => dispatch({ type: 'CLEAR_FILTERS' })}>
Clear all
</button>
</div>
);
}
A composable search widget system lets customers embed search UI into their websites by importing the <SearchProvider> component and composing widget components in their layout. A typical integration looks like: <SearchProvider apiKey="..." endpoint="..."><QueryBox /><ActiveFilters /><Facet field="category" /><Results /><Pagination /></SearchProvider>. The consumer controls layout and styling while the widgets handle all search logic internally.
The layered context architecture proved critical when the team added an analytics feature. Rather than modifying every widget to emit tracking events, they added a fourth context (AnalyticsContext) and a single useEffect in the <SearchProvider> component that listens to state changes and emits events. Existing widgets continued to work unchanged because they only consume SharedContext — the analytics layer was purely additive.
URL synchronization — keeping the browser URL in sync with the search state so that search results pages are bookmarkable and shareable — was implemented as a separate hook (useUrlSync) that reads the initial state from URL parameters and dispatches SET_QUERY, ADD_FILTER, and SET_PAGE actions on mount. Because all state changes flow through the reducer, the URL sync hook simply subscribes to state changes and writes them back to the URL via history.replaceState.
setState calls in an event handler can produce intermediate renders where, for example, filters are updated but pagination hasn’t reset yet. A reducer processes the entire transition atomically.value prop of a Context.Provider is a new object on every render (e.g., value={{ state, dispatch }} without memoization), every consumer re-renders on every parent render regardless of whether the context value actually changed. Use useMemo for object values.