Questo articolo è stato pubblicato per la prima volta nel 2005 e completamente riscritto a ottobre 2026: i commenti più vecchi si riferiscono alla versione originale.
Nel novembre del 2005 raccontavo i miei esperimenti con Ajax per riempire una select partendo dal valore di un’altra: il codice viveva dentro delle textarea in sola lettura, le richieste passavano da XHConn, una piccola libreria che incapsulava XMLHttpRequest, il JSON veniva interpretato con un eval() che oggi farebbe svenire qualsiasi revisore e il tutto era testato su Firefox, Internet Explorer e Opera. Rileggendolo con calma ho scoperto anche che il ciclo for conteneva un’assegnazione al posto di un confronto, e il fatto che questo resti l’articolo storico con più visite da Google la dice lunga su quanto il problema delle select dipendenti sia eterno.
Il problema, infatti, è lo stesso di vent’anni fa: l’utente sceglie una regione e la seconda select deve proporre soltanto le province di quella regione, senza ricaricare la pagina. Gli strumenti, invece, sono cambiati del tutto, e in questo articolo ti mostro come risolverlo oggi in WordPress con un endpoint della REST API registrato con register_rest_route, fetch con async/await, qualche accortezza di sicurezza e un’attenzione all’accessibilità che nel 2005, lo confesso, non sapevo nemmeno di dover avere.
Come funzionano le select dipendenti
Due select si dicono dipendenti quando le opzioni della seconda cambiano in base al valore scelto nella prima: quando l’utente seleziona una regione, uno script intercetta l’evento change, chiede al server l’elenco delle province, riceve una risposta in JSON e ricostruisce le opzioni della seconda select. Il flusso è sempre questo, che tu stia scegliendo regione e provincia, marca e modello o categoria e prodotto.

