Redux dans une appli mobile React Native

Analysons l’intégration de Redux et Redux Persist pour stocker nos données dans le cadre du développement d’une application mobile React Native.

Maintenant que la fondation de nos écrans d’application React Native est prête, voyons comment enregistrer ses données de manière centralisée avec Redux.

Pour suivre cet article, il est conseillé de checkout la branche 2-redux-and-filestorage-persist du repo de l’application React Native.

React & Redux : rapide présentation

Dans une application React « basique », chaque composant a accès à ses propres données via son state. Cette encapsulation est la base de la logique React, où le composant gère à peu près tout, tout seul.

Si un composant parent doit partager une donnée à un composant enfant présent dans sa méthode render(), ce partage s’effectue via les props de l’enfant.

Mais dès qu’une application React grandit un peu, son développement devient fastidieux et une problématique se pose rapidement. Quel composant « possède » une donnée X ? A qui l’envoie-t-il ? Manque-t-il une prop à tel ou tel composant enfant pour pouvoir intercepter cette même donnée venue d’en haut ? Et si je déplace ce composant enfant de son parent, comment peut-il désormais accéder à certaines données qui lui seraient vitales ?

Configurer un store de données Redux dans notre application React Native

Arrive Redux : un store global accessible à tous

Redux permet de pallier à ce problème d’une manière simple : il propose un objet JSON centralisé (appelé store) où toutes les données de notre application React sont stockées.

On peut alors « connecter » tel ou tel composant à tel ou tel bout du store. Ces données sont alors injectées dans le composant par Redux via des props. Le composant a alors accès à des données de l’application.

La logique de Redux

Développer avec Redux impose de comprendre son fonctionnement — qui n’est pas des plus simples au premier abord. Essayons de rationaliser tout ça en reprenant les concepts décrits dans la documentation de Redux.

Les actions : envoyer des ordres

Pour modifier quelque chose dans les données du store Redux, il faut « expédier » (dispatch) une action. Une action est une fonction qui renvoie un objet JavaScript qui décrit ce qui doit se passer. Elle ne fait que décrire et ne modifie rien dans le store.

Chaque action possède une clé « type » qui est souvent une constante (pour éviter les typos, cette chaîne de caractères étant reprise par les actions mais aussi les reducers que l’on verra plus bas). C’est une bonne pratique prônée par la doc officielle.

/**
 * Add a new beer
 * @param {object} data The beer data
 */
export const addBeer = data => {
	return {
		type: ADD_BEER,
		data,
	};
};

La fonction addBeer() ci-dessus pourra être dispatchée par n’importe quel composant (connecté au store) de notre appli React Native souhaitant créer une nouvelle bière. Son paramètre data contiendra les données de la nouvelle bière à ajouter.

A part le type, la structure de l’objet retourné par une action est totalement libre.

Les reducers : exécuter les ordres et modifier le store

Dans Redux, les reducers ont pour mission de modifier le store en réponse aux actions. Ils interceptent une action et en fonction de son type, vont modifier l’objet central avec les données reçues de cette action.

Les reducers sont des fonctions dites pures : elles acceptent le store (state) et une action en paramètres, et retournent le prochain état du store (state).

En vrai, le reducer ne doit pas directement modifier le store. Il va retourner une nouvelle version du store en réponse, en utilisant des fonctions qui n’éditeront pas directement le state (comme ...spread, .map() ou .filter()).

const initialState = [];

const reducer = (state = initialState, action) => {
	switch (action.type) {
		case ADD_BEER: {
			return [...state, action.data];
		}

		default: {
			return state;
		}
	}
};

Un reducer est donc une fonction recevant deux paramètres :

  1. le state des données en cours (avec une valeur par défaut, si le state n’existe pas encore lors du premier lancement de l’appli React)
  2. l’action, objet décrit précédemment

Dans notre reducer en charge des bières, l’interception d’une action de type ADD_BEER (pour enregistrer une nouvelle bière) ajoutera l’object action.data dans notre tableau de bières.

