Azioni asincrone in Redux

Maico Orazio
weBeetle
Published in
9 min readJul 20, 2021
Azioni asincrone in Redux

Se avete seguito gli articoli precedenti sapete benissimo che finora abbiamo avuto a che fare con dati sincroni; tutti i dati con cui abbiamo lavorato erano presenti direttamente all’interno della nostra applicazione, utilizzati tramite l’invio di actions, e attraverso reducer ricevevamo lo stato successivo.

Supponiamo ora di voler recuperare i dati da un server, effettuando chiamate API. Qual è il posto in cui implementare funzioni asincrone in Redux?

Il reducer deve sempre essere una funzione pura quindi, dato un input, deve restituire sempre lo stesso output. Non va utilizzato, dunque, per chiamate API o per produrre effetti collaterali come chiamate AJAX.

Le actions sono oggetti semplici, ma possiamo provare ad inserirle in un action creator che è una funzione:

// src/store/actions.js

export function getTodo() {
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => response.json())
.then(json => {
return { type: "TODO_LOADED", payload: json };
});
}

Colleghiamola al componente e richiamiamolo quando viene montato:

// src/components/List.jsimport { connect } from "react-redux";
import { getTodo } from "../store/actions";
const mapStateToProps = state => {
return {todos: state.todos}
}
const List = (props) => {
useEffect(() => {
props.getTodo();
}, []);
return (
<ul>
{props.todos.map((title,i) => (
<li key={i}>{title}</li>
))}
</ul>
)
}
export default connect(mapStateToProps, { getTodo })(List);

Eseguendo l’app, riceveremo il seguente errore nella console: “Error: Actions must be plain objects. Use custom middleware for async actions”

Ciò sta a significare che non possiamo chiamare una fetch dall’interno di un action creator in Redux. Per farla funzionare abbiamo bisogno di un middleware personalizzato. Questo perché Redux si aspetta oggetti come action, mentre noi stiamo cercando di restituire una promise.

La documentazione Redux fornisce una guida dettagliata sulla creazione di middleware per gestire le chiamate API asincrone. Alla fine di questa procedura dettagliata, abbiamo essenzialmente creato il middleware redux-thunk .

Azioni asincrone in Redux con Redux Thunk

Redux non comprende altri tipi di actions oltre a un semplice oggetto.

Se vuoi spostare la logica asincrona da React a Redux ed essere in grado di restituire funzioni invece di oggetti semplici, devi usare un middleware personalizzato.

Redux Thunk è un middleware per Redux. Con Redux Thunk puoi restituire funzioni da action creators, puoi eseguire logica asincrona all’interno delle tue actions e inviare altre actions in risposta alle chiamate AJAX.

Per utilizzare Redux Thunk e implementare funzioni asincrone nelle nostre applicazione React, la prima cosa da fare è installare il middleware:

npm install redux-thunk --save-dev
yarn add redux-thunk --dev

Ora importiamo il middleware nella nostra app e colleghiamolo allo store:

// src/store/index.jsimport { createStore, applyMiddleware } from "redux"
import { rootReducer } from "./reducers";
import thunk from 'redux-thunk';
export const store = createStore(rootReducer, applyMiddleware(thunk));

A questo punto è necessario effettuare il refactoring di getTodo per utilizzare Redux Thunk:

// src/store/actions.jsexport function getTodo() {
return function(dispatch) {
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => {
return dispatch({ type: "TODO_LOADED", payload: json });
});
}
}

È possibile accedere anche allo stato, aggiungendo getState alla lista dei parametri della funzione che ritorniamo dall’action creator.

Fatto ciò, possiamo aggiungere la nuova action al nostro reducer:

// src/store/reducers.jsimport { ADD_TODO, TODO_LOADED } from "./actions"const initialState = {
todos: [],
remoteTodos: []
}
export function rootReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return Object.assign({}, state, {
todos: state.todos.concat(action.payload.title)
});
case TODO_LOADED:
return Object.assign({}, state, {
remoteTodos: state.remoteTodos.concat(action.payload)
});

