chocobonplan est le leader français des bons plans en ligne. Des milliers d’utilisateurs ouvrent l’app chaque jour pour trouver des promotions, des codes promo, des ventes flash. Et pendant les fêtes — Black Friday, Noël, soldes — le trafic explose. L’app existante fonctionnait, mais elle avait atteint ses limites : une base de code JavaScript sans types, un React Native vieillissant, des dépendances obsolètes, et une architecture qui rendait chaque nouvelle fonctionnalité plus risquée que la précédente.
Le client nous a confié la réécriture complète. Pas une migration progressive — un from scratch, avec un objectif clair : construire une app qui tient dans le temps. Qui supporte les pics de charge, qui reste maintenable quand un nouveau développeur rejoint le projet dans un an, et qui permette de livrer des fonctionnalités sans craindre les régressions.
Cet article détaille les choix d’architecture qu’on a faits et pourquoi. C’est un retour d’expérience sur la conception d’une app React Native de production — pas un tutoriel, mais un ensemble de décisions réfléchies qu’on est prêts à défendre.
L’audit de l’existant : pourquoi réécrire
Avant de jeter du code, on a passé deux semaines à auditer l’app existante. Ce qu’on a trouvé :
Pas de TypeScript. Toute la base en JavaScript pur. Des props passées sans contrat, des réponses API consommées sans validation, des bugs qui ne se manifestaient qu’en production parce qu’aucun type ne les empêchait de compiler.
React Native obsolète. Plusieurs versions majeures de retard, avec des patches manuels pour contourner des bugs natifs. Chaque montée de version était un projet en soi, donc elles ne se faisaient plus.
État global sans structure. Un mélange de Redux vanilla, de state local, et de AsyncStorage utilisé comme base de données. Pas de persistence sélective, pas de garbage collection — l’état grossissait indéfiniment.
Navigation fragile. Un routage custom qui ne supportait pas le deep linking et qui avait des edge cases non gérés sur Android.
Le diagnostic était clair : il fallait repartir de zéro. Pas par dogmatisme — par pragmatisme. Le coût de la mise à niveau de l’existant dépassait celui d’une réécriture propre.
Le socle technique
On part sur un stack qu’on maîtrise et qui a fait ses preuves :
React Native avec Expo en mode bare workflow. Expo apporte la gestion des builds natifs (EAS Build), les mises à jour OTA, et un écosystème de modules natifs pré-configurés. Le bare workflow nous laisse le contrôle total sur les projets natifs iOS et Android quand on en a besoin.
TypeScript en mode strict: true. Pas de compromis. Chaque prop est typée, chaque réponse API a un type, chaque reducer connaît la forme de son état. Le coût initial est réel — on tape plus de code. Le gain sur la durée est incomparable : les bugs de type disparaissent, l’autocomplétion fonctionne, et un nouveau développeur comprend les contrats sans lire la documentation (qui n’existe jamais).
Hermes comme moteur JavaScript sur les deux plateformes. Le gain en temps de démarrage et en consommation mémoire est mesurable — critique pour une app de contenu qui charge des dizaines d’offres avec des images.
Le state management : pourquoi Rematch
Le choix du state management est la décision architecturale la plus structurante d’une app React Native. On a évalué les options courantes — Redux Toolkit, Zustand, MobX, Jotai — et on a choisi Rematch.
Rematch est un wrapper au-dessus de Redux qui élimine le boilerplate tout en gardant la puissance du modèle Redux (middleware, persistence, devtools). Chaque “model” Rematch regroupe l’état initial, les reducers, les effects (actions asynchrones), et les selectors dans un seul fichier typé.
Pour une app comme chocobonplan, le modèle Redux est le bon choix. On a besoin de :
- Persistence sélective — certaines parties de l’état doivent survivre à un redémarrage, d’autres non.
- Middlewares — sept middlewares custom qui interceptent les actions pour la télémétrie, le push, le garbage collection.
- Sérialisabilité — l’état est un objet plat, inspectable, loggable, reproductible.
- Découplage — les composants UI ne connaissent pas les effects asynchrones, ils dispatch et reçoivent.
Zustand serait plus simple pour un petit projet. MobX serait plus réactif. Mais pour une app de cette taille avec ces contraintes, la structure de Redux (via Rematch) paye sur la durée.
Les 8 modèles
Chaque modèle est un fichier TypeScript auto-suffisant. L’état de l’app est la composition de ces 8 tranches :
// store/index.ts
import { init, RematchDispatch, RematchRootState } from '@rematch/core'
import immerPlugin from '@rematch/immer'
import persistPlugin from '@rematch/persist'
import { models, RootModel } from './models'
import { middlewares } from './middlewares'
import { persistConfig } from './persistConfig'
let store = init<RootModel>({
models,
plugins: [persistPlugin(persistConfig), immerPlugin()],
redux: { middlewares },
})
export type Store = typeof store
export type Dispatch = RematchDispatch<RootModel>
export type RootState = RematchRootState<RootModel>
Le plugin Immer est ce qui rend les reducers lisibles. Au lieu de recréer des objets imbriqués à chaque mise à jour (le cauchemar classique de Redux), on mute directement le draft :
// store/models/OffersModel.ts — extrait
reducers: {
offersLoaded(state, payload: { list: OfferListKey; offers: Offer[]; page: number }) {
let { list, offers, page } = payload
// Avec Immer, on mute directement — pas de spread
for (let offer of offers) {
state.offerById[offer.id] = offer
}
let listInfo = state.lists[list]
listInfo.offerIds = page === 1
? offers.map(o => o.id)
: [...listInfo.offerIds, ...offers.map(o => o.id)]
listInfo.loading = false
listInfo.page = page
},
}
Le modèle Offers : le cœur de l’app
L’offre est l’entité centrale. Le modèle OffersModel gère un dictionnaire d’offres (indexé par ID) et des listes qui référencent ces offres par ID. C’est le pattern de normalisation classique de Redux, mais avec une particularité : les listes sont multiples et dynamiques.
type OfferState = {
offerById: Record<string, Offer>
lists: {
selection: OfferListInfo // Bons plans du moment
live: OfferListInfo // En cours
upcoming: OfferListInfo // À venir
filtered: OfferListInfo // Par catégorie
search: OfferListInfo // Résultats de recherche
}
alerts: {
loadingIds: number[]
lists: {
myAlerts: AlertInfo
preorderLive: AlertInfo
preorderPast: AlertInfo
}
userBellIds: number[]
}
}
Ce design a un avantage crucial : une même offre apparaît dans plusieurs listes (sélection + live + alertes) sans être dupliquée en mémoire. Le offerById est la source de vérité unique, et chaque liste ne contient que des IDs.
Mais ce design a un coût : le offerById grossit au fil de la navigation. Un utilisateur qui parcourt des dizaines de pages accumule des centaines d’offres en mémoire. C’est pour ça qu’on a construit un garbage collector.
Le garbage collector d’état
C’est un des middlewares les plus intéressants du projet. Le problème : quand un utilisateur navigue entre les listes, les anciennes offres restent dans offerById même si aucune liste ne les référence plus. Sur une session longue, la mémoire augmente linéairement.
La solution : un middleware qui, après chaque action, calcule l’ensemble des offres “en usage” (référencées par au moins une liste, une alerte, ou une notification) et supprime les autres.
// store/middlewares/garbageCollectorMiddleware.ts
let debounceTimer: NodeJS.Timeout | null = null
let garbageCollectorMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
let result = next(action)
// Debounce à 2 secondes — on ne GC pas à chaque action
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
let rootState = store.getState()
let usedIds = collectUsedOfferIds(rootState)
store.dispatch({ type: 'offers/garbageCollect', payload: usedIds })
}, 2000)
return result
}
function collectUsedOfferIds(state: RootState): Set<number> {
let ids = new Set<number>()
// Toutes les listes d'offres
for (let list of Object.values(state.offers.lists)) {
for (let id of list.offerIds) ids.add(id)
}
// Toutes les alertes
for (let alertList of Object.values(state.offers.alerts.lists)) {
for (let id of alertList.offerIds) ids.add(id)
}
for (let id of state.offers.alerts.userBellIds) ids.add(id)
return ids
}
Le reducer correspondant dans OffersModel :
garbageCollect(state, usedIds: Set<number>) {
for (let id of Object.keys(state.offerById)) {
if (!usedIds.has(Number(id))) {
delete state.offerById[id]
}
}
}
Le debounce à 2 secondes est le bon compromis : assez long pour ne pas tourner en boucle pendant un scroll rapide, assez court pour libérer la mémoire avant qu’elle ne devienne un problème. Sur une session de 30 minutes, le GC maintient le offerById sous 200 entrées au lieu de 2000+.
La persistence sélective
Pas tout l’état mérite d’être persisté. Un token d’authentification, oui. Une liste d’offres en cours de chargement, non. La configuration des notifications, oui. Le flag “l’app est en arrière-plan”, évidemment non.
On utilise redux-persist avec des transforms conditionnels :
// store/persistConfig.ts
let persistConfig = {
key: 'chocobonplan',
storage: AsyncStorage,
whitelist: ['user', 'carousel', 'categories', 'notifications', 'offers'],
transforms: [
userConditionalStorage,
notificationsConditionalStorage,
offersConditionalStorage,
],
throttle: 200,
}
Le transform userConditionalStorage ne persiste l’état utilisateur que si l’utilisateur est authentifié. Si l’état est signed-out ou anonymous, on ne persiste rien — au prochain lancement, l’app repart sur l’état initial au lieu de restaurer un état “déconnecté” qui n’a aucune valeur.
Le offersConditionalStorage est plus subtil : il ne persiste les offres que si l’utilisateur est connecté (les offres personnalisées n’ont de sens que dans le contexte d’un compte). Un utilisateur anonyme repart à zéro à chaque lancement.
Le throttle: 200 évite de sérialiser vers AsyncStorage à chaque micro-action. 200ms de debounce, c’est imperceptible pour l’utilisateur et ça divise par 10 les écritures disque pendant un scroll.
Les selectors : proxy-memoize
Les selectors sont le pont entre l’état global et les composants. On utilise proxy-memoize plutôt que reselect — c’est plus léger et ne nécessite pas de déclarer manuellement les dépendances :
// store/selectors/selectApiClient.ts
import memoize from 'proxy-memoize'
let selectApiClient = memoize((rootState: RootState) => {
let accessToken =
rootState.user.state === 'loaded' ? rootState.user.token : null
let userId =
rootState.user.state === 'loaded' ? rootState.user.id : undefined
return new ApiClient(accessToken, userId)
})
proxy-memoize utilise un Proxy pour tracker automatiquement quelles propriétés de l’état sont lues. Si rootState.user.token n’a pas changé, le selector retourne le même ApiClient sans recalculer. Pas besoin de lister les dépendances comme avec createSelector — le proxy les détecte.
On a ~20 selectors qui couvrent toute la surface de lecture de l’état. Chaque composant qui lit le store passe par un selector mémoïsé. Aucun composant n’accède directement à state.offers.offerById[id] — il utilise selectOfferById(id).
La pipeline de middlewares
Sept middlewares s’exécutent dans l’ordre après chaque action Redux. C’est une des forces du modèle Redux : on peut brancher de la logique transverse sans toucher au code métier.
Le middleware de télémétrie intercepte les actions d’authentification pour signaler les sign-in/sign-out à Firebase Crashlytics et Pushwoosh. Le code métier dans UserModel ne sait rien de Firebase — il dispatch userAuthenticated, et le middleware fait le reste :
let telemetryMiddleware: Middleware = (store) => (next) => (action) => {
let result = next(action)
let state = store.getState()
switch (action.type) {
case 'user/userAuthenticated':
signalSignInToTelemetrySDKs(
state.user.context,
state.user.method,
state.user,
)
break
case 'user/signInAnonymously':
signalAnonymousSignInToTelemetry()
break
case 'user/authCanceled':
signalSignOutToTelemetry()
break
}
return result
}
Ce pattern de séparation est au cœur de notre architecture. Les modèles contiennent la logique métier. Les middlewares contiennent les effets de bord infra (analytics, push, persistence). Les composants ne connaissent ni l’un ni l’autre — ils dispatch des actions et lisent des selectors.
Les hooks custom : AbortSignal everywhere
Un des patterns les plus originaux de cette app est l’utilisation systématique d’AbortSignal dans les hooks d’effet. Le problème qu’on résout : quand un composant est démonté pendant qu’une requête est en cours, React affiche un warning “Can’t perform a React state update on an unmounted component”. C’est un symptôme de fuite mémoire.
On a créé une famille de hooks qui wrappent les patterns courants avec un AbortController :
// utils/hooks/useEffectWithSignal.ts
export default function useEffectWithSignal(
effect: (signal: AbortSignal) => void | Promise<void>,
deps: DependencyList = [],
) {
useEffect(() => {
let controller = new AbortController()
effect(controller.signal)?.catch?.((error) => {
if (error.name !== 'CanceledError') sendErrorToTelemetry(error)
})
return () => controller.abort()
}, deps)
}
Et sa variante navigation-aware, qui ré-exécute l’effet quand l’écran revient en focus :
// utils/hooks/useFocusEffectWithSignal.ts
export default function useFocusEffectWithSignal(
effect: (signal: AbortSignal) => void | Promise<void>,
deps: DependencyList = [],
) {
useFocusEffect(
useCallback(() => {
let controller = new AbortController()
async function run() {
try {
await effect(controller.signal)
} catch (error) {
if (!(error instanceof CanceledError)) {
console.warn('[useFocusEffectWithSignal] error:', error)
}
}
}
run()
return () => controller.abort()
}, deps),
)
}
L’usage dans un écran est immédiat :
function OfferListScreen() {
let dispatch = useDispatch<Dispatch>()
// Charge les offres quand l'écran prend le focus,
// annule si on navigue ailleurs avant la fin
useFocusEffectWithSignal(async (signal) => {
await dispatch.offers.loadOffers({ list: 'live', signal })
}, [])
// Rafraîchit les offres quand l'app revient au premier plan
useInForegroundEffect(() => {
dispatch.offers.loadOffers({ list: 'live' })
})
// ...
}
Le useInForegroundEffect est un autre hook custom qui détecte quand l’app revient du background (l’utilisateur a switché vers une autre app et revient). Il est branché sur le state AppModel.inBackground mis à jour par le middleware updateAppState.
L’authentification multi-méthodes
L’app supporte cinq modes de connexion : email/mot de passe, Apple, Facebook, Twitter, et anonyme. C’est un des aspects les plus complexes du projet, parce que chaque provider a son propre flow, ses propres erreurs, et ses propres edge cases.
Le modèle UserModel gère un état discriminé :
type UserModelState = SignedOutState | CurrentUser
type SignedOutState = {
state: 'signed-out' | 'anonymous'
} & LoadingState
type CurrentUser = UserProfile & {
state: 'loaded'
method: 'email' | 'apple' | 'facebook' | 'twitter' | 'restored'
token: string
appleUserId?: string
loading: boolean
}
Le method: 'restored' mérite une explication. Quand l’app démarre et que redux-persist restaure un profil depuis AsyncStorage, on ne sait pas comment l’utilisateur s’était connecté la dernière fois. On marque method: 'restored' jusqu’à ce qu’on revalide le token avec l’API. C’est un état transitoire qui évite de confondre “connecté et vérifié” avec “restauré mais pas encore vérifié”.
Chaque provider auth est encapsulé dans un service dédié (AppleAuthenticationService, FacebookAuthenticationService, TwitterAuthenticationService) qui expose une interface uniforme. Les containers (AppleLoginButton, FacebookLoginButton) connectent le service au store :
// containers/AppleLoginButton.tsx
function AppleLoginButton() {
let dispatch = useDispatch<Dispatch>()
let apiClient = useSelector(selectApiClient)
async function signIn() {
dispatch.user.authStarted({ method: 'apple', context: 'login' })
try {
let credential = await appleService.signInOrUp()
let profile = await apiClient.signInWithApple(credential)
dispatch.user.userAuthenticated({
userProfile: profile,
appleUserId: credential.user,
})
} catch (error) {
dispatch.user.authCanceled({ error })
}
}
return <AppleButton onPress={signIn} />
}
Le pattern est identique pour les trois providers sociaux. L’uniformité facilite les tests et la maintenance — ajouter un nouveau provider (Google, par exemple) est une affaire de quelques heures : un service, un container, un appel API.
Le design system responsive
L’app tourne sur des écrans de 4 pouces (iPhone SE) à 6.7 pouces (iPhone 14 Pro Max), plus tous les formats Android. Le design system est construit autour d’une fonction scaleResponsiveValue qui adapte les valeurs en fonction de la taille de l’écran :
// config/theme/textVariants.ts
let textVariants = {
h1: { fontSize: scaleResponsiveValue({ medium: 23, default: 20 }) },
h2: { fontSize: scaleResponsiveValue({ medium: 18, default: 16 }) },
h3: { fontSize: 14 },
defaults: { fontSize: 13 },
footnote: { fontSize: 13 },
}
Les couleurs sont centralisées dans un fichier de constantes :
// config/theme/colors.ts
export default {
primary: '#ff4e00',
primaryDimmed: '#f59b76',
max: '#FFD700',
green: '#52BEA3',
red: '#d72729',
// ...
}
Ce n’est pas un design system sophistiqué — pas de tokens, pas de thème dark mode. Mais c’est suffisant pour la cohérence visuelle de l’app, et c’est ce qui compte. On a résisté à la tentation de construire un système de thème complet alors que l’app n’a qu’un seul thème et n’en aura probablement pas d’autre. Le bon niveau d’abstraction, c’est celui dont on a besoin — pas celui qui impressionne en code review.
Le layer API
L’ApiClient est un singleton Axios configuré avec le token Bearer de l’utilisateur courant. Il est reconstruit par le selector selectApiClient à chaque changement de token :
export default class ApiClient {
private axios: AxiosInstance
constructor(
private accessToken: string | null,
private userId: number | null = null,
apiBaseUrl: string = defaultApiBaseUrl,
) {
let headers = accessToken
? { Authorization: `Bearer ${accessToken}` }
: {}
this.axios = axios.create({
baseURL: apiBaseUrl,
timeout: apiTimeoutMs,
headers,
})
if (__DEV__) {
let curlirize = require('axios-curlirize').default
curlirize(this.axios)
}
}
async fetchOffers(list: OfferListKey, page: number, signal?: AbortSignal) {
// ...
}
async signInWithEmailAndPassword(email: string, password: string) {
// ...
}
async toggleAlert(offerId: number, value: boolean) {
// ...
}
// ... 15+ méthodes
}
Le curlirize en mode dev est un détail qui fait gagner des heures de debug : chaque requête Axios est loggée comme une commande curl complète dans la console. Quand un appel API échoue, on copie-colle la commande dans un terminal pour tester en isolation. C’est un outil de debug qu’on met sur tous nos projets depuis.
Les erreurs sont typées avec un ApiError custom qui distingue les erreurs réseau, les erreurs serveur, les erreurs d’authentification et les erreurs de validation. Le modèle qui appelle l’API peut réagir différemment selon le type : une erreur réseau mérite un retry, une erreur 401 mérite une déconnexion, une erreur 422 mérite un message utilisateur.
La navigation : deep linking et structure imbriquée
La navigation est bâtie sur React Navigation v6, avec trois niveaux d’imbrication :
RootStack
├── LandingScreen
├── SignInScreen
├── SignUpScreen
├── ResetPasswordScreen
├── App (BottomTabs)
│ ├── HomeTab (MaterialTopTabs)
│ │ ├── Selection
│ │ ├── Live
│ │ └── Upcoming
│ ├── MyOffersTab (MaterialTopTabs)
│ │ ├── MyAlerts
│ │ └── AllAlerts
│ ├── MyWaitListTab (MaterialTopTabs)
│ │ ├── Live
│ │ └── Past
│ └── AccountTab (Stack)
│ ├── AccountSections
│ └── AccountDetails
├── OfferDetails { offerId }
├── CommentList { offerId }
├── AddComment { offerId }
└── NotificationsScreen
Le RootStack gère la séparation auth/app : si l’utilisateur n’est pas connecté, seuls les écrans d’authentification sont accessibles. Le OfferDetails est au niveau root (pas dans un tab) pour pouvoir être poussé depuis n’importe quel onglet.
Les types de navigation sont générés automatiquement par TypeScript :
type RootStackParamList = {
LandingScreen: undefined
SignInScreen: { redirectTo?: PresentRedirectScreens }
App: undefined
OfferDetails: { offerId: number; offerLevel?: number; openComments?: boolean }
CommentListScreen: { offerId: number; link: string; offerLevel: number }
// ...
}
// Navigation typée — impossible de passer un mauvais param
navigation.navigate('OfferDetails', { offerId: 42, offerLevel: 3 })
Les textes : i18n sans i18n
L’app est en français uniquement. Pas besoin de react-i18next, de fichiers PO, ou d’un service de traduction. Mais on a quand même centralisé tous les textes dans un arbre de constantes typé :
// config/texts/fr/
genericTexts // Labels communs
screenTexts // Textes par écran
buttonTexts // Labels de boutons
errorTexts // Messages d'erreur
formTexts // Labels de formulaires
servicesTexts // Textes des services
navigatorsTexts // Titres de navigation
externalLinks // URLs pré-configurées
Ce n’est pas de l’i18n, c’est de la centralisation. Le gain : aucune chaîne en dur dans les composants. Si le client veut changer “Mes alertes” en “Mes bons plans”, c’est un seul fichier à toucher, pas 12 écrans. Et si un jour l’app passe en multilingue, la migration sera triviale — les textes sont déjà séparés du code.
Ce qu’on a appris
Rematch est le bon niveau d’abstraction au-dessus de Redux. On garde la puissance (middlewares, persistence, devtools) sans le boilerplate (action types, action creators, switch/case). Pour une app de cette taille, c’est le sweet spot.
Le garbage collector d’état est indispensable dès qu’on a un dictionnaire normalisé qui grossit. Sans lui, la mémoire de l’app augmente de 20 Mo sur une session de 30 minutes. Avec, elle reste stable sous 5 Mo d’état Redux.
Les hooks à AbortSignal préviennent une catégorie entière de bugs. Les warnings “state update on unmounted component” ont disparu. Les requêtes réseau sont annulées proprement à chaque démontage. C’est devenu un pattern qu’on applique systématiquement.
TypeScript strict est non-négociable. Sur un projet de cette taille (~250 fichiers TypeScript), le mode strict a attrapé des dizaines de bugs avant qu’ils n’arrivent en production. Le surcoût d’écriture est absorbé par la vélocité de maintenance.
L’app est en production. Elle a tenu le Black Friday, les soldes de janvier, et les ventes flash quotidiennes sans incident. Le code est lisible par un nouveau développeur en une journée — pas parce qu’il est simple, mais parce que chaque décision est cohérente et chaque pattern est appliqué uniformément.
Nos autres retours d’expérience en React Native : l’upload vidéo avec compression, et pour un tout autre stack mobile, l’app Sircle en RxSwift.