Introduzione alla State Machine
Nel corso dello sviluppo di applicazioni web, ci troviamo spesso a gestire componenti o processi che possono trovarsi in diversi "stati". Pensate a un carrello della spesa, a un modulo di pagamento multistep, o a un player video. Inizialmente, potremmo essere tentati di gestire questi stati utilizzando una serie di variabili booleane: isLoading, isError, isSuccess, isEditing.
Tuttavia, man mano che l'applicazione cresce, questo approccio porta a quello che viene chiamato "Boolean Hell". Se abbiamo tre booleani, abbiamo potenzialmente $2^3 = 8$ combinazioni di stati, molte delle quali potrebbero essere logicamente impossibili (ad esempio, essere contemporaneamente in stato di isLoading e isSuccess).
La State Machine (Macchina a Stati Finiti o FSM) è un pattern di design che risolve questo problema definendo esplicitamente gli stati possibili e le transizioni consentite tra di essi. Invece di chiedere "il modulo è caricato?", chiediamo "in quale stato si trova il modulo?".
Perché usare una State Machine?
L'obiettivo principale di una State Machine è rendere il comportamento del software prevedibile e deterministico. Quando implementiamo la logica di transizione attraverso un pattern strutturato, otteniamo diversi vantaggi:
- Eliminazione degli stati impossibili: Definendo un unico stato attivo, non rischiamo mai di trovarci in una configurazione contraddittoria.
- Facilità di debug: Sapere esattamente in quale stato si trova l'applicazione rende molto più semplice tracciare l'origine di un bug.
- Manutenibilità: Aggiungere un nuovo stato non richiede di rivedere ogni singolo
ifsparsi nel codice, ma consiste nell'aggiungere un nuovo caso alla logica di transizione. - Documentazione implicita: Il codice stesso diventa una mappa del flusso di business dell'applicazione.
Implementazione Base con lo Switch
Sebbene esistano librerie potenti come XState, per molte applicazioni web un'implementazione leggera basata su un oggetto di configurazione e l'istruzione switch è più che sufficiente e molto più performante in termini di bundle size.
L'idea è di separare la definizione degli stati dalla logica di transizione.
Definizione degli stati
Iniziamo definendo i nostri stati come costanti per evitare errori di battitura (typos) che potrebbero causare bug difficili da scovare.
const STATES = {
IDLE: 'IDLE',
LOADING: 'LOADING',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR'
};
const EVENTS = {
FETCH_DATA: 'FETCH_DATA',
RESOLVE: 'RESOLVE',
REJECT: 'REJECT',
RESET: 'RESET'
};
La logica di transizione
Ora implementiamo una funzione che riceve lo stato attuale e un evento, e restituisce il nuovo stato. Qui entra in gioco lo switch.
function transition(state, event) {
switch (state) {
case STATES.IDLE:
if (event === EVENTS.FETCH_DATA) return STATES.LOADING;
break;
case STATES.LOADING:
if (event === EVENTS.RESOLVE) return STATES.SUCCESS;
if (event === EVENTS.REJECT) return STATES.ERROR;
break;
case STATES.SUCCESS:
if (event === EVENTS.RESET) return STATES.IDLE;
break;
case STATES.ERROR:
if (event === EVENTS.FETCH_DATA) return STATES.LOADING;
if (event === EVENTS.RESET) return STATES.IDLE;
break;
}
// Se l'evento non è valido per lo stato corrente, rimaniamo nello stato attuale
return state;
}
In questo esempio, abbiamo creato un sistema dove non è possibile passare da IDLE a SUCCESS senza prima passare per LOADING. Questo vincolo è fondamentale per l'integrità del flusso.
Esempi Pratici: Un Sistema di Autenticazione
Immaginiamo di dover gestire l'interfaccia di login di un'applicazione. Gli stati potrebbero essere: LOGGED_OUT, AUTHENTICATING, LOGGED_IN, SESSION_EXPIRED.
Implementazione completa
Ecco come potremmo strutturare un gestore di stato per l'autenticazione:
const AuthStates = {
LOGGED_OUT: 'LOGGED_OUT',
AUTHENTICATING: 'AUTHENTICATING',
LOGGED_IN: 'LOGGED_IN',
SESSION_EXPIRED: 'SESSION_EXPIRED'
};
const AuthEvents = {
SUBMIT_FORM: 'SUBMIT_FORM',
AUTH_SUCCESS: 'AUTH_SUCCESS',
AUTH_FAILURE: 'AUTH_FAILURE',
LOGOUT: 'LOGOUT',
REFRESH_TOKEN: 'REFRESH_TOKEN'
};
class AuthStateMachine {
constructor() {
this.state = AuthStates.LOGGED_OUT;
}
send(event) {
const nextState = this.getNextState(this.state, event);
if (nextState !== this.state) {
console.log(`Transizione: ${this.state} -> ${nextState} via ${event}`);
this.state = nextState;
this.onStateChange(nextState);
}
}
getNextState(state, event) {
switch (state) {
case AuthStates.LOGGED_OUT:
switch (event) {
case AuthEvents.SUBMIT_FORM: return AuthStates.AUTHENTICATING;
default: return state;
}
case AuthStates.AUTHENTICATING:
switch (event) {
case AuthEvents.AUTH_SUCCESS: return AuthStates.LOGGED_IN;
case AuthEvents.AUTH_FAILURE: return AuthStates.LOGGED_OUT;
default: return state;
}
case AuthStates.LOGGED_IN:
switch (event) {
case AuthEvents.LOGOUT: return AuthStates.LOGGED_OUT;
case AuthEvents.REFRESH_TOKEN: return AuthStates.SESSION_EXPIRED;
default: return state;
}
case AuthStates.SESSION_EXPIRED:
switch (event) {
case AuthEvents.REFRESH_TOKEN: return AuthStates.LOGGED_IN;
case AuthEvents.LOGOUT: return AuthStates.LOGGED_OUT;
default: return state;
}
default:
return state;
}
}
onStateChange(newState) {
// Qui potremmo aggiornare il DOM o triggerare re-render in React/Vue
document.body.className = `app-state-${newState.toLowerCase()}`;
}
}
// Utilizzo
const auth = new AuthStateMachine();
auth.send(AuthEvents.SUBMIT_FORM); // LOGGED_OUT -> AUTHENTICATING
auth.send(AuthEvents.AUTH_SUCCESS); // AUTHENTICATING -> LOGGED_IN
auth.send(AuthEvents.SUBMIT_FORM); // Non succede nulla, l'evento non è valido per LOGGED_IN
In questo codice, abbiamo utilizzato un switch annidato. Il primo livello di switch controlla lo stato attuale, mentre il secondo livello controlla l'evento ricevuto. Questo approccio rende la matrice di transizione estremamente chiara e facile da leggere.
Errori comuni e Best Practices
1. Dimenticare il default case
Uno degli errori più frequenti è non prevedere cosa debba accadere se un evento non è gestito per uno specifico stato. La best practice è restituire lo stato corrente. Questo evita che l'applicazione crashi o passi a uno stato undefined.
2. Logica di business dentro la transizione
La funzione di transizione dovrebbe essere una funzione pura. Non dovrebbe effettuare chiamate API o modificare variabili globali. Il suo unico compito è: (stato, evento) => nuovoStato.
Sbagliato:
case STATES.IDLE:
if (event === EVENTS.FETCH_DATA) {
fetch('/api/data'); // NO: Effetto collaterale!
return STATES.LOADING;
}
Corretto: La funzione di transizione decide il nuovo stato, e un "effetto" esterno (o un metodo della classe) esegue l'azione basandosi sul cambiamento di stato.
3. Usare stringhe letterali
Evitate di scrivere switch('loading'). Usate sempre costanti. Se doveste cambiare il nome di uno stato, lo farete in un unico punto invece di fare un "cerca e sostituisci" rischioso in tutto il progetto.
Evoluzione: Verso un approccio basato su oggetti (Lookup Table)
Se l'applicazione diventa molto grande, lo switch potrebbe diventare eccessivamente lungo. In JavaScript, possiamo sostituire lo switch con un oggetto che mappa gli stati e i relativi eventi. Questo è tecnicamente ancora una State Machine, ma implementata come una Lookup Table.
const transitions = {
[AuthStates.LOGGED_OUT]: {
[AuthEvents.SUBMIT_FORM]: AuthStates.AUTHENTICATING,
},
[AuthStates.AUTHENTICATING]: {
[AuthEvents.AUTH_SUCCESS]: AuthStates.LOGGED_IN,
[AuthEvents.AUTH_FAILURE]: AuthStates.LOGGED_OUT,
},
// ... altri stati
};
function getNextState(state, event) {
return transitions[state]?.[event] || state;
}
Questo approccio riduce drasticamente il boilerplate e rende la logica di transizione quasi puramente dichiarativa.
Conclusione e Prossimi Passi
Implementare una State Machine, anche in modo semplice tramite switch, trasforma il modo in cui gestite la logica di un'applicazione web. Passate da una gestione reattiva e frammentata (basata su if/else) a una gestione proattiva e strutturata.
Cosa approfondire ora?
- XState: Se avete bisogno di state machine complesse con concetti di "Hierarchical States" (stati annidati) o "Parallel States", XState è lo standard industriale per JavaScript.
- Redux/Vuex: Molti state manager per framework moderni sono essenzialmente implementazioni di state machine (dove le
actionsono gli eventi e ilreducerè la funzione di transizione). - TypeScript: Provare a implementare questo pattern con i
Union Typeso gliEnumdi TypeScript per avere un controllo ancora più rigoroso sui tipi di stato e di evento, ottenendo l'autocompletamento e l'analisi statica degli errori.
Passare a questo paradigma richiede un piccolo sforzo iniziale di progettazione, ma il ritorno in termini di stabilità del codice e velocità di sviluppo nel lungo periodo è inestimabile.