default:
return state;
}
}

Infine, aggiorniamo il nostro componente List per visualizzare i nostri todos remoti:

// src/components/List.jsimport {connect} from "react-redux";
import { getTodo } from "../store/actions";
import {useEffect} from "react";
const mapStateToProps = state => {
return {todos: state.remoteTodos.slice(0, 10)}
}
const List = (props) => { useEffect(() => {
props.getTodo();
}, []);
return (
<ul>
{props.todos.map((todo,i) => (
<li key={i}>{todo.title}</li>
))}
</ul>
)
}
export default connect(mapStateToProps, { getTodo })(List);

Redux Thunk è un middleware che funziona molto bene per la maggior parte dei casi d’uso. Tuttavia, se la tua logica asincrona comporta scenari più complessi, o se hai requisiti specifici, allora Redux Saga potrebbe essere una soluzione migliore.

Azioni asincrone in Redux con Redux Saga

Le azioni asincrone possono essere più complicate da testare e organizzare, questo è il motivo per cui molti sviluppatori preferiscono Redux Saga.

Redux Saga è un middleware Redux per la gestione degli effetti collaterali. Con Redux Saga puoi avere un thread separato nella tua applicazione per gestire le actions non pure, tipo chiamate API, accesso allo storage o altro.

Con Redux Saga puoi avere una chiara separazione tra logica sincrona e asincrona: è diverso in termini di sintassi e organizzazione del codice.

Redux Saga utilizza i generatori, che, in breve, sono funzioni che possono essere messe in pausa e riprese su richiesta e che sono in grado di :

  • comunicare con il chiamante;
  • mantenere il loro contesto e l’ambito di esecuzione per le chiamate successive.

Un generatore, al suo interno, ha il codice suddiviso in diverse porzioni, delimitate dalla nuova keyword yield. Attraverso il metodo next() possiamo far eseguire al generatore la parte che ci interessa, nel momento più opportuno. Una volta eseguita quella parte di codice, il generatore si rimette in pausa finché noi non andremo a richiedere un’altra parte di codice riutilizzando il metodo next() .

Un generatore si comporta come un iteratore, ogni invocazione di next() restituirà un oggetto nella forma:

{ 
value: Any,
done: true|false
}

La proprietà value conterrà il valore. La proprietà done è true o false. Quando done diventa true, il generatore si ferma e non genera più valori.

Anche se i generatori possono essere utilizzati in diversi scenari, nascono per lavorare con le promise e quindi sono stati ideati per maneggiare codice asincrono.

Utilizzando Redux Saga non sarà necessario chiamare next() per la successiva esecuzione, lo gestisce “sotto il cofano” per noi.

Refactoring con Redux Saga

Effettuiamo il refactoring del nostro codice per usare Redux Saga invece di Thunk. Installiamo redux-saga:

npm install redux-saga --save-dev
yarn add redux-saga --dev

Con Saga il nostro action creator invierà semplicemente una action, modifichiamo getTodo

// src/store/actions.jsexport function getTodo() {
return { type: "TODO_REQUESTED" }
}

L’action TODO_REQUESTED sarà intercettata da Redux Saga con takeEvery per processare la richiesta ed elaborare una risposta.

La logica di Redux Saga può essere implementata in un file a parte contenente:

  • una watcher function: un generatore che intercetta ogni action che gli interessa e richiama la relativa worker function per processarla;
  • una worker function: un generatore per elaborare la risposta.

Nel nostro esempio, la worker function eseguirà la chiamata API tramite la function call di redux-saga/effects; quando i dati vengono caricati, possiamo inviare un’altra action tramite la function put , sempre di redux-saga/effects.

Quindi creiamo un nuovo file dove andiamo a scrivere la nostra saga:

