Create a graphical illustration of react redux and wordpress inter connected
, ,

Scaling WooCommerce Product Selection in Blocks: using Custom REST API endpoints

Building intuitive product layout editors for WooCommerce presents unique challenges, especially when dealing with large product catalogs. While creating a plugin that deals with handpicking WooCommerce products, I have encountered a following problem: how to efficiently load and search through thousands of products without degrading the editor experience. This issue will probably face a user owning (or maintaining) a mid to larger-scale e-commerce site. This technical deep-dive explores two approaches to product data fetching in WordPress block editor, both based on REST API, examining their architecture, performance characteristics, and real-world implementation strategies.

The Challenge: Performance at Scale

When developing custom block plugin for WooCommerce, I quickly encountered a fundamental problem: how to provide an intuitive product selection interface that remains responsive even with, say, 10,000+ products?

Traditional approaches might fail in a following ways:

  • Loading all products at once – freezing the editor
  • Client-side filtering struggling with large datasets
  • Repeated API calls creating network bottlenecks
  • Poor, or no caching, and wasting resources

The solution used in my Mosaic Product Layouts combines two complementary approaches: authenticated backend REST API and unauthenticated WooCommerce Store API for the frontend.

Architecture Overview: Two Complementary Approaches

WooCommerce product selection systems can leverage two distinct API strategies:

1. Custom Backend REST API (Editor Context)

Purpose: Authenticated product selection during block editing
Authentication: Required (edit_posts capability)
Use Case: Admin/editor searching and selecting products
Data Returned: Minimal (ID, title, thumbnail ID)

2. WooCommerce Store API (Frontend Context)

Purpose: Unauthenticated product display on frontend
Authentication: None (public endpoint)
Use Case: Rendering selected products to visitors
Data Returned: Comprehensive (price, images, cart actions)

Building a Custom REST API Endpoint

Let’s examine how to build a lightweight, scalable REST API specifically optimized for product selection in the block editor. This solution is used in the Mosaic Product Layouts.

Endpoint Registration

<?php
namespace Micemade\MosaicProductLayouts;

class RestAPI {
    private const NAMESPACE = 'mosaic-layouts/v1';
    private const DEFAULT_PER_PAGE = 50;

    public function init(): void {
        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
    }

    public function register_routes(): void {
        // Products endpoint
        register_rest_route(
            self::NAMESPACE,
            '/products',
            array(
                'methods'             => 'GET',
                'callback'            => array( $this, 'get_products' ),
                'permission_callback' => array( $this, 'check_permission' ),
                'args'                => $this->get_collection_params(),
            )
        );
    }

    public function check_permission(): bool {
        return current_user_can( 'edit_posts' );
    }
}

Key Architectural Decisions

1. Authentication Required

public function check_permission(): bool {
    return current_user_can( 'edit_posts' );
}

This ensures the endpoint is only accessible to authenticated users who can edit posts, preventing unauthorized access while maintaining security. When the editor invokes this endpoint via apiFetch, include the WordPress REST nonce (e.g., window.wpApiSettings.nonce or the localized window.mosaicProductLayouts?.nonce) so requests pass capability checks.

2. Minimal Data Transfer

public function get_products( WP_REST_Request $request ) {
    $search   = $request->get_param( 'search' );
    $per_page = $request->get_param( 'per_page' ) ?? self::DEFAULT_PER_PAGE;

    $args = array(
        'limit'   => $per_page,
        'status'  => 'publish',
        'orderby' => 'date',
        'order'   => 'DESC',
        'return'  => 'ids',
    );

    if ( ! empty( $search ) ) {
        $args['s'] = $search;
    }

    $product_ids = wc_get_products( $args );

    $products = array_map(
        function ( int $id ): array {
            return array(
                'value'   => $id,
                'label'   => get_the_title( $id ),
                'mediaId' => get_post_thumbnail_id( $id ) ?: 0,
            );
        },
        $product_ids
    );

    return rest_ensure_response( $products );
}

Why minimal data?

  • Reduces payload size by ~90% compared to full product objects
  • Speeds up initial load from seconds to milliseconds
  • Enables efficient client-side caching
  • Thumbnail IDs are fetched separately and cached in Redux

3. Built-in Search Support
The endpoint accepts a search parameter that leverages WooCommerce’s native product search:

if ( ! empty( $search ) ) {
    $args['s'] = $search;
}

This searches across:

  • Product titles
  • Product descriptions

