State Management
Gestion d'état avec Zustand pour l'état local et React Query pour l'état serveur.
Overview
L'application mobile utilise une approche hybride pour la gestion d'état :
Zustand
État local et persistant : authentification, préférences utilisateur, état UI
React Query
État serveur : cache, synchronisation, refetching automatique, mutations
Zustand Store
Zustand est une bibliothèque de gestion d'état minimaliste et performante. Elle est utilisée pour l'état qui doit persister entre les sessions et l'état UI global.
Pourquoi Zustand ?
- Léger : ~1KB gzipped, pas de boilerplate
- Simple : API intuitive basée sur les hooks
- Performant : Re-renders optimisés par sélecteur
- Flexible : Middleware pour persistence, devtools, etc.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import * as SecureStore from 'expo-secure-store';
// Custom storage adapter for Expo SecureStore
const secureStorage = {
getItem: async (name: string) => {
const value = await SecureStore.getItemAsync(name);
return value ? JSON.parse(value) : null;
},
setItem: async (name: string, value: unknown) => {
await SecureStore.setItemAsync(name, JSON.stringify(value));
},
removeItem: async (name: string) => {
await SecureStore.deleteItemAsync(name);
},
};
// Create store with persistence
const useStore = create(
persist(
(set, get) => ({
// State
value: 0,
// Actions
increment: () => set((state) => ({ value: state.value + 1 })),
reset: () => set({ value: 0 }),
}),
{
name: 'app-storage',
storage: secureStorage,
}
)
);Auth Store
Le store d'authentification gère l'utilisateur connecté, les tokens, et les préférences de sécurité.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import * as SecureStore from 'expo-secure-store';
import { api } from '@/services/api';
interface User {
id: string;
email: string;
name: string;
avatarUrl?: string;
kycLevel: 'NONE' | 'BASIC' | 'INTERMEDIATE' | 'ADVANCED';
}
interface AuthState {
// State
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
biometricEnabled: boolean;
isDemoMode: boolean;
// Actions
initialize: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
loginDemo: () => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
enableBiometric: () => Promise<void>;
disableBiometric: () => Promise<void>;
}
// Secure storage adapter
const secureStorage = {
getItem: async (name: string) => {
try {
const value = await SecureStore.getItemAsync(name);
return value ?? null;
} catch {
return null;
}
},
setItem: async (name: string, value: string) => {
await SecureStore.setItemAsync(name, value);
},
removeItem: async (name: string) => {
await SecureStore.deleteItemAsync(name);
},
};
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// Initial state
user: null,
isAuthenticated: false,
isLoading: true,
biometricEnabled: false,
isDemoMode: false,
// Initialize - check stored auth on app start
initialize: async () => {
try {
const token = await SecureStore.getItemAsync('authToken');
if (token) {
// Validate token with API
const { data } = await api.get('/auth/me');
set({ user: data, isAuthenticated: true });
}
} catch (error) {
// Token invalid, clear auth
await SecureStore.deleteItemAsync('authToken');
set({ user: null, isAuthenticated: false });
} finally {
set({ isLoading: false });
}
},
// Login with email/password
login: async (email, password) => {
set({ isLoading: true });
try {
const { data } = await api.post('/auth/login', { email, password });
await SecureStore.setItemAsync('authToken', data.token);
set({
user: data.user,
isAuthenticated: true,
isDemoMode: false,
});
} finally {
set({ isLoading: false });
}
},
// Demo mode - no backend required
loginDemo: async () => {
set({
user: {
id: 'demo-user',
email: 'demo@yanipay.com',
name: 'Demo User',
kycLevel: 'BASIC',
},
isAuthenticated: true,
isDemoMode: true,
isLoading: false,
});
},
// Register new account
register: async (data) => {
set({ isLoading: true });
try {
const response = await api.post('/auth/register', data);
await SecureStore.setItemAsync('authToken', response.data.token);
set({
user: response.data.user,
isAuthenticated: true,
isDemoMode: false,
});
} finally {
set({ isLoading: false });
}
},
// Logout
logout: async () => {
await SecureStore.deleteItemAsync('authToken');
await SecureStore.deleteItemAsync('biometricEnabled');
set({
user: null,
isAuthenticated: false,
biometricEnabled: false,
isDemoMode: false,
});
},
// Update user data
updateUser: (updates) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...updates } });
}
},
// Enable biometric authentication
enableBiometric: async () => {
await SecureStore.setItemAsync('biometricEnabled', 'true');
set({ biometricEnabled: true });
},
// Disable biometric authentication
disableBiometric: async () => {
await SecureStore.deleteItemAsync('biometricEnabled');
set({ biometricEnabled: false });
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => secureStorage),
partialize: (state) => ({
biometricEnabled: state.biometricEnabled,
}),
}
)
);Usage in Components
import { useAuthStore } from '@/store/authStore';
function ProfileScreen() {
// Select specific state (optimized re-renders)
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
// Or destructure multiple values
const { isAuthenticated, isDemoMode } = useAuthStore();
return (
<View>
<Text>Hello, {user?.name}</Text>
{isDemoMode && <Badge>Demo Mode</Badge>}
<Button onPress={logout}>Logout</Button>
</View>
);
}React Query
TanStack React Query gère tout l'état serveur : fetching, caching, synchronisation et mutations.
Configuration
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
retry: 2,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 1,
},
},
});
// In _layout.tsx
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}Query Examples
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { walletService } from '@/services/walletService';
// Fetch wallet balance
export function useWalletBalance() {
return useQuery({
queryKey: ['wallet', 'balance'],
queryFn: () => walletService.getBalance(),
staleTime: 30 * 1000, // 30 seconds for balance
});
}
// Fetch transactions with pagination
export function useTransactions(page: number) {
return useQuery({
queryKey: ['transactions', page],
queryFn: () => transactionService.getTransactions({ page, limit: 20 }),
keepPreviousData: true,
});
}
// Top-up mutation
export function useTopUp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (amount: number) => walletService.createTopUp({ amount }),
onSuccess: () => {
// Invalidate balance to refetch
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
},
});
}
// Usage in component
function WalletScreen() {
const { data: balance, isLoading } = useWalletBalance();
const { mutate: topUp, isPending } = useTopUp();
if (isLoading) return <Spinner />;
return (
<View>
<Text>Balance: {balance?.balance} {balance?.currency}</Text>
<Button
onPress={() => topUp(50)}
loading={isPending}
>
Top Up 50€
</Button>
</View>
);
}Common Patterns
Optimistic Updates
export function useToggleCardFreeze() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ cardId, freeze }: { cardId: string; freeze: boolean }) => {
if (freeze) {
await cardService.freezeCard(cardId);
} else {
await cardService.unfreezeCard(cardId);
}
},
// Optimistic update
onMutate: async ({ cardId, freeze }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['cards', cardId] });
// Snapshot previous value
const previousCard = queryClient.getQueryData(['cards', cardId]);
// Optimistically update
queryClient.setQueryData(['cards', cardId], (old: Card) => ({
...old,
frozen: freeze,
}));
return { previousCard };
},
// Rollback on error
onError: (err, { cardId }, context) => {
queryClient.setQueryData(['cards', cardId], context?.previousCard);
},
// Refetch on success
onSettled: (_, __, { cardId }) => {
queryClient.invalidateQueries({ queryKey: ['cards', cardId] });
},
});
}Combining Zustand with React Query
// Use Zustand for: auth state, UI preferences, app settings
// Use React Query for: API data, server state
function useUserData() {
// Auth state from Zustand
const { user, isAuthenticated } = useAuthStore();
// Server data from React Query (only when authenticated)
const { data: profile } = useQuery({
queryKey: ['user', 'profile'],
queryFn: () => userService.getProfile(),
enabled: isAuthenticated, // Only fetch when logged in
});
return {
user,
profile,
isAuthenticated,
};
}Wallet Store
Le wallet store Zustand centralise le solde, les statistiques et les mises à jour optimistes liés au portefeuille de l'utilisateur. Il implémente un TTL de cache de 2 minutes pour éviter les appels API redondants tout en garantissant une UI réactive.
Strategie de cache
lastFetchedAt (timestamp Unix) pour décider si un appel API est nécessaire. Si le cache a moins de 2 minutes, fetchBalance() retourne immédiatement sans requête réseau.import { create } from 'zustand';
import { walletService } from '@/services/walletService';
interface WalletBalance {
amount: number; // Solde en centimes
currency: string; // 'EUR'
available: number; // Solde disponible
pending: number; // Transactions en cours
}
interface WalletStats {
totalIn: number;
totalOut: number;
cashback: number;
monthlySpend: number;
}
interface WalletState {
balance: WalletBalance | null;
stats: WalletStats | null;
loading: boolean;
error: string | null;
lastFetchedAt: number | null;
// Actions
fetchBalance: (force?: boolean) => Promise<void>;
fetchStats: () => Promise<void>;
optimisticDebit: (amountCents: number) => void;
optimisticCredit: (amountCents: number) => void;
reset: () => void;
}
// Cache TTL: 2 minutes
const CACHE_TTL = 2 * 60 * 1000;
export const useWalletStore = create<WalletState>((set, get) => ({
balance: null,
stats: null,
loading: false,
error: null,
lastFetchedAt: null,
fetchBalance: async (force = false) => {
const { lastFetchedAt, loading } = get();
const now = Date.now();
// Skip fetch if cache is fresh and not forced
if (!force && lastFetchedAt && now - lastFetchedAt < CACHE_TTL) return;
if (loading) return;
set({ loading: true, error: null });
try {
// Normalized response handling (multiple API formats supported)
const response = await walletService.getBalance();
const balance: WalletBalance = {
amount: response.balance ?? response.amount ?? 0,
currency: response.currency ?? 'EUR',
available: response.available ?? response.balance ?? 0,
pending: response.pending ?? 0,
};
set({ balance, loading: false, lastFetchedAt: Date.now() });
} catch (err) {
// Fallback to mock data if API fails (demo / offline)
const mockBalance: WalletBalance = {
amount: 125000, // 1 250,00 €
currency: 'EUR',
available: 120000,
pending: 5000,
};
set({
balance: mockBalance,
loading: false,
error: 'Données en mode hors-ligne',
lastFetchedAt: Date.now(),
});
}
},
fetchStats: async () => {
try {
const stats = await walletService.getStats();
set({ stats });
} catch {
// Stats are non-critical — fail silently
}
},
// Instantly update UI before API confirmation
optimisticDebit: (amountCents) => {
const { balance } = get();
if (!balance) return;
set({
balance: {
...balance,
amount: balance.amount - amountCents,
available: balance.available - amountCents,
},
});
},
optimisticCredit: (amountCents) => {
const { balance } = get();
if (!balance) return;
set({
balance: {
...balance,
amount: balance.amount + amountCents,
available: balance.available + amountCents,
},
});
},
reset: () => set({ balance: null, stats: null, lastFetchedAt: null }),
}));Usage dans les composants
import { useWalletStore } from '@/store/walletStore';
import { useEffect } from 'react';
function WalletScreen() {
const { balance, loading, fetchBalance, optimisticDebit } = useWalletStore();
useEffect(() => {
fetchBalance(); // No-op if cache is still fresh
}, [fetchBalance]);
const handlePayment = async (amountCents: number) => {
// 1. Update UI instantly
optimisticDebit(amountCents);
// 2. Call API in background
try {
await paymentService.send({ amount: amountCents });
} catch {
// 3. Revert on failure — force-refresh from server
fetchBalance(true);
}
};
if (loading) return <ActivityIndicator />;
return (
<View>
<Text>{((balance?.amount ?? 0) / 100).toFixed(2)} €</Text>
<Button onPress={() => handlePayment(5000)}>Payer 50 €</Button>
</View>
);
}Montants en centimes
Transactions Store
Le transactions store gère la liste filtrée et paginée des opérations de l'utilisateur. Il prend en charge cinq types de transactions, quatre périodes de filtrage, une recherche textuelle, et une pagination à 20 éléments par page.
import { create } from 'zustand';
import { transactionService } from '@/services/transactionService';
type TransactionType =
| 'incoming'
| 'outgoing'
| 'cashback'
| 'transfer'
| 'refund';
type TransactionPeriod = 'today' | 'week' | 'month' | 'year';
interface Transaction {
id: string;
type: TransactionType;
amount: number; // In cents
currency: string;
label: string;
date: string; // ISO 8601
status: 'completed' | 'pending' | 'failed';
metadata?: Record<string, unknown>;
}
interface TransactionFilters {
type: TransactionType | 'all';
period: TransactionPeriod | 'all';
search: string;
}
interface TransactionsState {
transactions: Transaction[];
filters: TransactionFilters;
page: number;
hasMore: boolean;
loading: boolean;
refreshing: boolean;
error: string | null;
// Actions
fetchTransactions: (reset?: boolean) => Promise<void>;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
setFilter: (partial: Partial<TransactionFilters>) => void;
resetFilters: () => void;
invalidateOnNewTransaction: () => void;
}
const DEFAULT_FILTERS: TransactionFilters = {
type: 'all',
period: 'month',
search: '',
};
const PAGE_SIZE = 20;
export const useTransactionsStore = create<TransactionsState>((set, get) => ({
transactions: [],
filters: DEFAULT_FILTERS,
page: 0,
hasMore: true,
loading: false,
refreshing: false,
error: null,
fetchTransactions: async (reset = false) => {
const { filters, page, loading } = get();
if (loading) return;
const currentPage = reset ? 0 : page;
set({ loading: true, error: null });
try {
const { data, total } = await transactionService.list({
type: filters.type === 'all' ? undefined : filters.type,
period: filters.period === 'all' ? undefined : filters.period,
search: filters.search || undefined,
page: currentPage,
limit: PAGE_SIZE,
});
set((state) => ({
transactions: reset ? data : [...state.transactions, ...data],
page: currentPage + 1,
hasMore: (currentPage + 1) * PAGE_SIZE < total,
loading: false,
}));
} catch (err) {
set({ loading: false, error: 'Impossible de charger les transactions' });
}
},
loadMore: async () => {
const { hasMore, loading } = get();
if (!hasMore || loading) return;
await get().fetchTransactions();
},
refresh: async () => {
set({ refreshing: true });
await get().fetchTransactions(true);
set({ refreshing: false });
},
// Re-apply filters resets to page 0
setFilter: (partial) => {
set((state) => ({
filters: { ...state.filters, ...partial },
page: 0,
transactions: [],
hasMore: true,
}));
get().fetchTransactions(true);
},
resetFilters: () => {
set({ filters: DEFAULT_FILTERS, page: 0, transactions: [], hasMore: true });
get().fetchTransactions(true);
},
// Call this after a successful payment/transfer to invalidate the cache
invalidateOnNewTransaction: () => {
set({ transactions: [], page: 0, hasMore: true });
get().fetchTransactions(true);
},
}));Utilisation avec filtres
import { useTransactionsStore } from '@/store/transactionsStore';
import { useEffect } from 'react';
function TransactionListScreen() {
const {
transactions,
filters,
hasMore,
loading,
refreshing,
setFilter,
loadMore,
refresh,
} = useTransactionsStore();
useEffect(() => {
refresh();
}, []);
return (
<View>
{/* Filter bar */}
<FilterTabs
selected={filters.type}
options={['all', 'incoming', 'outgoing', 'cashback', 'transfer', 'refund']}
onChange={(type) => setFilter({ type })}
/>
<PeriodSelector
selected={filters.period}
onChange={(period) => setFilter({ period })}
/>
<SearchInput
value={filters.search}
onChange={(search) => setFilter({ search })}
/>
{/* List */}
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TransactionRow tx={item} />}
onRefresh={refresh}
refreshing={refreshing}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={hasMore ? <ActivityIndicator /> : null}
/>
</View>
);
}Invalidation automatique
invalidateOnNewTransaction()depuis votre hook de paiement ou de transfert dès qu'une opération est confirmée par le backend. Cela vide le cache local et déclenche un refetch depuis la page 0.Offline Persistence
YaniPay Mobile prend en charge un mode hors-ligne complet via une file d'attente persistante dans AsyncStorage. Toute action utilisateur effectuée sans connexion est sauvegardée localement et synchronisée automatiquement au retour de la connectivité.
AsyncStorage
File d'attente des opérations et données utilisateur en cache
SecureStore
JWT et données sensibles chiffrées via le keychain natif
Clés AsyncStorage
// AsyncStorage keys — non-sensitive data
export const STORAGE_KEYS = {
OFFLINE_QUEUE: '@yanipay:offline_queue', // Pending operations (QueuedOperation[])
USER_DATA: '@yanipay:user_data', // Cached user profile
WALLET_CACHE: '@yanipay:wallet_cache', // Last known balance
PREFERENCES: '@yanipay:preferences', // UI settings (theme, language)
} as const;
// SecureStore keys — sensitive data (keychain-encrypted)
export const SECURE_KEYS = {
AUTH_TOKEN: '@yanipay:auth_token', // JWT access token
REFRESH_TOKEN: '@yanipay:refresh_token', // JWT refresh token
BIOMETRIC_FLAG: '@yanipay:biometric_flag', // Biometric enabled flag
} as const;Structure d'une opération en file d'attente
export interface QueuedOperation {
id: string; // UUID v4
type:
| 'PAYMENT'
| 'TRANSFER'
| 'CARD_UPDATE'
| 'KYC_SUBMIT';
endpoint: string; // '/api/payments'
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
data: Record<string, unknown>; // Request body
retryCount: number; // Current attempt count
maxRetries: 3; // Hard limit
createdAt: string; // ISO 8601
status: 'pending' | 'syncing' | 'failed';
}Flux hors-ligne
Action utilisateur
│
▼
Vérifier réseau (NetInfo)
│
┌───┴───────────────┐
│ │
▼ ▼
En ligne Hors-ligne
│ │
▼ ▼
Appel API Sauvegarder dans AsyncStorage
│ Afficher badge "En attente"
▼ │
Succès Au retour du réseau
│
▼
Auto-sync (syncOfflineQueue)
│
Retry avec backoff exponentiel
1s → 2s → 4s (max 3 tentatives)
│
┌────┴────┐
│ │
▼ ▼
Succès Échec (status: 'failed')
│ │
▼ ▼
Supprimer Notifier utilisateurImplémentation du service offline
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
import { v4 as uuidv4 } from 'uuid';
import { api } from '@/services/api';
import type { QueuedOperation } from '@/types/offlineQueue';
import { STORAGE_KEYS } from '@/constants/storageKeys';
class OfflineQueueService {
private syncing = false;
// Add an operation to the queue
async enqueue(op: Omit<QueuedOperation, 'id' | 'retryCount' | 'createdAt' | 'status'>) {
const queue = await this.getQueue();
const newOp: QueuedOperation = {
...op,
id: uuidv4(),
retryCount: 0,
maxRetries: 3,
createdAt: new Date().toISOString(),
status: 'pending',
};
queue.push(newOp);
await AsyncStorage.setItem(STORAGE_KEYS.OFFLINE_QUEUE, JSON.stringify(queue));
return newOp.id;
}
async getQueue(): Promise<QueuedOperation[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEYS.OFFLINE_QUEUE);
return raw ? JSON.parse(raw) : [];
}
// Sync all pending operations — call on reconnect
async syncOfflineQueue(): Promise<void> {
if (this.syncing) return;
this.syncing = true;
const queue = await this.getQueue();
const pending = queue.filter((op) => op.status === 'pending');
for (const op of pending) {
await this.processWithBackoff(op);
}
this.syncing = false;
}
private async processWithBackoff(op: QueuedOperation): Promise<void> {
const queue = await this.getQueue();
const idx = queue.findIndex((o) => o.id === op.id);
if (idx === -1) return;
queue[idx].status = 'syncing';
await AsyncStorage.setItem(STORAGE_KEYS.OFFLINE_QUEUE, JSON.stringify(queue));
for (let attempt = 0; attempt <= op.maxRetries; attempt++) {
try {
await api.request({ method: op.method, url: op.endpoint, data: op.data });
// Success: remove from queue
const updated = queue.filter((o) => o.id !== op.id);
await AsyncStorage.setItem(STORAGE_KEYS.OFFLINE_QUEUE, JSON.stringify(updated));
return;
} catch {
if (attempt === op.maxRetries) {
queue[idx].status = 'failed';
await AsyncStorage.setItem(STORAGE_KEYS.OFFLINE_QUEUE, JSON.stringify(queue));
return;
}
// Exponential backoff: 1s → 2s → 4s
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
queue[idx].retryCount = attempt + 1;
await AsyncStorage.setItem(STORAGE_KEYS.OFFLINE_QUEUE, JSON.stringify(queue));
}
}
}
}
export const offlineQueueService = new OfflineQueueService();Operations critiques uniquement
React Query Patterns
Les hooks React Query de l'application suivent des conventions uniformes pour les clés de cache, les durées de stale-time, et le comportement de retry. Ce tableau de constantes centralise toutes les valeurs de cache.
// Centralized cache TTL constants (all values in milliseconds)
export const CACHE_TIMES = {
cards: 5 * 60 * 1000, // 5 min — cards change rarely
transactions: 2 * 60 * 1000, // 2 min — transactions update often
user: 10 * 60 * 1000, // 10 min — profile is mostly stable
kyc: 30 * 60 * 1000, // 30 min — KYC status rarely changes
loyalty: 5 * 60 * 1000, // 5 min — loyalty points / programs
notifications: 60 * 1000, // 1 min — notifications are time-sensitive
} as const;Hook useCards
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cardService } from '@/services/cardService';
import { CACHE_TIMES } from '@/constants/cacheTimes';
// Query key factory — keeps keys consistent across the codebase
export const cardKeys = {
all: ['cards'] as const,
list: () => [...cardKeys.all, 'list'] as const,
detail: (id: string) => [...cardKeys.all, 'detail', id] as const,
};
export function useCards() {
return useQuery({
queryKey: cardKeys.list(),
queryFn: () => cardService.getCards(),
staleTime: CACHE_TIMES.cards,
retry: 2,
});
}
export function useCard(cardId: string) {
return useQuery({
queryKey: cardKeys.detail(cardId),
queryFn: () => cardService.getCard(cardId),
staleTime: CACHE_TIMES.cards,
enabled: Boolean(cardId),
});
}Hook useKycStatus
import { useQuery } from '@tanstack/react-query';
import { kycService } from '@/services/kycService';
import { CACHE_TIMES } from '@/constants/cacheTimes';
export const kycKeys = {
status: ['kyc-status'] as const,
documents: ['kyc-documents'] as const,
};
export function useKycStatus() {
return useQuery({
queryKey: kycKeys.status,
queryFn: () => kycService.getStatus(),
staleTime: CACHE_TIMES.kyc,
// KYC status: PENDING | IN_REVIEW | VALIDATED | REJECTED | EXPIRED
select: (data) => ({
...data,
isValidated: data.status === 'VALIDATED',
isPending: data.status === 'PENDING' || data.status === 'IN_REVIEW',
isExpired: data.status === 'EXPIRED',
}),
});
}
// Hook to invalidate KYC after document upload
export function useKycInvalidate() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: kycKeys.status });
queryClient.invalidateQueries({ queryKey: kycKeys.documents });
};
}Hook useLoyalty
import { useQuery } from '@tanstack/react-query';
import { loyaltyService } from '@/services/loyaltyService';
import { CACHE_TIMES } from '@/constants/cacheTimes';
import { useAuthStore } from '@/store/authStore';
export const loyaltyKeys = {
programs: (userId: string) => ['loyalty', 'programs', userId] as const,
points: (userId: string) => ['loyalty', 'points', userId] as const,
};
export function useLoyaltyPrograms() {
const userId = useAuthStore((s) => s.user?.id);
return useQuery({
queryKey: loyaltyKeys.programs(userId ?? ''),
queryFn: () => loyaltyService.getPrograms(),
staleTime: CACHE_TIMES.loyalty,
enabled: Boolean(userId), // Only fetch when authenticated
});
}Query key factories
invalidateQueries({ queryKey: cardKeys.all }) sans risque de typos.Store Architecture Diagram
Le schema suivant illustre comment les trois couches de state management interagissent : Zustand pour l'etat local persistant, React Query pour l'etat serveur avec cache, et AsyncStorage / SecureStore pour la persistence physique.
authStoreUser, tokens, biometric
walletStoreBalance, stats, optimistic
notifStoreUnread count, push prefs
['cards']CardList — 5 min
['transactions']TxList — 2 min
['kyc-status']KYC — 30 min
['loyalty']Programs — 5 min
auth_tokenSecureStore
Keychain-encrypted
user_dataAsyncStorage
Cached profile
offline_queueAsyncStorage
Pending ops
Regles de repartition
// ZUSTAND — Utiliser pour :
// ✅ Etat d'authentification (user, token, biometricEnabled)
// ✅ Solde wallet + mises à jour optimistes
// ✅ Compteur de notifications non lues
// ✅ Préférences UI (thème, langue)
// ✅ État de la file d'attente hors-ligne
// REACT QUERY — Utiliser pour :
// ✅ Listes récupérées depuis l'API (cards, transactions, programs)
// ✅ Données avec cache automatique et refetch on focus
// ✅ Mutations avec rollback (useToggleCardFreeze)
// ✅ Données paginées (transactions avec loadMore)
// ASYNCSTORAGE / SECURESTORE — Utiliser pour :
// ✅ Persistance inter-sessions (auth token → SecureStore)
// ✅ File d'attente hors-ligne (offline_queue → AsyncStorage)
// ✅ Données utilisateur en cache offline (user_data → AsyncStorage)
// NE PAS utiliser :
// ❌ useState pour état partagé entre écrans → utiliser Zustand
// ❌ useEffect + fetch → utiliser React Query
// ❌ localStorage (inexistant en React Native) → AsyncStorageReferences
Documentation officielle des librairies utilisees dans ce module de gestion d'etat.
Zustand
Documentation officielle et demos interactives
zustand-demo.pmnd.rsTanStack Query
React Query — guides, API reference, exemples
tanstack.com/queryAsyncStorage
React Native AsyncStorage — installation et API
react-native-async-storage.github.ioVersions utilisees
cacheTime renomme en gcTime dans React Query 5) peuvent differer selon la version installee dans le projet.