// src/store/sagas.jsimport { takeEvery, call, put } from "redux-saga/effects";export default function* watcherSaga() {
yield takeEvery("TODO_REQUESTED", workerSaga);
}
function* workerSaga() {
try {
const payload = yield call(getTodo);
yield put({ type: "TODO_LOADED", payload });
} catch (e) {
yield put({ type: "API_ERRORED", payload: e });
}
}

function getTodo() {
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
});
}

Ecco come funziona:

  1. intercetta ogni action nominata TODO_REQUESTED e, per ogni action, fai girare una worker saga
  2. all’interno della worker saga viene chiamata tramite call la funzione getTodo ;
  3. se la funzione ha esito positivo, inviamo una nuova action TODO_LOADED tramite la funzione put con i relativi dati recuperati da getTodo ;
  4. se la funzione fallisce, allora inviamo l’action API_ERRORED con il messaggio di errore ricevuto da getTodo .

Finalmente possiamo collegare Redux Saga al nostro Store di Redux:

// src/store/index.jsimport { createStore, applyMiddleware } from "redux"
import { rootReducer } from "./reducers";
import createSagaMiddleware from "redux-saga";
import apiSaga from "./sagas";
const initialiseSagaMiddleware = createSagaMiddleware();const store = createStore(rootReducer, applyMiddleware(initialiseSagaMiddleware));initialiseSagaMiddleware.run(apiSaga);export default store;

La funzione createSagaMiddleware crea il middleware Redux e collega le nostre saghe allo Store di Redux; con .run avviamo la loro esecuzione.

Saghe e parametri

Le worker saga prendono la action effettiva come parametro, ciò significa che possiamo utilizzare al suo interno il payload della action, se presente. Facciamo un esempio con il nostro componente List:

// src/components/List.jsimport {connect} from "react-redux";
import { getTodo } from "../store/actions";
const mapStateToProps = state => {
return {todos: state.remoteTodos.slice(0, 10)}
}
const List = (props) => {useEffect(() => {
props.getTodo("https://jsonplaceholder.typicode.com/todos");
}, []);
return (
<ul>
{props.todos.map((todo,i) => (
<li key={i}>{todo.title}</li>
))}
</ul>
)
}
export default connect(mapStateToProps, { getTodo })(List);

l’action creator restituisce anche il payload:

// src/store/actions.jsexport function getTodo() {
return { type: "TODO_REQUESTED", payload: { url } }
}

modifichiamo il nostro watcher e worker saga per passare il payload della action a getTodo :

// src/store/sagas.jsimport { takeEvery, call, put } from "redux-saga/effects";export default function* watcherSaga() {
yield takeEvery("TODO_REQUESTED", workerSaga);
}
function* workerSaga(action) {
try {
const payload = yield call(getTodo, action.payload.url);
yield put({ type: "TODO_LOADED", payload });
} catch (e) {
yield put({ type: "API_ERRORED", payload: e });
}
}

function getTodo(url) {
return fetch(url)
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
});
}

Azioni asincrone in Redux con Redux Toolkit

Quando si tratta di chiamate API, è necessario gestire tre diverse actions in Redux:

  • FETCH_REQUEST: quando inizia la richiesta;
  • FETCH_FAILURE: se la richiesta fallisce;
  • FETCH_SUCCESS: se la richiesta ha esito positivo;

Di seguito un semplice esempio, prima con Redux Thunk:

// src/store/actions.jsexport function getTodo() {
return function(dispatch) {
dispatch({ type: "FETCH_TODO_REQUEST" });
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => {
return dispatch({ type: "FETCH_TODO_SUCCESS", payload: json });
})
.catch(error =>
dispatch({
type: "FETCH_TODO_FAILURE",
payload: error.message
})

);
}
}

poi con Redux Saga:

