Gérer l’état d’une application mobile React Native

UNE ASSISTANCE TECHNIQUE ? NOUS SOMMES DISPONIBLES UNE ASSISTANCE TECHNIQUE ? NOUS SOMMES DISPONIBLES

Accéder au sommaire de « Guide pratique du développeur React Native »

Avant de rentrer dans le vif du sujet, si vous vous demandez ce qu’est la gestion d’état dans une application React, voici un article sur le sujet.

La gestion de l’état dans une application mobile React est d’une grande complexité pour l’érudit tant il existe de multiples mécanismes.

  • Gestion de l’état interne des composants (setState())
  • Utilisation du Context API avec ou sans les hooks
  • Utilisation d’outils tels que Redux ou MobX

Dans cet article j’insisterai plus particulièrement sur les solutions Redux et MobX car les deux premières font partie intégrante de React. J’ajouterai également que sur ce point ma subjectivité est clairement assumée et probablement biaisée par deux décennies de développement orienté objet.

Redux or not Redux ?

Il existait, bien avant React, des centaines de Framework dédiés à la gestion de l’état, que ce soit Beans Binding en Java ou le Data Binding Bidirectionnel de .NET ou plus récemment le Two-way Databinding d’Angular ou Vue.js influencé par le pattern MVVM.  Ces architectures de binding s’appuient sur le modèle observeur/observable et font leurs preuves depuis maintenant plus de 20 ans.

Je reconnais tout à fait que le paradigme MVVM ne soit pas tout à fait adapté à la programmation fonctionnelle qui privilégie des appels de fonction pure. Mais lorsque le monde entier s’extasie en 2015 devant Redux pour ses 99 lignes de code ultra optimisés je ne peux m’empêcher de trouver le raisonnement un peu réducteur. La richesse et la puissance d’un Framework ne peut se résumer à son nombre de ligne de code interne. Si vous ne connaissez pas Redux, je vous invite à lire les articles d’introduction sur le sujet.

Je dois donc avouer que Redux ne m’a jamais réellement convaincu (c’est un doux euphémisme). Et pour cause, l’ajout d’un simple objet TODO dans une TODOList nécessite de créer un store puis un reducer suivi d’une action (ou des constructeurs d’actions), des énumérations et enfin des fonctions mapStateToProps() et mapDispatchToProps() dans chaque composant jouant le rôle d’intermédiaire entre Redux et les composants graphiques. Avec Redux, on se répète beaucoup et on copie colle souvent. Ces dernières années la communauté n’a eu de cesse de simplifier Redux ou le rendre compatible avec les appels asynchrones. On peut citer Redux-Thunk, Redux Saga, Redux Observable, Redux Promise, l’écosystème Redux est riche, très riche, probablement trop riche….

Mais la verbosité de Redux n’est pas son seul défaut. L’élément clé d’une application à état est le Store. Un store est un vaste singleton partagé par tous les composants applicatifs. Or, les applications opèrent généralement par le biais d’API qui mettent à jour des objets de présentation. Le fait de normaliser et dénormaliser systématiquement des portions de cet état a des conséquences non négligeables résumées parfaitement dans la documentation du site Redux.

 » Notice that the structure of the data is a bit complex, and some of the data is repeated. This is a concern for several reasons:

  • When a piece of data is duplicated in several places, it becomes harder to make sure that it is updated appropriately.
  • Nested data means that the corresponding reducer logic has to be more nested and therefore more complex. In particular, trying to update a deeply nested field can become very ugly very fast.
  • Since immutable data updates require all ancestors in the state tree to be copied and updated as well, and new object references will cause connected UI components to re-render, an update to a deeply nested data object could force totally unrelated UI components to re-render even if the data they’re displaying hasn’t actually changed.

Because of this, the recommended approach to managing relational or nested data in a Redux store is to treat a portion of your store as if it were a database, and keep that data in a normalized form. « 

Résoudre cette problématique consiste à recréer un flux sous une forme normalisée pour faciliter la mise à jour de grappes d’objets imbriquées. Mais pourquoi recréer quelque chose qui existe déjà sous la forme d’objets ? Nous avons tous un modèle du domaine ou un modèle de présentation. L’exemple du blog dans l’article en question est explicite. Redux nous recommande de trouver un modèle de représentation sous la forme d’entités, d’UI mais aussi de relations inter-entités s’appuyant sur des identifiants… En clair, on normalise une structure qui existe déjà sous une forme objet pour faciliter son absorption par Redux.

Je connais peu de développeurs React/Redux qui normalisent et dénormalisent leur store sur le principe précédent ou utilisent l’outil Normalizr. Et pourtant, cela devrait être un standard pour éviter d’incessantes opérations de rendus inutiles.

MobX

Même si les développeurs de MobX insistent pour ne pas apparaître comme un concurrent de Redux, il est clair que MobX répond à la même problématique que Redux pour un coût moindre. MobX s’appuie sur tous les principes du pattern Obverver/Observable dans la philosophie MVVM (Dynamic proxy dans sa version 5).

Il est possible de développer une application entière avec une gestion d’état simplement en maîtrisant deux décorateurs: @observable et @observer.

Les composants graphiques souhaitant réagir aux changements du store sont annotés @observer. Le modèle du domaine, le store ou n’importe quel objet de transfert (Data Transfer Object) est annoté @observable.

Voici dans Dossardeur le code de l’écran affichant la liste des épreuves.

import {CompetitionEntity} from "../../sdk/models";
import {apiCompetitions} from "../sdk/api-client";
(...)