Notez également la présence d’un default qui permet de renvoyer le state dans le même état, sans modification du store Redux. C’est très important, car si un reducer n’a pas à traiter une action spécifique, il faudra quand même qu’il rende le state/store intact en réponse.

Créer un datastore Redux persistant sur mobile / React Native

Création des actions permettant l’interaction avec le store Redux

Avant de se lancer dans le développement complexe d’une application React et de ses composants, il est judicieux de planifier toutes les actions qui seront réalisables par l’utilisateur. En commençant par la création de ces actions, on se forcera à bien penser à l’architecture des données stockées dans notre store Redux.

Trois actions seront nécessaires dans notre application beerkip :

  1. l’ajout d’une nouvelle bière, action exécutée à la suite de l’envoi du formulaire de création de bière
  2. l’édition d’une bière, action exécutée à la suite de l’envoi du formulaire d’édition d’une bière (non-développé dans ce tutoriel)
  3. la suppression d’une bière, action exécutée après un clic sur une icône « poubelle » dans un menu sur l’écran de détails d’une bière
import { ADD_BEER, EDIT_BEER, DELETE_BEER } from "./actionTypes";

export const addBeer = data => {
	return {
		type: ADD_BEER,
		data,
	};
};

export const editBeer = (uid, data) => {
	return {
		type: EDIT_BEER,
		uid,
		data,
	};
};

export const deleteBeer = uid => {
	return {
		type: DELETE_BEER,
		uid,
	};
};

Création des reducers Redux

Voici à quoi ressemble le reducer en charge d’intercepter ses actions :

const initialState = [];

const reducer = (state = initialState, action) => {
	switch (action.type) {
		case ADD_BEER: {
			return [...state, action.data];
		}

		case EDIT_BEER: {
			return state.map(beer => {
				if (beer.uid === action.uid) {
					return {
						...beer,
						edited: true,
						editedAt: Date.now(),
						...action.data,
					};
				} else {
					return beer;
				}
			});
		}

		case DELETE_BEER: {
			return state.filter(beer => beer.uid !== action.uid);
		}

		default: {
			return state;
		}
	}
};

Comme vous le voyez, on fait de simples ajouts dans un tableau recrée avec le spread de ...state, des usages de .map() ou de .filter() : le state n’est jamais directement modifié. Seulement des copies altérées sont renvoyées.

La structure finale de nos reducers

Dans notre application finale, nous aurons :

  1. un reducer pour gérer les données relatives aux bières (et uniquement celles ci),
  2. un autre pour gérer les données relatives à l’utilisateur connecté,
  3. et un autre pour gérer les données des formulaires de redux-form.

Pensez à ne pas mélanger les types de données dans vos reducers et créez autant de reducers que nécessaires. Le travail de métamorphose des données d’un reducer est grandement simplifié si la structure de son objet de données n’est pas trop profonde, sinon bonjour les nœuds au cerveau ! La documentation de Redux propose d’excellents conseils sur comment structurer ses reducers.

Configuration générale du store Redux

Par défaut, le store Redux n’est pas persistant. J’entends par là que ces données sont supprimées à la fermeture de l’application React (web ou mobile). A la prochaine ouverture de l’application par l’utilisateur, le store est réinitialisé et retrouve son état initial.

Heureusement pour nous, des librairies comme Redux Persist permettent de conserver les données du datastore Redux afin qu’elles persistent dans le temps. L’utilisateur retrouve alors ses données à la prochaine ouverture de l’application. Plusieurs moyens de stockage sont disponibles : local storage, session, etc.

Dans le cas de notre développement d’application mobile avec React Native, nous utiliserons l’adaptateur redux-persist-filesystem-storage. Les données du store de l’application seront sauvegardées dans le système de fichiers local du smartphone de l’utilisateur.

import { createStore, combineReducers } from "redux";
import { persistStore, persistReducer } from "redux-persist";
import FilesystemStorage from "redux-persist-filesystem-storage";

import beersReducer from "./reducers/beers";

const persistConfig = {
	key: "brkp",
	storage: FilesystemStorage,
};

const rootReducer = combineReducers({
	beers: beersReducer,
});

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = createStore(persistedReducer);
export const persistor = persistStore(store);