// src/store/sagas.jsimport { takeEvery, call, put } from "redux-saga/effects";export default function* watcherSaga() {
yield takeEvery("FETCH_TODO_REQUEST", workerSaga);
}
function* workerSaga() {
try {
yield put({ type: "FETCH_TODO_REQUEST" });
const payload = yield call(getTodo);
yield put({ type: "FETCH_TODO_SUCCESS", payload });
} catch (e) {
yield put({ type: "FETCH_TODO_FAILURE", payload: e });
}
}

function getTodo() {
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
});
}

Come abbiamo visto nell’articolo dedicato, Redux Toolkit include redux-thunk, quindi l’esempio riportato sopra funzionerebbe bene. Tuttavia, per gestire le azioni asincrone Redux Toolkit fornisce un metodo chiamato createAsyncThunk .

createAsyncThunk crea in automatico le tre actions, accetta un identificatore e una callback che esegue la logica asincrona effettiva e restituisce una promise che gestirà l’invio delle actions pertinenti in base al suo stato.

const getTodo = createAsyncThunk("todo/fetchList", () => {
return fetch("https://jsonplaceholder.typicode.com/todos")
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => json);
});

createAsyncThunk crea in automatico un action creator per ogni stato della promise. Nel nostro esempio, dove abbiamo chiamato il nostro thunk asincrono todos/getTodo , genera le tre actions con i seguenti nomi:

  • in attesa: todos/getTodo/pending;
  • rifiutato: todos/getTodo/rejected;
  • soddisfatto: todos/getTodo/fulfilled.

A differenza dei flussi di dati tradizionali, le actions create da createAsyncThunk verranno gestite dalla sezione extraReducers all’interno di una slice:

import { configureStore, createSlice } from '@reduxjs/toolkit';const initialState = {
loading: false,
error: "",

todos: []
}
const todoReducerSlice = createSlice({
name: "todo",
initialState: initialState,
reducers: {
addTodo: (state, action) => {
state.todos = state.todos.concat(action.payload.title)
}
},
extraReducers: {
[getTodo.pending]: state => {
state.loading = true;
},
[getTodo.rejected]: (state, action) => {
state.loading = false;
state.error = action.error.message;
},
[getTodo.fulfilled]: (state, action) => {
state.loading = false;
state.todos = action.payload;
}
}

});

Utilizzeremo action.error.message per ottenere il messaggio di errore della promise rifiutata; mentre per ottenere il payload del metodo API bisogna accedere a action.payload .

Se vogliamo passare un argomento all’ action creator, ad esempio l’end-point del metodo API:

store.dispatch(getTodo("https://jsonplaceholder.typicode.com/todos"));

lo recupereremo come parametro nella funzione di callback:

const getTodo = createAsyncThunk("todos/getTodo", (url) => {
return fetch(url)
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => json);
});

Se abbiamo bisogno di accedere ai parametri di thunk per utilizzare dispatch o getState , passiamo thunkApi come parametro alla funzione di callback:

const getTodo = createAsyncThunk("todos/getTodo", (url, thunkApi) => {
return fetch(url)
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => json);
});

Conclusioni

In sintesi, abbiamo scoperto che qualsiasi azione asincrona può essere suddivisa in almeno tre azioni sincrone. Abbiamo sfruttato questo principio per implementare tre approcci per la gestione delle azioni asincrone durante l’utilizzo di Redux.

Puoi considerare il primo approccio, basato sul middleware Thunk, più semplice, ma il suo limite è che ti costringe a modificare la natura originale di un action creator.

Il secondo approccio, basato su un middleware Saga, può sembrare a prima vista più complesso ma è molto più scalabile e gestibile, ti consente di avere una chiara separazione tra logica sincrona e asincrona.

Il terzo approccio con Redux Toolkit, utilizza sempre Thunk, già incluso, ma alleggerisce il codice fornendo un metodo chiamato createAsyncThunk che crea in automatico le tre actions.

--

--

Maico Orazio
weBeetle

Senior Web Application Developer. I'm a software engineer, a passionate coder, and a web developer. I am a fan of technology. #php #symfony #javascript #reactjs