export const ListCompetitionScreen = observer(({navigation}) => {
    const store = useStore();
    useEffect(() => {
        const runSearchQuery = async () => {
            filterList(store.searchQuery)
        };

        runSearchQuery()
    }, [store.filteredCompetitions])

   const switchBarVisible = () => {
        return (store.competitionFilter.displayPast && store.competitionFilter.displayFuture)
    }

    return (
        <SafeAreaView style={{flex:1}}>
            <FlatList
                getItemLayout={(data, index) => (
                    {length: FLLENGTH, offset: FLOFFSET * index, index}
                )}
                ref={flatListRef}
                onRefresh={async () => {
                        store.filteredCompetitions = await apiCompetitions.getCompetitionsByFilter({competitionFilter: store.competitionFilter});
                }}
                refreshing={false}
                data={store.filteredCompetitions}
                ...
              </FlatList>
         <SateAreaView>
   )
}

Cet écran réalise une opération API consistant à rechercher les épreuves du calendrier. Une fois les épreuves typées insérées dans le store, la FlatList est rachaichie instantanément.

Quant au store, il est déclaré sur le code ci-dessous. Il est bien évidemment typé à l’aide de TypeScript pour éviter les déconvenus à l’utilisation et injecté dans n’importe quel écran grâce au Hook useStore().

import React from "react";
import {CompetitionEntity, CompetitionFilter} from "../sdk/models";

export const createStore = () => {
    const store = {
        currentLocation: {latitude:43.6043,longitude:1.4437},
        searchQuery:'',
        searchDateOrder: DATE_ORDER.ALL,
        subFilteredCompetitions:<CompetitionEntity[]>[],
        competitionFilter: <CompetitionFilter>{
            competitionTypes: ['ROUTE'],
            fedes: ['FSGT'],
            depts: [],
            displayFuture:true,
            openedNL:false,
            openedToOtherFede:false,
            displayPast:true,
            displaySince:365,
        },
        maintenanceMode:false,
        maintenanceMessage:'',
        competitionFilterPending: <CompetitionFilter>{},
        filteredCompetitions: <CompetitionEntity[]>[],
        currentCompetition: null,
        loading: false,
        showCompetitionPopup:false,
        goMapPopupVisible:false,
        goMapPopupOptions:{},
        goMapCompetition:null,
        tutorialShowed:false
    };
    return store;
}

export type GlobalState = ReturnType<typeof createStore>;
export const storeContext = React.createContext<GlobalState | null>(null);
export const useStore = () => {
    const store = React.useContext(storeContext)
    if (!store) {
        throw new Error('useStore must be used within a StoreProvider.')
    }
    return store
}

La seule utilisation de l’ordre observer(()=><Composant/>) suffit pour indiquer que le composant souhaite réagir à tout changement du store, c’est l’équivalent du décorateur @observer.

Les types utilisés dans le code précédent sont les types déclarés dans les entités générés par Swagger et Open API. Lorsqu’une entité est mise à jour dans notre API, le SDK est re-généré et une erreur de compilation apparaît instantanément si le typage n’est pas respecté. Il y a une correspondance directe entre les références de nos objets du domaine et la gestion de l’état. Nul besoin de normaliser quoique ce soit.

 » Since data doesn’t need to be normalized, and MobX automatically tracks the relations between state and derivations, you get referential integrity for free. Rendering something that is accessed through three levels of indirection ? No problem, MobX will track them and re-render whenever one of the references changes. As a result staleness bugs are a thing of the past. This makes the library very suitable for very complex domain models. (At Mendix, for example, there are ~500 different domain classes in a single application.) » (source : https://github.com/mobxjs/mobx)

Si vous n’êtes pas convaincus, comparez simplement l’application Todo de MobX avec celle de Redux. Dans cet autre article, l’auteur conclut, exemple de code à l’appui que mobX requiert 2x moins de code que Redux.

Simplicité apparente de MobX

Attention, MobX semble tellement transparent qu’il est facile de tomber dans de mauvaises pratiques. Gardez à l’esprit que la philosophie de MobX consiste à enrichir votre modèle objet avec des opérations de notification de changement d’état. Même si l’intégrité référentielle est préservée, le type d’origine des objets est modifié pour y ajouter des caractéristiques d’observation.

MobX fournit donc des primitives qui vont vous permettre de rendre une référence (objet, tableau, type primitif, …) observable.

Voici un exemple illustrant le procédé d’enrichissement, on voit bien l’utilisation systématique de la fonction observable() :

const map = observable.map({ key: "value" })
map.set("key", "new value")

const list = observable([1, 2, 4])
list[2] = 3

const person = observable({
    firstName: "Clive Staples",
    lastName: "Lewis"
})
person.firstName = "C.S."

const temperature = observable.box(20)
temperature.set(25)

Par ailleurs, avec MobX, il est tellement facile d’interagir ou de modifier le store avec effet immédiat sur les composants graphiques qu’on peut facilement tomber dans le piège consistant à passer la référence du store dans tous ces écrans et faire l’impasse sur le découplage avec l’état local et les propriétés (this.props) des composants.

MobX peut vite encourager au code spaghetti et complexifier et rendre difficile la testabilité des composants.

Bibliographie

MobX vs Redux with React: A noob’s comparison and questions

MobX vs Redux: Comparing the Opposing Paradigms

UNE ASSISTANCE TECHNIQUE ? NOUS SOMMES DISPONIBLES UNE ASSISTANCE TECHNIQUE ? NOUS SOMMES DISPONIBLES