By default wc_get_products does not search SKUs unless you add a meta_query targeting _sku. If SKU matching is required, extend the endpoint with a meta query or delegate to a dedicated search index (e.g., Elasticsearch) so expectations remain clear.

React Implementation: Async Select Hook

The power of this REST API becomes apparent when paired with an optimized React implementation. Here’s the custom hook that manages async product selection:

useAsyncSelect Hook

import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

const DEBOUNCE_DELAY = 300;
const MIN_SEARCH_LENGTH = 3;

export const useAsyncSelect = ( endpoint, options = {} ) => {
    const { perPage = 50 } = options;

    const [ defaultOptions, setDefaultOptions ] = useState( [] );
    const [ isInitialLoading, setIsInitialLoading ] = useState( true );
    const debounceTimerRef = useRef( null );

    // Fetch initial 50 products on mount
    useEffect( () => {
        const fetchInitialOptions = async () => {
            try {
                const response = await apiFetch( {
                    path: `${ endpoint }?per_page=${ perPage }`,
                } );
                setDefaultOptions( response );
            } catch ( err ) {
                console.error( 'Failed to fetch initial options:', err );
                setDefaultOptions( [] );
            } finally {
                setIsInitialLoading( false );
            }
        };

        fetchInitialOptions();

        return () => {
            if ( debounceTimerRef.current ) {
                clearTimeout( debounceTimerRef.current );
            }
        };
    }, [ endpoint, perPage ] );

    // Debounced async search
    const loadOptions = useCallback(
        ( inputValue ) => {
            return new Promise( ( resolve ) => {
                if ( debounceTimerRef.current ) {
                    clearTimeout( debounceTimerRef.current );
                }

                // Return default options if search too short
                if ( ! inputValue || inputValue.length < MIN_SEARCH_LENGTH ) {
                    resolve( defaultOptions );
                    return;
                }

                // Debounce search requests
                debounceTimerRef.current = setTimeout( async () => {
                    try {
                        const response = await apiFetch( {
                            path: `${ endpoint }?search=${ encodeURIComponent( inputValue ) }&per_page=${ perPage }`,
                        } );
                        resolve( response );
                    } catch ( err ) {
                        console.error( 'Failed to search:', err );
                        resolve( [] );
                    }
                }, DEBOUNCE_DELAY );
            } );
        },
        [ endpoint, perPage, defaultOptions ]
    );

    return {
        defaultOptions,
        loadOptions,
        isInitialLoading,
    };
};

Key Performance Optimizations

1. Debouncing (300ms)

debounceTimerRef.current = setTimeout( async () => {
    // Execute search
}, DEBOUNCE_DELAY );

Prevents excessive API calls while user is typing. A 300ms delay balances responsiveness with server load.

2. Minimum Search Length (3 characters)

if ( ! inputValue || inputValue.length < MIN_SEARCH_LENGTH ) {
    resolve( defaultOptions );
    return;
}

Avoids broad searches that return too many results and strain the server.

3. Initial Load Strategy

useEffect( () => {
    const fetchInitialOptions = async () => {
        const response = await apiFetch( {
            path: `${ endpoint }?per_page=${ perPage }`,
        } );
        setDefaultOptions( response );
    };
    fetchInitialOptions();
}, [ endpoint, perPage ] );

Loads 50 recent products immediately, providing instant options without requiring a search.

Both micemade-products-grid and micemade-product blocks import this hook inside their inspector controls, so every layout shares the exact same selection UX and cached data source regardless of block complexity.

Enhanced Hook: Media Handling with Redux Store

For optimal performance, product thumbnails are fetched separately and cached in a Redux store:

export const useProductsData = () => {
    const { setMediaItems } = useDispatch('micemade/data');

    const {
        defaultOptions,
        loadOptions,
        isInitialLoading,
    } = useAsyncSelect('/mosaic-layouts/v1/products');

    // Batch fetch media items for products
    const fetchMediaForProducts = useCallback(
        async (products) => {
            if (!products || products.length === 0) return;

            // Collect unique media IDs
            const mediaIds = [
                ...new Set(
                    products.map((p) => p.mediaId).filter((id) => id)
                ),
            ];

            if (mediaIds.length > 0) {
                try {
                    const pageSize = Math.min(mediaIds.length, 100);
                    const mediaItems = await apiFetch({
                        path: `/wp/v2/media?include=${mediaIds.join(',')}&per_page=${pageSize}`,
                    });

                    // Convert to object with ID as key
                    const mediaItemsMap = mediaItems.reduce((acc, item) => {
                        acc[item.id] = item;
                        return acc;
                    }, {});

                    setMediaItems(mediaItemsMap);
                } catch (error) {
                    console.error('Failed to fetch media items:', error);
                }
            }
        },
        [setMediaItems]
    );

    // Auto-fetch media for default options
    useEffect(() => {
        if (defaultOptions && defaultOptions.length > 0) {
            fetchMediaForProducts(defaultOptions);
        }
    }, [defaultOptions, fetchMediaForProducts]);

    return {
        defaultOptions,
        loadOptions,
        isLoading: isInitialLoading,
        fetchMediaForProducts,
    };
};

