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)
While both strategies are used in Mosaic Product Layouts (MPL) plugin, the rest of this article will focus primarily on Custom REST API endpoint and React implementation in the block editor.
The Store API is used in MPL for rendering products in editor and on frontend, and I will deep-dive into that strategy in one of upcoming articles.
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/mediaendpoint honors REST pagination limits (defaultper_page=10, maximumper_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
| Feature | Custom REST API | WooCommerce Store API |
|---|---|---|
| Authentication | Required (edit_posts) | None (public) |
| Primary Use | Product selection in editor | Frontend product display |
| Data Volume | Minimal (50-200 bytes/product) | Comprehensive (2-5KB/product) |
| Search Support | Built-in with debouncing | Available but heavier |
| Caching Strategy | Client-side (Redux) | Browser/CDN caching |
| Performance | Optimized for selection | Optimized for display |
| Security | Permission-checked | Public (read-only) |
| Typical Response Time | 50-150ms | 200-500ms |
| Best For | Editor interfaces | Customer-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.





Leave a Reply