Introduction à Redux
Si vous participez à un projet de taille conséquente, il se peut que vous vous posiez la question de la gestion des nombreux états de votre application.
Redux est une librairie permettant la gestion et la modification des états d'une application. Pour ce faire, Redux utilise des évènements appelés actions.
Ces actions sont déclenchables à travers toute l'application grâce au store de Redux, avec des règles garantissant que l'état de celui-ci ne peut être mis à jour que de façon prévisible, c'est à dire avec des évènements prévus.
Quand utiliser Redux ?
Il y certains concepts à apprendre et plus de code à écrire quand on utilise Redux. Ce n'est pas un outil à déployer sur un petit projet.
Le gros point fort de Redux est la gestion centralisée des états de l'application. Il est donc utile de le mettre en place quand :
- L'application comporte beaucoup d'états, il est nécessaire de les centraliser
- L'état de l'application est mis à jour fréquemment
- La logique pour mettre à jour un état peut être modifiée à l'avenir, et a besoin d'être isolée
Et Redux-thunk ?
Par défaut, Redux expédie ses actions de manière synchrone, ce qui peut poser problème pour certaines applications. Le middleware Redux-thunk étend les capacités du store de Redux, et permet à son utilisateur d'écrire la logique interagissant avec le store de manière asynchrone.
Démonstration par l'exemple
Nous allons créer ensemble une application permettant la gestion d'un aquarium connecté, Aquasmart !
Pour ce faire, l'équipe embarqué vous a fourni le prototype de l'aquarium pouvant contrôler :
- La température de l'eau
- Le pH de l'eau
- La conductivité de l'eau
- Le distributeur de nourriture
- La luminosité dans l'aquarium
Pour pouvoir contrôler l'aquarium à distance, il faut maintenant pouvoir envoyer et recevoir ces informations depuis l'interface utilisateur, grâce à une application mobile fournie par l'équipe Android. Celle-ci a été développée avec React-native en Typescript.
Notre mission sera d'utiliser Redux-thunk afin d'implémenter la logique entre l'application et l'aquarium, pour ce faire on considère que le Backend de notre application (Java side) communique déjà avec l'aquarium, on ne s'en souciera pas.
TODO
Voici les actions que nous devront gérer:
- INIT: état par défaut, permet de recevoir toutes les informations actuelles depuis l'aquarium
- IDLE: ne fait rien, va nous servir de gestion d'erreur
- GET_TEMP: recevoir la température
- GET_PH: recevoir le pH
- GET_CONDUCTIVITY: recevoir la conductivité
- GET_LUM: recevoir la luminosité
- GET_FOOD_HISTORY: recevoir la dernière date d'activation du distributeur de nourriture
- GET_FOOD_LEVEL: recevoir la quantité restante de nourriture
- SET_TEMP: modifier la température
- SET_LUM: modifier la luminosité
- SMART_LIGHT(ON/OFF): permet de gérer la luminosité de manière autonome
- GIVE_FOOD: donner de la nourriture
Cette quantité d'évènements reste gérable mais l'avantage de mettre en place Redux-thunk ici est qu'il est très facile de modifier ces actions, étant donné que le projet est à l'état de prototype. On sait également que le projet a pour but d'évoluer, il sera donc facile d'ajouter des actions dans le temps ainsi que de les retrouver rapidement.
Le store
La première chose à créer quand on utilise Redux, c'est son coeur: le store.
Le store est un conteneur retenant l'état global de l'application. C'est un objet Javascript comportant quelques spécificités et donc quelques règles :
- Il est interdit de modifier directement l'état retenu par le store.
- Pour ce faire, il faut créer une action puis dispatch cette action dans le store afin de lui décrire l'évènement qui vient de se passer.
- Quand une action est dispatch, le store va se servir du rootReducer qui lui est passé en paramètre. Ce reducer va permettre de calculer le nouvel état de l'application en fonction de l'évènement passé, c'est la logique de notre application.
- Enfin, le store va notifier tous ses subscribers que l'état de l'application a été modifié. Cela permet notamment de modifier l'UI.
- Il est également possible d'interroger le store avec la méthode getState() à n'importe quel moment afin de connaitre son état actuel.
./redux/store.ts
/**
* On créé le store avec notre reducer par défaut (vide pour le moment),
* et on applique le middleware de Redux-thunk qui nous permettra d'effectuer
* des actions de manière asynchrone.
*/
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
Reducer
Le reducer est le gestionnaire d'évènement de notre application. C'est le store qui l'appelle lorsqu'une action est dispatch. Il prend en paramètre le state actuel et une action décrivant l'évènement qui vient d'être dispatch par le store. Au démarrage de l'application, il n'y a pas encore d'état, alors on peut créer un état par défaut initialState.
./redux/reducers/rootReducer.ts
const initialState = {
value: 0,
};
const rootReducer: Reducer<State, Actions> = (state = initialState, actions) => {
switch (actions.type) {
default:
return state;
}
};
export default rootReducer;
Reducer interface
Afin de pouvoir gérer tous nos états, nous avons besoin de créer une interface qui va nous servir à regrouper tous les évènements possibles.
./redux/reducers/reducerInterface.ts
/**
* L'interface d'état de notre application. Permet de retenir les informations suivante dans le state.
* On pourra les récupérer avec store.getState().
*/
interface AquasmartStateInterface {
temperature: number;
wantedTemperature: number;
pH: number;
conductivity: number;
luminosity: number;
wantedLuminosity: number;
smartLight: boolean;
foodLevel: number;
lastFoodHistory: string;
}
export type State = AquasmartStateInterface;
export const defaultInitialState: State = {
temperature: 0,
wantedTemperature: 0,
pH: 0,
conductivity: 0,
luminosity: 0,
wantedLuminosity: 0,
smartLight: false,
foodLevel: 0,
lastFoodHistory: '',
};
/**
* On décrit toutes les actions possibles de notre application. Notez qu'elles sont ainsi regroupées,
* il est donc facile de modifier ceci à l'avenir.
*/
export enum ActionType {
INIT = 'INIT',
IDLE = 'IDLE',
GET_TEMP = 'GET_TEMP',
GET_PH = 'GET_PH',
GET_CONDUCTIVITY = 'GET_CONDUCTIVITY',
GET_LUM = 'GET_LUM',
GET_FOOD_HISTORY = 'GET_FOOD_HISTORY',
GET_FOOD_LEVEL = 'GET_FOOD_LEVEL',
SET_TEMP = 'SET_TEMP',
SET_LUM = 'SET_LUM',
SMART_LIGHT_ON = 'SMART_LIGHT_ON',
SMART_LIGHT_OFF = 'SMART_LIGHT_OFF',
GIVE_FOOD = 'GIVE_FOOD',
}
export type Actions =
| { type: ActionType.INIT; payload: State }
| { type: ActionType.IDLE }
| { type: ActionType.GET_TEMP; payload: number }
| { type: ActionType.GET_PH; payload: number }
| { type: ActionType.GET_CONDUCTIVITY; payload: number }
| { type: ActionType.GET_LUM; payload: number }
| { type: ActionType.GET_FOOD_HISTORY; payload: string }
| { type: ActionType.GET_FOOD_LEVEL; payload: number }
| { type: ActionType.SET_TEMP; payload: number }
| { type: ActionType.SET_LUM; payload: number }
| { type: ActionType.SMART_LIGHT_ON }
| { type: ActionType.SMART_LIGHT_OFF }
| { type: ActionType.GIVE_FOOD };
On connait maintenant nos états et nos actions ! On peut passer à nos différents reducers.
Combiner des reducers
Pour notre aquarium, nous allons diviser nos actions précédemment définies en 2 reducers.
Notez que dans la majorité des cas, un reducer sera sous forme de switch/case, qui pour un type d'action donné effectuera un changement dans le state via une copie de celui-ci (le state étant immutable), le but étant qu'à n'importe quel moment, si on interroge le store avec store.getState(), le state retourné contiendra les valeurs attendues.
./redux/reducers/getDataReducer.ts
const getDataReducer: Reducer<State, Actions> = (state = defaultInitialState, actions) => {
switch (actions.type) {
case ActionType.INIT:
return actions.payload;
case ActionType.IDLE:
return state;
case ActionType.GET_TEMP:
/**
* Les reducers ne peuvent que faire des copies des valeurs originales, puis elles peuvent modifier les copies.
*/
return {
...state,
temperature: actions.payload,
};
case ActionType.GET_PH:
return {
...state,
pH = actions.payload,
};
case ActionType.GET_CONDUCTIVITY:
return {
...state,
conductivity: actions.payload,
};
case ActionType.GET_FOOD_HISTORY:
return {
...state,
lastFoodHistory: actions.payload,
};
case ActionType.GET_FOOD_LEVEL:
return {
...state,
foodLevel: actions.payload,
};
default:
return state;
}
};
export default getDataReducer;
./redux/reducers/setDataReducer.ts
const setDataReducer: Reducer<State, Actions> = (state = defaultInitialState, actions) => {
switch (actions.type) {
case ActionType.INIT:
return actions.payload;
case ActionType.SET_TEMP:
return {
...state,
wantedTemperature: actions.payload,
};
case ActionType.SET_LUM:
return {
...state,
wantedLum: actions.payload,
};
case ActionType.SMART_LIGHT_ON:
return {
...state,
smartLight: true,
};
case ActionType.SMART_LIGHT_OFF:
return {
...state,
smartLight: false,
};
case ActionType.GIVE_FOOD:
return {
...state,
lastFoodHistory: getCurrentDate(),
};
default:
return state;
}
};
export default setDataReducer;
Il est important que chaque reducer comporte l'état d'initialisation.
On peut enfin modifier notre rootReducer et combiner nos 2 reducers:
./redux/reducers/rootReducer.ts
const rootReducer = combineReducers({
getDataReducer,
setDataReducer,
});
export default rootReducer;
ThunkDispatch
Maintenant qu'on a décrit nos évènements et retenu le résultat de ceux-ci dans notre state, nous pouvons dispatch nos actions ! C'est ce qui nous permettra de faire le lien direct entre un évènement dans notre application et l'action à prendre en fonction de celle-ci.
Et c'est là que les choses se corsent. Afin de dispatch une action de manière asynchrone, ThunkDispatch prend en paramètre... une fonction ! C'est là la grande particularité de Redux-thunk.
Commençons par un cas simple. Comme dit précédemment, afin de modifier le state de notre store, il faut dispatch un évènement sous forme d'action.
Prenons le cas par défaut: l'initialisation de notre state. Etant donné qu'il n'y a pas de call API pour ce cas, pas besoin de retrouner une fonction pour faire les choses de manière asynchrone.
./redux/store.ts
export const fetchInit = () => {
const action: Actions = {
type: ActionType.INIT,
payload: {
...defaultInitialState,
},
};
return action;
};
export const initAquariumState = () => {
store.dispatch(fetchInit());
};
Comme vous pouvez le voir, il suffit de retourner une action pour effectuer le dispatch. Ainsi, il nous suffit d'appeler initAquariumState() au démarrage de notre application pour initialiser notre state.
Cependant, afin d'initialiser correctement notre application et synchroniser celle-ci avec l'aquarium, il faut récupérer toutes les informations de l'aquarium afin d'afficher les bons paramètres à l'utilisateur.
Prenons l'exemple de la température, ce sera le même schéma pour les autres paramètres :
./redux/getDataThunk.ts
/**
* React native bridge, faisant le lien entre le Frontend et le Backend.
* Cela représente les appels API qui ont besoin d'être effectués de manière asynchrone.
*/
export const reactInterface = NativeModules.ReactInterface;
export const fetchGetTemperature = () => {
return function (dispatch: ThunkDispatch<State, unknown, Actions>) {
return reactInterface.getTemperature((status: string) => {
if (!isValidTemperature(status)) {
/**
* La température reçue n'est pas correcte, on ne fait rien
* Pour une gestion des erreurs plus complète, il nous suffirait d'ajouter une action dédiée !
*/
return dispatch({
type: ActionType.IDLE,
});
}
return dispatch({
type: ActionType.GET_TEMPERATURE,
payload: parseFloat(status),
} as Actions);
});
};
};
De même, si on veut envoyer la température voulue à l'aquarium :
./redux/setDataThunk.ts
export const fetchSetTemperature = (wantedTemperature: number) => {
return function (dispatch: ThunkDispatch<State, unknown, Actions>) {
return reactInterface.setTemperature(wantedTemperature, (status: string) => {
if (isAnError(status)) {
/**
* La température s'est mal envoyé envoyée ou n'est pas correcte, on ne fait rien
* Pour une gestion des erreurs plus complète, il nous suffirait d'ajouter une action dédiée !
*/
return dispatch({
type: ActionType.IDLE,
});
}
return dispatch({
type: ActionType.SET_TEMPERATURE,
payload: wantedTemperature,
} as Actions);
});
};
};
On peut maintenant créer notre fonction d'update !
./redux/store.ts
export const updateAquariumState = () => {
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetTemperature());
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetLuminosity());
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetpH());
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetConductivity());
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetLastFoodHistory());
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchGetFoodLevel());
};
export const changeTemperature = (temperature: number) => {
(store.dispatch as ThunkDispatch<State, unknown, Actions>)(fetchSetTemperature(temperature));
};
./index.ts
setInterval(function() {
updateAquariumState();
}, 1000);
{...}
return (
<ChangeTemperatureButton onPress={() => {changeTemperature(temperature)}} />
);
Un dernier petit détail...
Afin de pouvoir avoir accès au store depuis n'importe quel endroit de votre application, il est primordial de fournir le store à votre application !
Pour ce faire, il vous suffit d'ajouter ce Provider dans App.tsx
const AquariumApp: FunctionComponent = () => {
return (
<Provider store={store}>
{your components}
</Provider>
);
};
Et voilà, on peut désormais contrôler l'état de notre Aquarium à travers notre application grâce à Redux-thunk ! Nos poissons 🐠 peuvent se sentir tranquille...