Revenez sur l’asynchrone
JavaScript est un langage dit “single-thread”, c'est-à-dire que les tâches sont exécutées les unes après les autres sans possibilité d’exécuter deux choses ou plus à la fois.
Si vous arrivez à exécuter le script suivant dans votre navigateur, vous verrez que le temps qu’il se termine, rien d’autre ne pourra être fait :
let a = 0
const b = 10_000_000_000 // notation BIGINT
console.log(new Date())
for (let i = 0; i < b; i++) {
a += i
}
console.log(a)
console.log(new Date())
Tous les autres scripts ou actions devront attendre la fin de celui-ci avant de s’exécuter.
Dans bien des cas, ce fonctionnement serait problématique. Par exemple, faire une requête de données pourrait bloquer la page.
Comment éviter ce comportement ?
Eh bien avec JavaScript, nous pouvons contourner ce problème en utilisant la programmation asynchrone. Vous connaissez sans doute le gestionnaire d'événements, la fameuse méthode addEventListener
: c’est une approche de l’asynchrone.
Quel impact cette approche a-t-elle sur notre store Redux ? Et comment l’ajouter à notre store ?
Nous allons pour cela introduire un outil Redux, les middlewares, et ainsi utiliser createAsyncThunk
pour gérer l’asynchrone dans notre store.
Interceptez les changements du state avec les middlewares
Les middlewares sont en quelque sorte une pile de fonctions qui exécutent un traitement sur une donnée, puis la transmettent jusqu’à la fonction suivante jusqu'à ce qu'une des fonctions termine le programme ou qu’il n’y ait plus de fonction à la suite.
Je vous fais un petit schéma ci-dessous pour représenter le processus :
Les middlewares interceptent donc les valeurs et les modifient si nécessaire. Si on veut ajouter ou supprimer une valeur avant de laisser la main à la fonction suivante, par exemple, ou bien si on veut vérifier des données avant que l’application fasse un traitement sur ces données.
Les middlewares Redux fonctionnent de cette manière en interceptant les actions avant de les transmettre aux reducers. Il est donc possible de traiter les valeurs des payloads ou de modifier les types des actions avant que les reducers ne traitent les actions.
Cette fonctionnalité nous permet par exemple d’ajouter du logging, du reporting en cas de crash, de faire des appels à des API, et bien plus.
Je ne vais pas vous proposer de créer un middleware, mais pour mieux comprendre ses interactions avec les actions et le store, je vous suggère de me suivre dans ce screencast :
Si vous voulez essayez vous-même, voici le code que vous pouvez copier et coller dans le store :
export const store = configureStore(
{
preloadedState: state,
reducer: combineReducers({
owner: ownerSlice.reducer,
list: cartSlice.reducer,
notes: notesSlice.reducer,
}),
// Liste des middlewares
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend([
(store) => (next) => (action) => {
console.log('Action', action);
next(action);
}
])
Ce middleware a un fonctionnement très simple : il reçoit l’action lorsque que l’on utilise le dispatch
du store, il affiche l’action dans la console, et transmet l’action au middleware suivant.
Vous devriez voir dans la console de votre navigateur s’afficher l’action exécutée, et cela, pour toutes les actions.
Utilisez les thunks pour modifier le store de façon asynchrone
Nous savons ce que sont les middlewares et nous maîtrisons le concept d’asynchrone. Voyons maintenant comment combiner ces deux aspects afin d’exécuter des traitements asynchrones dans notre store. Cela nous permettra d’utiliser les appels de données via le réseau, avec fetch
par exemple.
Pour cela, nous allons utiliser les thunks de Redux fournis par le middleware redux-thunk.
Unthunk
est une fonction qui s'exécute de façon synchrone ou asynchrone et qui prend en paramètres les méthodes dispatch
et getState
de notre store :
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
store.dispatch(thunkFunction)
Comme pour les actions, il est préférable de créer des thunks actions creators, thunkCreator
, comme ci-dessous :
const thunkCreator = (arg1, arg2) => {
return async (dispatch, getState) => {
// on met ici la logique du thunk
}
}
Nous allons maintenant créer notre premier thunk
.
Admettons que pour toute commande de Poulet Croquant, nous voulions demander au client s’il veut bénéficier d’une offre spéciale : 3 burgers ‘Poulet Croquant’ achetés, le troisième à moitié prix. Et pour cela, nous lui demandons dans une boîte de dialogue s'il veut en profiter en ajoutant un troisième poulet croquant 5 secondes après qu’il en a ajouté 2.
Suivez le screencast suivant pour comprendre comment j’ai implémenté cette fonctionnalité :
Nous avons donc modifié notre fichier features/menu/Menu.js
pour changer notre dispatch
d’ajout de produit.
Nous avons créé un thunk action creator et ajouté une confirmation de l’utilisateur. Celle-ci s’affiche au bout de 5 secondes après l’ajout du burger si celui-ci est de type Poulet Croquant et au nombre de 2 dans le panier :
import { useDispatch } from 'react-redux';
import * as ProductList from '../../common/models';
import { ProductCard } from '../../common/components/ProductCard';
import { cartSlice } from '../cart/cartSlice';
import { getListQuantityProductPerName } from '../../app/selectors';
const addProductThunk = (product) => (dispatch, getState) => {
dispatch(cartSlice.actions.addProduct(product));
return new Promise((resolve) => {
setTimeout(() => {
const state = getState();
const numberProductPerName = getListQuantityProductPerName(state);
const numberForSpecialOffer = numberProductPerName.find((item) => item.title === "Poulet Croquant")?.quantity;
if (numberForSpecialOffer === 2) {
window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?")
const specialOffer = ProductList.PouletCroquant
dispatch(cartSlice.actions.addProduct({...specialOffer, price: Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}));
}
resolve();
}, 5000)
});
}
export const Menu = () => {
const dispatch = useDispatch();
return className="Menu"
{
Object.values(ProductList).map(product => key={product.name} product={product} onSelect={() => dispatch(addProductThunk(product))}
}
}
Utilisez createAsyncThunk
pour notre store
Vous l’aurez sans doute deviné, Redux Toolkit nous amène un outil pour gérer nos thunks, createAsyncThunk
!
Cet outil, à l’instar de createAction
, va nous simplifier la manière de déclarer et d’ajouter nos thunks à nos slices.
Je vous propose donc de réécrire notre thunk d’ajout de produit en utilisant cette fois-ci createAsyncThunk
de RTK.
Dans notre fichier cartSlide.js
, on reporte notre fonction addProductThunk
.
Elle devient une fonction qui :
en premier paramètre, comme pour les actions, va prendre une clé pour identifier le thunk ;
en deuxième paramètre, en tant que fonction asynchrone, prend toujours en dernier paramètre le
thunkApi
.
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { getListQuantityProductPerName } from "../../app/selectors";
import * as ProductList from '../../common/models';
export const addProductThunk = createAsyncThunk( 'cart/addProductThunk' , async (product, thunkApi) => {
})
Le thunkApi
nous donne accès à plusieurs choses : le dispatch
et le getState
du store, une fonction rejectWithValue
et une fonction fulfillWithValue
pour annuler ou valider l’action asynchrone, ainsi que d’autres paramètres tels que requestId
, signal
et extra
.
Nous simplifions notre fonction en veillant à dispatch
un produit dans la liste sélectionné via thunkApi.dispatch(cartSlice.actions.addProduct(product));
:
export const addProductThunk = createAsyncThunk( 'cart/addProductThunk' , async (product, thunkApi) => {
// Ajout du produit au panier via le dispatch du store
thunkApi.dispatch(cartSlice.actions.addProduct(product));
})
Selon le choix de l’utilisateur, nous retournons une Promise qui sera résolue ( resolve()
), s’il valide l’ajout au panier de la promo, et rejetée ( reject()
) dans le cas contraire.
Notre fonction addProductThunk devient donc :
export const addProductThunk = createAsyncThunk( 'cart/addProductThunk' , async (product, thunkApi) => {
thunkApi.dispatch(cartSlice.actions.addProduct(product));
// Promise retourner par notre thunk
return new Promise((resolve, reject) => {
setTimeout(() => {
const state = thunkApi.getState();
const numberProductPerName = getListQuantityProductPerName(state);
const numberForSpecialOffer = numberProductPerName.find((item) => item.title === "Poulet Croquant")?.quantity;
if (numberForSpecialOffer === 2) {
if(window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?")) {
resolve();
} else {
reject();
}
} else {
reject();
}
}, 5000)
});
})
Dans notre slice, nous ajoutons les cas d’actions qui nous intéressent via la propriété `extraReducers`
:
builder.addCase(addProductThunk.rejected, …)
lorsque notre fonction asynchrone serarejected
;builder.addCase(addProductThunk.fulfilled, …)
lorsqu’elle seraresolved
.
extraReducers: function(builder) {
builder.addCase(addProductThunk.fulfilled, (state) => {
const specialOffer = ProductList.PouletCroquant
return [...state, {...specialOffer, price: Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}]
})
builder.addCase(addProductThunk.rejected, (state) => {
return [...state]
})
}
Pour le premier cas, nous renvoyons le state non modifié. Pour le second, nous ajoutons un produit à moitié prix.
Retrouvez ci-dessous l’implémentation complète de ces modifications dans le fichier features/card/cartSlice.js
:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { getListQuantityProductPerName } from "../../app/selectors";
import * as ProductList from '../../common/models';
export const addProductThunk = createAsyncThunk( 'cart/addProductThunk' , async (product, thunkApi) => {
// Ajout du produit au panier via le dispatch du store
thunkApi.dispatch(cartSlice.actions.addProduct(product));
return new Promise((resolve, reject) => {
setTimeout(() => {
const state = thunkApi.getState();
const numberProductPerName = getListQuantityProductPerName(state);
const numberForSpecialOffer = numberProductPerName.find((item) => item.title === "Poulet Croquant")?.quantity;
if (numberForSpecialOffer === 2) {
if(window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?")) {
resolve();
} else {
reject();
}
} else {
reject();
}
}, 5000)
});
})
export const cartSlice = createSlice({
name: 'list',
initialState: {},
reducers: {
addProduct: (currentState, action) => {
const listWithNewProduct = [...currentState, action.payload]
return listWithNewProduct
},
removeProduct: (currentState, action) => {
const list = [...currentState].filter(
(item, index) => index !== action.payload
)
return list
},
applyVoucher: (currentState, action) => {
const withVoucherList = currentState.map(
item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
)
return withVoucherList
},
},
extraReducers: function(builder) {
builder.addCase(addProductThunk.fulfilled, (state) => {
const specialOffer = ProductList.PouletCroquant
return [...state, {...specialOffer, price: Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}]
})
builder.addCase(addProductThunk.rejected, (state) => {
return [...state]
})
}
})
N’oublions pas de modifier le fichier features/menu/Menu.js
et d’ajouter l’import de addProductThunk
en supprimant l’implémentation précédente :
import { addProductThunk } from '../cart/cartSlice';
À vous de jouer !
Notre application de commande de burger prend forme.
Dans la vie réelle, il arrive parfois que les commandes soient abandonnées en cours de route : un client qui préfère passer commande à la caisse, un autre qui passe au petit coin. Le client qui arrive quelques minutes plus tard aura besoin d’un panier vide. C'est-à-dire, pouvoir reprendre à zéro.
On peut donc imaginer une fonctionnalité de suppression de liste de commande lorsque la dernière action de l’utilisateur dépasse un certain temps, on va dire 2 minutes (120 secondes / 120 000 millisecondes).
Il faut donc pouvoir exécuter un thunk que vous pouvez appeler resetOrderThunk
, à créer, qui déclenchera au bout de 120 secondes la suppression des produits de la liste.
Utilisez notre thunk
addProductThunk
pour dispatch resetOrderThunk
.
Vous avez trouvé ? Découvrez la solution dans le screencast suivant :
En résumé
Utiliser l’approche asynchrone permet de pouvoir exécuter du JavaScript non bloquant.
Utiliser des middlewares avec Redux permet d'interagir avec les actions de façon indirecte.
Les thunks permettent de faire des actions sur le store de façon asynchrone.
createAsyncThunk
nous permet de créer simplement des thunks et de les ajouter au store.
Nous avons à présent les outils pour ajouter des fonctionnalités “connectées” à notre application. Passons de suite à l’utilisation de web services dans notre store.