⚠️ The /wp/v2/media endpoint honors REST pagination limits (default per_page=10, maximum per_page=100). When more than 100 thumbnails are missing you should chunk the ID list into batches or rely on parallel requests to avoid truncated responses.

Why Separate Media Fetching?

Performance Benefits:

  • Initial product list loads in ~100ms (no media data)
  • Media fetches in parallel using WordPress Media API
  • Redux caching prevents redundant requests
  • Thumbnails appear progressively, not blocking UI

Technical Advantages:

  • Leverages WordPress’s native media optimization
  • Supports responsive image sizes
  • Enables lazy loading strategies
  • Reduces custom endpoint complexity

Comparison: Custom REST API vs WooCommerce Store API

Now let’s compare this approach with the WooCommerce Store API, which serves a different but complementary purpose.

WooCommerce Store API Example

const getProduct = (productId) => {
    const [product, setProduct] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        if (!productId) return;

        async function fetchProduct() {
            try {
                const product = await apiFetch({
                    path: `/wc/store/v1/products/${productId}?_fields=id,name,short_description,price_html,images,permalink,add_to_cart,type`,
                });
                setProduct(product);
                setLoading(false);
            } catch (error) {
                console.error("WC Store API error:", error);
            }
        }
        fetchProduct();
    }, [productId]);

    return { product, loading };
};

Architectural Comparison Table

FeatureCustom REST APIWooCommerce Store API
AuthenticationRequired (edit_posts)None (public)
Primary UseProduct selection in editorFrontend product display
Data VolumeMinimal (50-200 bytes/product)Comprehensive (2-5KB/product)
Search SupportBuilt-in with debouncingAvailable but heavier
Caching StrategyClient-side (Redux)Browser/CDN caching
PerformanceOptimized for selectionOptimized for display
SecurityPermission-checkedPublic (read-only)
Typical Response Time50-150ms200-500ms
Best ForEditor interfacesCustomer-facing pages

When to Use Each Approach

Use Custom REST API when:

  • Building admin/editor interfaces
  • Implementing product pickers/selectors
  • Need fast, lightweight product lists
  • Working with authenticated users
  • Require custom search logic

Use WooCommerce Store API when:

  • Displaying products to visitors
  • Building customer-facing features
  • Need complete product information
  • Implementing add-to-cart functionality
  • Creating product galleries/grids

Real-World Implementation: React-Select Integration

Here’s how these APIs integrate with react-select for a seamless UX:

import AsyncSelect from 'react-select/async';
import { useProductsData } from './hooks/useProductsData';

function ProductSelector({ onProductSelect }) {
    const {
        defaultOptions,
        loadOptions,
        isLoading,
    } = useProductsData();

    return (
        <AsyncSelect
            defaultOptions={defaultOptions}
            loadOptions={loadOptions}
            isLoading={isLoading}
            onChange={onProductSelect}
            placeholder="Select or type to search products..."
            noOptionsMessage={({ inputValue }) => {
                if (!inputValue || inputValue.length < 2) {
                    return 'Type to search...';
                }
                return 'No products found';
            }}
            formatOptionLabel={(option) => (
                <div style={{ display: 'flex', alignItems: 'center' }}>
                    <FeaturedThumbSrc mediaId={option.mediaId} />
                    <span style={{ marginLeft: '10px' }}>{option.label}</span>
                </div>
            )}
            cacheOptions={false}
        />
    );
}

UX Benefits

Instant Feedback:

  • 50 recent products appear immediately
  • No loading spinner for initial render
  • Progressive enhancement with thumbnails

Smart Search:

  • 300ms debounce feels instantaneous
  • 3-character minimum prevents accidental searches
  • Clear messaging guides user behavior

Performance:

  • Network requests minimized
  • Redux caching eliminates duplicate fetches
  • Smooth navigating through thousands of products

Advanced Pattern: Filtering Selected Products

A common requirement is preventing already-selected products from appearing in search results:

