Search Components Intermediate 12 min read
Predictive Search with Debouncing
Implement Shopify predictive search with React. Learn debouncing, API integration, and handling search states for instant results.
Predictive search shows results as users type, creating a faster shopping experience. Shopify provides a powerful predictive search API that returns products, collections, pages, and suggestions.
Theme Integration
This hook and functionality is used by the SearchModal component:
snippets/search-modal.liquid (included in theme.liquid)└── <div id="search-modal-root"> └── SearchModal (React) └── usePredictiveSearch hook ← You are here └── SearchResults (renders API response)See Search Modal Architecture for the complete component structure and mount point setup.
Data Source
| Prop/State | Source | Origin |
|---|---|---|
query | User input | Text typed in SearchInput |
debouncedQuery | Derived | query after 300ms delay |
results | Shopify API | GET /search/suggest.json response |
isLoading | Local state | - |
error | Local state | API error messages |
Data Flow: Predictive Search
1. USER TYPES IN SEARCH INPUT ┌─────────────────────────────────────────────────────────┐ │ SearchModal │ │ └── SearchInput │ │ └── onChange → setQuery("dress") │ └─────────────────────────────────────────────────────────┘ ↓2. DEBOUNCE DELAYS API CALL (300ms) ┌─────────────────────────────────────────────────────────┐ │ usePredictiveSearch hook │ │ └── useDebounce(query, 300) │ │ └── Waits 300ms after last keystroke │ │ └── If user types again, timer resets │ └─────────────────────────────────────────────────────────┘ ↓3. API REQUEST TO SHOPIFY ┌─────────────────────────────────────────────────────────┐ │ fetchPredictiveSearch(debouncedQuery) │ │ │ │ GET /search/suggest.json?q=dress │ │ &resources[type]=product,collection,page,query │ │ &resources[limit]=4 │ └─────────────────────────────────────────────────────────┘ ↓4. SHOPIFY RETURNS RESULTS ┌─────────────────────────────────────────────────────────┐ │ Response: { │ │ resources: { │ │ results: { │ │ products: [...], // Up to 4 products │ │ collections: [...], // Up to 4 collections │ │ pages: [...], // Up to 4 pages │ │ queries: [...] // Search suggestions │ │ } │ │ } │ │ } │ └─────────────────────────────────────────────────────────┘ ↓5. TRANSFORM & UPDATE STATE ┌─────────────────────────────────────────────────────────┐ │ transformSearchResponse(data) │ │ └── Snake_case → camelCase conversion │ │ └── setResults(transformedData) │ │ └── setIsLoading(false) │ └─────────────────────────────────────────────────────────┘ ↓6. RENDER RESULTS ┌─────────────────────────────────────────────────────────┐ │ SearchResults │ │ ├── SuggestionResult[] (queries) │ │ ├── ProductResult[] (products) │ │ ├── CollectionResult[] (collections) │ │ └── PageResult[] (pages) │ └─────────────────────────────────────────────────────────┘Key Points:
- No Liquid Data: Unlike other components, search results come entirely from the Shopify Predictive Search API
- Debouncing: Prevents API spam while user types (300ms delay)
- AbortController: Cancels stale requests if user types again before response
- Transform Layer: Converts Shopify’s snake_case to camelCase for consistency
Understanding Shopify Predictive Search API
GET /search/suggest.json?q={query}&resources[type]=product,collection,page&resources[limit]=4
Response:{ "resources": { "results": { "products": [...], "collections": [...], "pages": [...], "queries": [...] // Search suggestions } }}Debounce Hook
import { useState, useEffect, useRef, useCallback } from 'react';
/** * useDebounce delays updating a value until after a specified delay. * Useful for search inputs to avoid excessive API calls. * * @param value - The value to debounce * @param delay - Delay in milliseconds * @returns The debounced value */export function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => { // Set a timeout to update the debounced value. const timer = setTimeout(() => { setDebouncedValue(value); }, delay);
// Clear the timeout if value changes before delay completes. return () => { clearTimeout(timer); }; }, [value, delay]);
return debouncedValue;}
/** * useDebouncedCallback returns a debounced version of a callback function. * The callback won't be called until after the delay has passed * since the last invocation. * * @param callback - The function to debounce * @param delay - Delay in milliseconds * @returns Debounced callback function */export function useDebouncedCallback<T extends (...args: any[]) => any>( callback: T, delay: number): (...args: Parameters<T>) => void { const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup on unmount. useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []);
return useCallback( (...args: Parameters<T>) => { // Clear any existing timeout. if (timeoutRef.current) { clearTimeout(timeoutRef.current); }
// Set new timeout. timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay] );}Predictive Search Types
/** * Product result from predictive search. */export interface SearchProduct { id: number; title: string; handle: string; url: string; price: number; compareAtPrice: number | null; featuredImage: { url: string; altText: string | null; } | null; vendor: string; available: boolean;}
/** * Collection result from predictive search. */export interface SearchCollection { id: number; title: string; handle: string; url: string; image: { url: string; altText: string | null; } | null; productsCount: number;}
/** * Page result from predictive search. */export interface SearchPage { id: number; title: string; handle: string; url: string;}
/** * Search suggestion (query). */export interface SearchSuggestion { text: string; styledText: string; // HTML with <mark> tags for matching text.}
/** * Complete predictive search response. */export interface PredictiveSearchResponse { products: SearchProduct[]; collections: SearchCollection[]; pages: SearchPage[]; queries: SearchSuggestion[];}
/** * Search state for the hook. */export interface SearchState { results: PredictiveSearchResponse | null; isLoading: boolean; error: string | null;}Predictive Search API
import type { PredictiveSearchResponse, SearchProduct, SearchCollection, SearchPage } from '@/types/search';
/** * Fetch predictive search results from Shopify. * * @param query - Search query string * @param options - Optional configuration * @returns Predictive search results */export async function fetchPredictiveSearch( query: string, options: { limit?: number; types?: ('product' | 'collection' | 'page' | 'query')[]; } = {}): Promise<PredictiveSearchResponse> { const { limit = 4, types = ['product', 'collection', 'page', 'query'] } = options;
// Build the URL with query parameters. const params = new URLSearchParams({ q: query, 'resources[type]': types.join(','), 'resources[limit]': limit.toString(), });
const url = `/search/suggest.json?${params.toString()}`;
const response = await fetch(url, { headers: { Accept: 'application/json', }, });
if (!response.ok) { throw new Error(`Search failed: ${response.status}`); }
const data = await response.json();
// Transform the response to our types. return transformSearchResponse(data);}
/** * Transform Shopify's response format to our types. */function transformSearchResponse(data: any): PredictiveSearchResponse { const results = data.resources?.results || {};
return { products: (results.products || []).map(transformProduct), collections: (results.collections || []).map(transformCollection), pages: (results.pages || []).map(transformPage), queries: (results.queries || []).map((q: any) => ({ text: q.text, styledText: q.styled_text, })), };}
function transformProduct(product: any): SearchProduct { return { id: product.id, title: product.title, handle: product.handle, url: product.url, price: product.price, compareAtPrice: product.compare_at_price, featuredImage: product.featured_image ? { url: product.featured_image.url, altText: product.featured_image.alt, } : null, vendor: product.vendor, available: product.available, };}
function transformCollection(collection: any): SearchCollection { return { id: collection.id, title: collection.title, handle: collection.handle, url: collection.url, image: collection.image ? { url: collection.image.url, altText: collection.image.alt, } : null, productsCount: collection.products_count || 0, };}
function transformPage(page: any): SearchPage { return { id: page.id, title: page.title, handle: page.handle, url: page.url, };}usePredictiveSearch Hook
import { useState, useEffect } from 'react';import { useDebounce } from './useDebounce';import { fetchPredictiveSearch } from '@/api/search';import type { PredictiveSearchResponse } from '@/types/search';
/** * usePredictiveSearch fetches search results with debouncing. * Automatically cancels stale requests and handles loading/error states. * * @param query - The search query * @param debounceMs - Debounce delay (default 300ms) * @returns Search results, loading state, and error */export function usePredictiveSearch(query: string, debounceMs = 300) { // Debounce the query to avoid excessive API calls. const debouncedQuery = useDebounce(query, debounceMs);
// State for results, loading, and error. const [results, setResults] = useState<PredictiveSearchResponse | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null);
useEffect(() => { // Don't search for empty or very short queries. if (!debouncedQuery || debouncedQuery.trim().length < 2) { setResults(null); setIsLoading(false); setError(null); return; }
// Create an AbortController to cancel stale requests. const abortController = new AbortController();
const performSearch = async () => { setIsLoading(true); setError(null);
try { const data = await fetchPredictiveSearch(debouncedQuery);
// Only update state if request wasn't aborted. if (!abortController.signal.aborted) { setResults(data); } } catch (err) { // Ignore abort errors. if (err instanceof Error && err.name === 'AbortError') { return; }
if (!abortController.signal.aborted) { setError(err instanceof Error ? err.message : 'Search failed'); setResults(null); } } finally { if (!abortController.signal.aborted) { setIsLoading(false); } } };
performSearch();
// Cleanup: abort the request if component unmounts or query changes. return () => { abortController.abort(); }; }, [debouncedQuery]);
// Calculate if we have any results. const hasResults = results && (results.products.length > 0 || results.collections.length > 0 || results.pages.length > 0 || results.queries.length > 0);
return { results, isLoading, error, hasResults, // Expose the debounced query for comparison. debouncedQuery, };}SearchResults Component
import { usePredictiveSearch } from '@/hooks/usePredictiveSearch';import { saveRecentSearch } from './RecentSearches';import { ProductResult } from './ProductResult';import { CollectionResult } from './CollectionResult';import { PageResult } from './PageResult';import { SuggestionResult } from './SuggestionResult';import styles from './SearchResults.module.css';
interface SearchResultsProps { query: string; onClose: () => void;}
/** * SearchResults displays the predictive search results. * Shows products, collections, pages, and search suggestions. */export function SearchResults({ query, onClose }: SearchResultsProps) { const { results, isLoading, error, hasResults, debouncedQuery } = usePredictiveSearch(query);
// Handle clicking a result - save to recent and close modal. const handleResultClick = () => { saveRecentSearch(query); onClose(); };
// Loading state. if (isLoading && !results) { return <SearchResultsSkeleton />; }
// Error state. if (error) { return ( <div className={styles.error}> <p>Something went wrong. Please try again.</p> </div> ); }
// No results state. if (debouncedQuery && !hasResults && !isLoading) { return ( <div className={styles.noResults}> <p className={styles.noResultsTitle}>No results for "{query}"</p> <p className={styles.noResultsHint}> Try checking your spelling or using more general terms. </p> </div> ); }
// Results state. if (!results) return null;
return ( <div className={styles.results}> {/* Loading indicator for subsequent searches. */} {isLoading && <div className={styles.loadingBar} />}
{/* Search suggestions. */} {results.queries.length > 0 && ( <section className={styles.section}> <h3 className={styles.sectionTitle}>Suggestions</h3> <ul className={styles.list}> {results.queries.map((suggestion) => ( <SuggestionResult key={suggestion.text} suggestion={suggestion} onClick={handleResultClick} /> ))} </ul> </section> )}
{/* Products. */} {results.products.length > 0 && ( <section className={styles.section}> <h3 className={styles.sectionTitle}>Products</h3> <ul className={styles.productList}> {results.products.map((product) => ( <ProductResult key={product.id} product={product} onClick={handleResultClick} /> ))} </ul> {/* View all link. */} <a href={`/search?q=${encodeURIComponent(query)}&type=product`} className={styles.viewAll} onClick={handleResultClick} > View all products → </a> </section> )}
{/* Collections. */} {results.collections.length > 0 && ( <section className={styles.section}> <h3 className={styles.sectionTitle}>Collections</h3> <ul className={styles.list}> {results.collections.map((collection) => ( <CollectionResult key={collection.id} collection={collection} onClick={handleResultClick} /> ))} </ul> </section> )}
{/* Pages. */} {results.pages.length > 0 && ( <section className={styles.section}> <h3 className={styles.sectionTitle}>Pages</h3> <ul className={styles.list}> {results.pages.map((page) => ( <PageResult key={page.id} page={page} onClick={handleResultClick} /> ))} </ul> </section> )} </div> );}
/** * Loading skeleton for search results. */function SearchResultsSkeleton() { return ( <div className={styles.skeleton}> <div className={styles.skeletonSection}> <div className={styles.skeletonTitle} /> <div className={styles.skeletonItem} /> <div className={styles.skeletonItem} /> <div className={styles.skeletonItem} /> </div> <div className={styles.skeletonSection}> <div className={styles.skeletonTitle} /> <div className={styles.skeletonProduct} /> <div className={styles.skeletonProduct} /> </div> </div> );}.results { position: relative;}
.loadingBar { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient( 90deg, transparent, var(--color-primary), transparent ); animation: loading 1s ease-in-out infinite;}
@keyframes loading { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); }}
.section { margin-bottom: 1.5rem;}
.section:last-child { margin-bottom: 0;}
.sectionTitle { margin: 0 0 0.75rem; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted);}
.list { list-style: none; margin: 0; padding: 0;}
.productList { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem;}
.viewAll { display: block; margin-top: 0.75rem; padding: 0.5rem; text-align: center; font-size: 0.875rem; color: var(--color-primary); text-decoration: none; border-radius: var(--radius-sm); transition: background-color 0.15s ease;}
.viewAll:hover { background-color: var(--color-surface);}
/* Error state */.error { text-align: center; padding: 2rem 1rem; color: var(--color-error);}
/* No results state */.noResults { text-align: center; padding: 3rem 1rem;}
.noResultsTitle { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 500;}
.noResultsHint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted);}
/* Skeleton loading */.skeleton { display: flex; flex-direction: column; gap: 1.5rem;}
.skeletonSection { display: flex; flex-direction: column; gap: 0.5rem;}
.skeletonTitle { width: 80px; height: 12px; background-color: var(--color-surface); border-radius: var(--radius-sm);}
.skeletonItem { width: 100%; height: 40px; background: linear-gradient(90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
.skeletonProduct { width: 100%; height: 80px; background: linear-gradient(90deg, var(--color-surface) 25%, var(--color-border) 50%, var(--color-surface) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm);}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; }}Key Takeaways
- Debouncing: Delay API calls until user stops typing (300ms typical)
- AbortController: Cancel stale requests when query changes
- Loading states: Show skeleton first, loading bar for subsequent searches
- Error handling: Display user-friendly error messages
- No results: Provide helpful suggestions when no matches found
- Type safety: Define types for all search response data
- Recent searches: Save successful searches for quick access
In the next lesson, we’ll build the individual result components for products, collections, and pages.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...