On va donc avoir 2 exports dans notre fichier configureStore.js :

  1. le store Redux, contenant les données retournées par les reducers. Dans notre cas (lignes 12 à 14), nous n’avons qu’un seul reducer en charge de manipuler les bières, mais on ajoutera d’autres dans les futurs tutoriels pour stocker d’autres types de données.
  2. le persistore, l’adaptateur qui s’occupera de faire persister les données selon une configuration. Dans notre cas (lignes 7 à 10), on indique à redux-persist d’enregistrer les données persistantes sous une clé « brkp » en utilisant le système de stockage FilesystemStorage de redux-persist-filesystem-storage.

Il nous faut maintenant utiliser ces 2 fonctions dans App.js pour rendre accessible notre store central de données Redux à tous nos composants d’application React Native.

Mise en relation du composant <App /> avec le store Redux

principal (opens in a new tab) »>Editons maintenant notre principal (opens in a new tab)"><App /> principal (opens in a new tab) »> principal en charge d’encapsuler l’application, composant créé dans le tutoriel précédent.

import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./src/store/configureStore";

class App extends Component {
	render() {
		return (
			<Provider store={store}>
				<PersistGate loading={null} persistor={persistor}>
					<AppContainer />
				</PersistGate>
			</Provider>
		);
	}
}

On entoure notre <AppContainer /> de deux nouveaux composants fournis par Redux et Redux Persist :

  1. <Provider> va permettre de rendre nos données d’app disponibles aux composants souhaitant y récupérer des données. On lui passe le store créé précédemment.
  2. <PersistGate> va se charger d’intercepter les modifications faites sur ce store Redux pour les enregistrer de manière persistante dans la mémoire du téléphone de l’utilisateur. On lui passe le persistor créé précédemment.

Désormais, on va pouvoir connecter les composants souhaitant récupérer des données du store ou souhaitant y dispatcher des actions pour altérer ces données.

Connexion des composants React Native au store Redux

Lire les données du state global de l’application

On utilise pour cela la fonction connect() de Redux pour injecter des données du store dans des props d’un composant.

Pour utiliser connect(), il faut créer une fonction spéciale appelée mapStateToProps qui décrira comment transformer le state du store Redux pour les injecter dans les props de notre composant.

import { connect } from "react-redux";

class BeersList extends Component { ... }

const mapStateToProps = state => ({
	beers: state.beers,
});

export default connect(mapStateToProps)(BeersList);

Ici, notre composant <BeersList /> aura accès à la liste des bières (state.beers) du store via sa prop beers, comme utilisé ici.

Exécuter des actions sur le store Redux

En plus de lire les données, les composants connectés à Redux peuvent aussi envoyer des actions. Tout comme précédemment, nous créons cette fois une fonction mapDispatchToProps qui indiquera comment donner accès aux actions du store via des props de notre composant connecté.

import { connect } from "react-redux";
import { deleteBeer } from "../../store/actions/beers";

class BeerDetails extends Component { ... }

const mapDispatchToProps = dispatch => {
	return {
		deleteBeer: uid => dispatch(deleteBeer(uid)),
	};
};

export default connect(
	null,
	mapDispatchToProps
)(BeerDetails);

Dans cet exemple, notre écran de <BeerDetails /> aura accès à une action deleteBeer() pour supprimer une bière spécifique du store Redux. Il pourra alors exécuter cette action via sa prop deleteBeer : this.props.deleteBeer(uid).

Aller plus loin

Je conseille la lecture de cette page de la documentation officielle de Redux pour bien comprendre son intégration dans un développement React / React Native.

Et voila ! Nos composants sont désormais un peu plus intelligents : ils peuvent accéder aux données de l’application, centralisées dans un store Redux unique. Ils peuvent également « donner des ordres » et dispatcher des actions pour altérer ces données.

La prochaine étape sera de développer un formulaire dans notre application React Native. On utilisera redux-form pour mettre en place le formulaire permettant la création d’une nouvelle bière.


Vous avez aimé cet article ?

Partagez-le sur vos réseaux sociaux en guise de remerciement :)


Laisser un commentaire