Elementor: AJAX item picker for tags & widgets (large datasets)
When a Dynamic Tag or widget setting must point at one specific record out of many — "this product", "that landing page", "this author" — you need a searchable picker. On a small set a preloaded SELECT2 is fine. On a 20k-product store it is a trap: Elementor renders every option into the panel on load and the editor hangs. This skill is the AJAX-search alternative and how to degrade it when Elementor Pro is absent.
The misconception (and why the editor freezes)
"I'll list the products in a
SELECT2so the user can search them."
// ANTI-PATTERN at scale — every product becomes a preloaded <option>
$options = [];
foreach ( wc_get_products( [ 'limit' => -1 ] ) as $p ) {
$options[ $p->get_id() ] = $p->get_name(); // <-- 20k entries in the panel
}
$this->add_control( 'product_id', [
'type' => \Elementor\Controls_Manager::SELECT2,
'options' => $options,
] );
A preloaded SELECT2 ships all options to the editor up front. That is exactly what the reference plugin's ProductAttributes tag does — but only because attribute taxonomies are a handful (ProductAttributes.php:62-74). The same shape over products/posts is what locks the panel. Preloaded SELECT2 is correct only for small, bounded option sets (a dozen statuses, a few taxonomies).
The fix — Elementor Pro's AJAX query control
The query control is a SELECT2 whose options are fetched on demand, by search term, over AJAX. Catalog size is irrelevant because nothing is queried until the user types.
use ElementorPro\Modules\QueryControl\Module as QueryControlModule;
$this->add_control( 'product_id', [
'label' => esc_html__( 'Product', 'myplugin' ),
'type' => QueryControlModule::QUERY_CONTROL_ID, // 'query'
'options' => [], // empty — filled by AJAX
'label_block' => true,
'autocomplete' => [
'object' => QueryControlModule::QUERY_OBJECT_POST, // what to search
'query' => [ 'post_type' => 'product' ], // scope (search term is added server-side)
'display' => 'minimal', // or 'detailed'
],
] );
Verified contract:
QUERY_CONTROL_ID = 'query'; the control classQuery extends Control_Select2(controls/query.php:12-16).'autocomplete'['object']is one of (module.php:34-39):QUERY_OBJECT_POST('post'),QUERY_OBJECT_TAX('tax'),QUERY_OBJECT_AUTHOR('author'— users who authored content),QUERY_OBJECT_USER('user'— all users),QUERY_OBJECT_ATTACHMENT('attachment'),QUERY_OBJECT_LIBRARY_TEMPLATE.'query'is merged into the WP query scope;'display'is'minimal'or'detailed';'by_field' => 'ID'stores the chosen post ID.- The server-side AJAX handler is entirely Pro's — registered on
elementor/ajax/register_actions(pro_panel_posts_control_filter_autocomplete,query_control_value_titles). Your code writes nowp_ajax_handler; you only declare theautocompleteconfig and Pro does the search and the saved-value label resolution.
Why it doesn't freeze (verified)
The autocomplete handler returns early with a WP_Error when the search term is empty (module.php:211) — so nothing runs until the user types. When they do, autocomplete_query_for_post() sets $query['s'] = $data['q'] (module.php:242); the 'posts_per_page' => -1 alongside it (module.php:241) is harmless because the s search term bounds the result set. This is the inverse of the preloaded SELECT2: the query is small and on-demand, not large and upfront.
Using it inside a Dynamic Tag
The query control works in a Tag/Data_Tag exactly as in a widget — store the chosen ID in a setting and resolve it in render() / get_value(). Pro's Internal_URL data tag is the canonical example: a type selector plus per-type query controls (post_id, taxonomy_id, attachment_id, author_id), each with its own autocomplete['object'] and a condition (internal-url.php:72-142):
$this->add_control( 'post_id', [
'label' => esc_html__( 'Search & Select', 'myplugin' ),
'type' => QueryModule::QUERY_CONTROL_ID,
'options' => [],
'label_block' => true,
'autocomplete' => [
'object' => QueryModule::QUERY_OBJECT_POST,
'display' => 'detailed',
'query' => [ 'post_type' => 'any' ],
],
'condition' => [ 'type' => 'post' ],
] );
// …then resolve in get_value()/render():
$url = get_permalink( (int) $this->get_settings( 'post_id' ) );
Note: in the bundled reference plugin, no dynamic tag uses the AJAX query control — every SELECT2 tag (ProductAttributes, membership-plan tags) preloads a small, bounded set. The AJAX pattern there lives in the DynamicAddToCartWidget. For a tag, Pro's Internal_URL is the reference.
Graceful degradation when Pro is absent (required)
QueryControlModule only exists with Pro. Feature-detect and fall back to a manual ID field — the exact pattern in DynamicAddToCartWidget:
use Elementor\Controls_Manager;
use ElementorPro\Modules\QueryControl\Module as QueryControlModule;
private function has_query_control_support(): bool {
return class_exists( QueryControlModule::class ); // Pro present?
}
private function product_control_type(): string {
return $this->has_query_control_support()
? QueryControlModule::QUERY_CONTROL_ID
: Controls_Manager::TEXT; // manual ID entry
}
// …building the control:
$control = [ 'label' => esc_html__( 'Product', 'myplugin' ), 'type' => $this->product_control_type() ];
if ( $this->has_query_control_support() ) {
$control['autocomplete'] = [
'object' => QueryControlModule::QUERY_OBJECT_POST,
'query' => [ 'post_type' => 'product' ],
'display' => 'minimal',
'by_field' => 'ID',
];
} else {
$control['description'] = esc_html__( 'Enter the product ID manually. Activate Elementor Pro for the search picker.', 'myplugin' );
}
$this->add_control( 'product_id', $control );
Verified at DynamicAddToCartWidget.php:43-53,181-198. Either way the stored value is a post ID, so render()/get_value() resolves it identically regardless of which control produced it.
Critical rules
- Never preload a
SELECT2from an unbounded query (products/posts/users). Preloaded options are for small, fixed sets only. - Use
QUERY_CONTROL_ID+autocompletefor large datasets. It is search-scoped server-side; size doesn't matter. - The query control is Pro-only. Always
class_exists( QueryControlModule::class )and degrade to aControls_Manager::TEXTmanual-ID field (or a deliberately boundedSELECT2). - Write no AJAX handler. Pro owns
pro_panel_posts_control_filter_autocomplete/query_control_value_titles; you only declareautocomplete. 'options' => []for a query control — options arrive via AJAX; preloading defeats the purpose.- Pick the right
object:author= users who authored content,user= all users;postneeds aquery.post_typescope;taxsearches terms. - The stored value is an ID — cast and resolve it (
get_permalink( (int) $id ),wc_get_product( (int) $id )) and handle a missing/invalid ID.
Common mistakes
// WRONG — query control but options preloaded (pointless + slow)
$this->add_control( 'id', [
'type' => QueryControlModule::QUERY_CONTROL_ID,
'options' => $all_products, // <-- defeats the AJAX control
] );
// WRONG — hard-requiring Pro; control silently missing on free → no way to set a product
$this->add_control( 'id', [ 'type' => QueryControlModule::QUERY_CONTROL_ID, /* … */ ] );
// (no class_exists guard → fatal/empty when Pro inactive)
// WRONG — writing your own ajax handler for it
add_action( 'wp_ajax_my_product_search', /* … */ ); // <-- unnecessary; Pro handles query control AJAX
// RIGHT — feature-detect, degrade, let Pro do the AJAX
$type = class_exists( QueryControlModule::class )
? QueryControlModule::QUERY_CONTROL_ID
: \Elementor\Controls_Manager::TEXT;
Cross-references
- Run
elementor-dynamic-tag-fieldsfor the control types,TagvsData_Tag, and reading settings back. - Run
elementor-dynamic-tag-registerfor registering the tag and the Pro-feature reality. - Run
wc-product-search-selectfor the WooCommerce-native product search/select control (a non-Elementor alternative for product pickers).
What this skill does NOT cover
- The query control's full option surface (custom
queryargs per object type,include_type, sorting) — read module.phpautocomplete_query_for_*. - Building a bespoke AJAX select without Pro — possible via a custom control +
wp_ajax_, but out of scope; prefer the manual-ID degrade. - Query-control filtering for loop/posts widgets (the
Group_Controlquery side) — this skill is about single-item pickers. - Caching/transients for the search results — Pro's handler runs uncached per keystroke; debounce/min-length is Pro's select2 default.
References
- Query control module: wp-content/plugins/elementor-pro/modules/query-control/module.php —
QUERY_CONTROL_ID/QUERY_OBJECT_*(29,34-39), empty-term early return (211),autocomplete_query_for_postsetss(233-242), AJAX action registration (~1012-1024). - Query control class: wp-content/plugins/elementor-pro/modules/query-control/controls/query.php:12-16 —
class Query extends Control_Select2. - AJAX query control in a dynamic tag: wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/internal-url.php:72-142.
- Graceful-degradation widget: wp-content/plugins/dynamic-elementor-extension-main/modules/widgets/dynamic/DynamicAddToCartWidget.php:43-53,181-198.
- Bounded preloaded SELECT2 (correct small-set use): wp-content/plugins/dynamic-elementor-extension-main/dynamic-tags/woo-tags/ProductAttributes.php:62-74.
- Official documentation: https://developers.elementor.com/docs/dynamic-tags/