const {
    defaultOptions: productDefaultOptions,
    loadOptions: loadProductOptionsRaw,
} = useProductsData();

// Filter out already selected products
const productOptions = useMemo(() => {
    return productDefaultOptions?.filter(item =>
        !handpickedWcItems.some(filter => filter.value === item.value)
    ) || [];
}, [productDefaultOptions, handpickedWcItems]);

// Wrap loadOptions to filter search results
const loadProductOptions = useCallback(
    async (inputValue) => {
        const results = await loadProductOptionsRaw(inputValue);
        return results?.filter(item =>
            !handpickedWcItems.some(filter => filter.value === item.value)
        ) || [];
    },
    [loadProductOptionsRaw, handpickedWcItems]
);

This pattern:

  • Prevents duplicate selections
  • Maintains performance (client-side filtering)
  • Updates dynamically as selections change
  • Provides clear UX (unavailable options hidden)

Performance Metrics: Real-World Results

Based on internal testing against mid-sized client stores (≈5,000 published products, ~1,200 with thumbnails):

Custom REST API (Editor)

  • Initial Load: 120ms (50 products)
  • Search Query: 180ms average
  • Memory Usage: 2-3MB (client-side)
  • Network Transfer: 8KB initial, 5-15KB per search

WooCommerce Store API (Frontend)

  • Single Product: 250ms average
  • Multiple Products: 400-800ms (depends on quantity)
  • Memory Usage: 5-8MB (with full product data)
  • Network Transfer: 3-5KB per product

Comparison to Naive Approach

Loading all 5,000 products at once:

  • Load Time: 8-12 seconds
  • Memory Usage: 40-60MB
  • Network Transfer: 2-3MB
  • Editor Performance: Unusable (frozen UI)

The custom REST API represents a 96% reduction in initial load time and an 85% reduction in memory usage.

Best Practices and Recommendations

When Building WooCommerce Solutions

1. Hybrid Approach

  • Use custom REST API for admin interfaces
  • Use Store API for frontend rendering
  • Cache aggressively at both layers

2. Progressive Enhancement

// Load minimal data first
const products = await fetchProductIds();

// Fetch full data as needed
const fullProducts = await fetchFullProductData(selectedIds);

3. Optimize for Scale

  • Implement server-side pagination
  • Use database indexes on product titles
  • Consider Elasticsearch for large catalogs (10,000+ products)

4. Monitor Performance

const start = performance.now();
const products = await apiFetch({ path: endpoint });
console.log(`Fetch took ${performance.now() - start}ms`);

Security Considerations

Custom REST API:

public function check_permission(): bool {
    // Only editors and admins
    return current_user_can( 'edit_posts' );
}

Add a WordPress nonce header (e.g., X-WP-Nonce) to every editor-side apiFetch call so the REST API can verify intent even if someone tries to replay a request.

Frontend Display:

// No authentication needed - public data
const product = await apiFetch({
    path: `/wc/store/v1/products/${productId}`,
});

Error Handling

try {
    const response = await apiFetch({ path: endpoint });
    return response;
} catch (error) {
    if (error.code === 'rest_forbidden') {
        // User lacks permission
        console.error('Authentication required');
    } else if (error.code === 'woocommerce_not_active') {
        console.error('WooCommerce required');
    }
    return [];
}

Surface those errors to the editor UI so site builders understand what happened:

import { useDispatch } from '@wordpress/data';
const { createNotice } = useDispatch('core/notices');

createNotice('error', __('Product search failed. Check your connection or license.', 'mosaic-product-layouts'));

Conclusion

Building efficient product selection interfaces for WooCommerce requires understanding the architectural trade-offs between custom REST APIs and existing WooCommerce endpoints. The hybrid approach outlined here provides:

  • Fast initial loads with minimal data transfer
  • Smooth search with debouncing and async loading
  • Scalability to thousands of products
  • Clean architecture separating editor and frontend concerns

For agencies working with mid-sized e-commerce clients, this architecture pattern offers a production-ready solution that balances performance, maintainability, and user experience. The custom REST API handles authenticated product selection with minimal overhead, while the WooCommerce Store API provides comprehensive product data for frontend rendering.

By leveraging WordPress’s native REST infrastructure, Redux for state management, and React’s async patterns, you can build product layout editors that feel instantaneous even with massive product catalogs.


Further Reading


Post tagged with:

Comments

Leave a Reply

Discover more from Micemade

Subscribe now to keep reading and get access to the full archive.

Continue reading