Rispetto al 2005 cambia il modo in cui organizziamo i pezzi. Lato server, in WordPress, la scelta più pulita è un endpoint della REST API: ha un URL proprio, valida i parametri in modo dichiarativo, restituisce JSON senza che tu debba preoccuparti degli header e si integra con il sistema di autenticazione del core, mentre il vecchio admin-ajax.php resta utilizzabile ma richiede più codice per ottenere lo stesso risultato. Lato browser fetch ha sostituito XMLHttpRequest e le librerie che lo incapsulavano, e async/await rende il codice leggibile come una sequenza di passi.
L’endpoint REST in WordPress
Per creare l’endpoint si registra una rotta con register_rest_route() all’interno dell’hook rest_api_init, indicando metodo, callback, argomenti accettati e una permission_callback, che dalla versione 5.5 di WordPress va sempre dichiarata. Ecco un plugin completo, in un unico file, che registra l’endpoint /wp-json/mm/v1/province, carica lo script solo dove serve e mette a disposizione lo shortcode [select_dipendenti] per stampare il modulo.
<?php
/**
* Plugin Name: Select dipendenti regione e provincia
* Description: Esempio di select dipendenti con la REST API di WordPress e fetch().
* Version: 1.0.0
* Requires at least: 6.3
* Requires PHP: 7.4
*/
// Impedisce l'accesso diretto al file.
defined( 'ABSPATH' ) || exit;
/**
* Dati di esempio: in un progetto reale potrebbero arrivare da una tassonomia,
* da una tabella personalizzata o da un file JSON.
*/
function mm_select_dati() {
return array(
'liguria' => array(
'label' => 'Liguria',
'province' => array( 'GE' => 'Genova', 'IM' => 'Imperia', 'SP' => 'La Spezia', 'SV' => 'Savona' ),
),
'piemonte' => array(
'label' => 'Piemonte',
'province' => array(
'AL' => 'Alessandria', 'AT' => 'Asti', 'BI' => 'Biella', 'CN' => 'Cuneo',
'NO' => 'Novara', 'TO' => 'Torino', 'VB' => 'Verbano-Cusio-Ossola', 'VC' => 'Vercelli',
),
),
'valle-d-aosta' => array(
'label' => "Valle d'Aosta",
'province' => array( 'AO' => 'Aosta' ),
),
);
}
// 1. Registra l'endpoint GET /wp-json/mm/v1/province?regione=piemonte
add_action( 'rest_api_init', function () {
register_rest_route(
'mm/v1',
'/province',
array(
'methods' => WP_REST_Server::READABLE, // Solo GET.
'callback' => 'mm_select_rest_province',
// Dati pubblici: chiunque può leggerli, ma va dichiarato in modo esplicito.
'permission_callback' => '__return_true',
'args' => array(
'regione' => array(
'type' => 'string',
'required' => true,
// Accetta solo le regioni previste: ogni altro valore riceve un errore 400.
'enum' => array_keys( mm_select_dati() ),
'validate_callback' => 'rest_validate_request_arg',
),
),
)
);
} );
// 2. Callback dell'endpoint: restituisce un array di oggetti { value, label }.
function mm_select_rest_province( WP_REST_Request $request ) {
$dati = mm_select_dati();
$province = $dati[ $request->get_param( 'regione' ) ]['province'];
$risposta = array();
foreach ( $province as $sigla => $nome ) {
$risposta[] = array(
'value' => $sigla,
'label' => $nome,
);
}
// WordPress si occupa di serializzare la risposta in JSON con gli header corretti.
return rest_ensure_response( $risposta );
}
// 3. Registra lo script: verrà accodato solo dalle pagine che usano lo shortcode.
add_action( 'wp_enqueue_scripts', function () {
wp_register_script(
'mm-select-dipendenti',
plugins_url( 'select-dipendenti.js', __FILE__ ),
array(),
'1.0.0',
array(
'strategy' => 'defer',
'in_footer' => true,
)
);
// Passa al JavaScript l'URL dell'endpoint, corretto con qualsiasi impostazione dei permalink.
wp_localize_script(
'mm-select-dipendenti',
'mmSelect',
array( 'endpoint' => esc_url_raw( rest_url( 'mm/v1/province' ) ) )
);
} );
// 4. Shortcode [select_dipendenti]: stampa il modulo e accoda lo script.
add_shortcode( 'select_dipendenti', function () {
wp_enqueue_script( 'mm-select-dipendenti' );
ob_start();
?>
<div class="mm-select-dipendenti">
<label for="mm-regione">Regione</label>
<select id="mm-regione" name="regione">
<option value="">Scegli una regione</option>
<?php foreach ( mm_select_dati() as $slug => $regione ) : ?>
<option value="<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $regione['label'] ); ?></option>
<?php endforeach; ?>
</select>
<label for="mm-provincia">Provincia</label>
<select id="mm-provincia" name="provincia" disabled>
<option value="">Prima scegli una regione</option>
</select>
<!-- Messaggi di stato letti dagli screen reader senza spostare il focus. -->
<p id="mm-stato" class="screen-reader-text" role="status" aria-live="polite"></p>
</div>
<?php
return ob_get_clean();
} );Due dettagli meritano una sottolineatura. Il primo è l’argomento regione, dichiarato con type, enum e rest_validate_request_arg: è WordPress a rifiutare qualsiasi valore non previsto con un errore 400, prima ancora che la tua callback venga eseguita, per cui non devi scrivere controlli a mano. Il secondo è rest_url(), che genera l’indirizzo corretto anche sui siti che non usano i permalink «belli» o che sono installati in una sottocartella: scrivere /wp-json/ a mano nel JavaScript è uno di quei piccoli errori che funzionano sempre, fino al giorno in cui smettono di funzionare.
Il JavaScript con fetch e async/await
Lato browser bastano una funzione asincrona che chiama l’endpoint con fetch, un controllo sullo stato della risposta e un AbortController che annulla la richiesta precedente se l’utente cambia regione prima che arrivi la risposta. Salva questo codice nel file select-dipendenti.js, nella stessa cartella del plugin.
/**
* select-dipendenti.js
* Quando cambia la regione, chiede le province all'endpoint REST
* e ricostruisce le opzioni della seconda select.
*/
( () => {
const regione = document.getElementById( 'mm-regione' );
const provincia = document.getElementById( 'mm-provincia' );
const stato = document.getElementById( 'mm-stato' );
// Se il modulo non è nella pagina non c'è nulla da fare.
if ( ! regione || ! provincia || ! stato ) {
return;
}
// Richiesta in corso: serve per annullarla se l'utente cambia idea.
let controller = null;
// Sostituisce tutte le option della select provincia con quelle passate.
const impostaOpzioni = ( opzioni ) => {
provincia.replaceChildren(
...opzioni.map( ( { value, label } ) => new Option( label, value ) )
);
};
const caricaProvince = async ( codiceRegione ) => {
// Annulla l'eventuale richiesta precedente ancora in volo.
controller?.abort();
controller = new AbortController();
// Stato di caricamento: select disabilitata e messaggio per gli screen reader.
provincia.disabled = true;
impostaOpzioni( [ { value: '', label: 'Caricamento…' } ] );
stato.textContent = 'Caricamento delle province in corso.';
try {
// Aggiunge il parametro all'URL senza rompere eventuali query string esistenti.
const url = new URL( mmSelect.endpoint );
url.searchParams.set( 'regione', codiceRegione );
const risposta = await fetch( url, {
signal: controller.signal,
headers: { Accept: 'application/json' },
} );
// fetch() non considera errori le risposte 4xx e 5xx: va controllato a mano.
if ( ! risposta.ok ) {
throw new Error( `HTTP ${ risposta.status }` );
}
const province = await risposta.json();
impostaOpzioni( [ { value: '', label: 'Scegli una provincia' }, ...province ] );
provincia.disabled = false;
stato.textContent = `${ province.length } province disponibili.`;
} catch ( errore ) {
// Una richiesta annullata non è un errore: ne è già partita una nuova.
if ( errore.name === 'AbortError' ) {
return;
}
impostaOpzioni( [ { value: '', label: 'Province non disponibili' } ] );
stato.textContent = 'Non è stato possibile caricare le province. Riprova tra qualche istante.';
console.error( errore );
}
};
regione.addEventListener( 'change', () => {
if ( regione.value ) {
caricaProvince( regione.value );
return;
}
// Nessuna regione scelta: torna allo stato iniziale.
controller?.abort();
provincia.disabled = true;
impostaOpzioni( [ { value: '', label: 'Prima scegli una regione' } ] );
stato.textContent = '';
} );
} )();La cosa che mi diverte di più è che new Option( testo, valore ), il costruttore che usavo nel 2005, è ancora lì e funziona benissimo: quello che è cambiato è tutto il resto. Il JSON viene interpretato in modo sicuro da response.json() invece che da eval(), replaceChildren() sostituisce in un colpo solo tutte le opzioni senza i cicli acrobatici che descrivevo nell’aggiornamento del 2006, e il controllo su response.ok ricorda che fetch considera riuscita anche una risposta 404 o 500, un dettaglio che sorprende quasi tutti la prima volta.
Sicurezza: permission_callback e nonce
La permission_callback decide chi può usare l’endpoint, e va sempre dichiarata in modo esplicito: __return_true per i dati pubblici, come l’elenco delle province, oppure una funzione che controlla i permessi dell’utente quando i dati sono riservati o quando l’endpoint modifica qualcosa. Se la ometti WordPress registra comunque la rotta, ma segnala l’errore con un avviso, e soprattutto lasci a chi verrà dopo di te il dubbio se la scelta fosse voluta o una dimenticanza.
Quando l’endpoint dipende dall’utente collegato entra in gioco il nonce. Con l’autenticazione basata sui cookie, quella che usa il browser di chi ha fatto login, la REST API si aspetta un nonce creato con l’azione wp_rest e inviato nell’header X-WP-Nonce; se manca, la richiesta viene trattata come anonima anche se l’utente ha fatto login, e la tua permission_callback la rifiuterà.
// Variante protetta: solo gli utenti che possono modificare articoli.
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
// In wp_localize_script() aggiungi anche il nonce per la REST API.
'nonce' => wp_create_nonce( 'wp_rest' ),// Nel fetch(), l'header che permette a WordPress di riconoscere l'utente collegato.
const risposta = await fetch( url, {
signal: controller.signal,
headers: { Accept: 'application/json', 'X-WP-Nonce': mmSelect.nonce },
} );Il nonce non è una password e non sostituisce il controllo dei permessi: serve a dimostrare che la richiesta parte da una pagina del tuo sito, mentre a decidere chi può fare che cosa resta current_user_can(). Ricorda anche che un nonce ha una durata limitata, al massimo 24 ore, per cui le pagine servite da una cache molto lunga possono contenerne uno scaduto: per gli endpoint pubblici è un motivo in più per non usarlo affatto, per quelli protetti conviene escludere quelle pagine dalla cache.
Accessibilità: etichette, stati e messaggi
Una select dipendente è accessibile quando ogni campo ha un’etichetta collegata, quando lo stato di caricamento è chiaro anche a chi non vede lo schermo e quando cambiare un valore non provoca azioni inattese come l’invio del modulo o lo spostamento del focus. Nel codice dell’esempio queste attenzioni si traducono in pochi elementi concreti.
- Ogni
selectha unlabelcollegato con l’attributofor, che gli screen reader leggono e che allarga l’area cliccabile. - La seconda select resta disabilitata finché le province non sono arrivate, e la prima opzione spiega che cosa sta succedendo.
- Il paragrafo con
role="status"annuncia caricamento, risultato ed errori senza rubare il focus, e la classescreen-reader-textdei temi WordPress lo nasconde alla vista ma non alle tecnologie assistive. - L’evento
changeaggiorna solo la seconda select e non invia nulla: l’utente che naviga con la tastiera può scorrere le opzioni senza sorprese.
Se vuoi fare un passo in più, prevedi un’alternativa per chi ha JavaScript disattivato, per esempio un’unica select con le province raggruppate per regione tramite optgroup, che lo script sostituisce con le due select dipendenti al caricamento della pagina.
Tirando le somme
Vent’anni dopo le select dipendenti funzionano esattamente come allora, ma il codice è più corto, più sicuro e più gentile con chi lo usa: un endpoint REST registrato con register_rest_route, con validazione dichiarativa e una permission_callback esplicita, una funzione asincrona con fetch e AbortController, e qualche attributo ben scelto per l’accessibilità. Lo stesso schema si adatta a qualsiasi coppia di campi collegati, e ti consiglio di provarlo anche con dati che arrivano da una tassonomia, per vedere quanto poco cambia.
Per approfondire parti dal manuale della REST API su developer.wordpress.org, in particolare dalle pagine sugli endpoint personalizzati e sull’autenticazione, e dagli altri articoli della categoria WordPress; se ti piace l’idea di farti aiutare da un agente di coding per scrivere e testare codice come questo, dai un’occhiata alla mia guida a Claude Code. Tra gli articoli storici riscritti in questi giorni trovi anche quello sui popup trascinabili con dialog e Pointer Events, che segue la stessa filosofia. Raccontami nei commenti se usavi anche tu XHConn, o qualcosa di peggio, e condividi l’articolo con chi sta ancora scrivendo eval() per leggere un JSON. Se invece ti serve una mano su un progetto WordPress, puoi prenotare una call conoscitiva gratuita o passare dai contatti.


Eccezionale, semplice, efficace. Complimenti. 🙂
Funziona perfettamente su Firefox, ha problemi però su IE. 🙁
“text” è nullo o non è un getto.
Come potrei risolvere? Sigh…
Non funziona un piffero su IE come dice Luigi…
Ciao Fab,
ho aggiornato il post, verificato e corretto.
fai un prova con il codice che ho postato, adesso dovrebbe andare…
cià
FANTASTICO!
Funziona anche con doctype XHTML 1.1 content-type=”application/xml”