@buivietphi/skill-mobile-mt 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,422 @@
1
+ # Data Flow Patterns — Fetching, Caching, Real-Time
2
+
3
+ > On-demand module. Loaded when implementing pagination, optimistic updates, cache invalidation, or real-time features.
4
+ > Contains production patterns that handle edge cases (race conditions, stale data, error recovery).
5
+
6
+ ---
7
+
8
+ ## Pagination — Infinite Scroll
9
+
10
+ ### React Native (TanStack Query)
11
+
12
+ ```typescript
13
+ // hooks/useProductList.ts
14
+ import { useInfiniteQuery } from '@tanstack/react-query';
15
+
16
+ interface ProductPage {
17
+ items: Product[];
18
+ nextCursor: string | null; // null = no more pages
19
+ }
20
+
21
+ export function useProductList(category?: string) {
22
+ return useInfiniteQuery({
23
+ queryKey: ['products', { category }],
24
+ queryFn: async ({ pageParam }) => {
25
+ const res = await productService.getProducts({
26
+ cursor: pageParam,
27
+ limit: 20,
28
+ category,
29
+ });
30
+ return res as ProductPage;
31
+ },
32
+ initialPageParam: undefined as string | undefined,
33
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
34
+ staleTime: 5 * 60 * 1000, // 5 minutes
35
+ });
36
+ }
37
+
38
+ // ProductListScreen.tsx
39
+ function ProductListScreen() {
40
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error, refetch } = useProductList();
41
+
42
+ const products = data?.pages.flatMap(page => page.items) ?? [];
43
+
44
+ if (isLoading) return <ProductListSkeleton />;
45
+ if (error) return <ErrorView error={error} onRetry={refetch} />;
46
+ if (products.length === 0) return <EmptyView message="No products found" />;
47
+
48
+ return (
49
+ <FlatList
50
+ data={products}
51
+ keyExtractor={(item) => item.id}
52
+ renderItem={({ item }) => <ProductCard product={item} />}
53
+ onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }}
54
+ onEndReachedThreshold={0.5}
55
+ ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
56
+ refreshControl={<RefreshControl refreshing={false} onRefresh={refetch} />}
57
+ />
58
+ );
59
+ }
60
+ ```
61
+
62
+ ### Offset-Based Pagination (alternative)
63
+
64
+ ```typescript
65
+ // When API uses page numbers instead of cursors
66
+ export function useProductListPaged() {
67
+ return useInfiniteQuery({
68
+ queryKey: ['products'],
69
+ queryFn: async ({ pageParam = 1 }) => {
70
+ return productService.getProducts({ page: pageParam, limit: 20 });
71
+ },
72
+ initialPageParam: 1,
73
+ getNextPageParam: (lastPage, allPages) => {
74
+ // No more pages if last page returned fewer items than limit
75
+ return lastPage.items.length >= 20 ? allPages.length + 1 : undefined;
76
+ },
77
+ });
78
+ }
79
+ ```
80
+
81
+ ### Flutter (Riverpod)
82
+
83
+ ```dart
84
+ // providers/product_list_provider.dart
85
+ @riverpod
86
+ class ProductList extends _$ProductList {
87
+ String? _nextCursor;
88
+ bool _hasMore = true;
89
+
90
+ @override
91
+ FutureOr<List<Product>> build() => _fetchPage(null);
92
+
93
+ Future<List<Product>> _fetchPage(String? cursor) async {
94
+ final page = await ref.read(productRepoProvider).getProducts(cursor: cursor);
95
+ _nextCursor = page.nextCursor;
96
+ _hasMore = page.nextCursor != null;
97
+ return page.items;
98
+ }
99
+
100
+ Future<void> loadMore() async {
101
+ if (!_hasMore || state is AsyncLoading) return;
102
+ final currentItems = state.value ?? [];
103
+ final newItems = await _fetchPage(_nextCursor);
104
+ state = AsyncData([...currentItems, ...newItems]);
105
+ }
106
+
107
+ bool get hasMore => _hasMore;
108
+ }
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Optimistic Updates with Rollback
114
+
115
+ ### React Native (TanStack Query)
116
+
117
+ ```typescript
118
+ // hooks/useToggleFavorite.ts
119
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
120
+
121
+ export function useToggleFavorite() {
122
+ const queryClient = useQueryClient();
123
+
124
+ return useMutation({
125
+ mutationFn: (productId: ProductId) => productService.toggleFavorite(productId),
126
+
127
+ // Optimistic update: change UI before API responds
128
+ onMutate: async (productId) => {
129
+ // Cancel outgoing refetches (they would overwrite our optimistic update)
130
+ await queryClient.cancelQueries({ queryKey: ['product', productId] });
131
+
132
+ // Snapshot the previous value
133
+ const previous = queryClient.getQueryData<Product>(['product', productId]);
134
+
135
+ // Optimistically update the cache
136
+ queryClient.setQueryData<Product>(['product', productId], (old) =>
137
+ old ? { ...old, isFavorite: !old.isFavorite } : old
138
+ );
139
+
140
+ // Return snapshot for rollback
141
+ return { previous };
142
+ },
143
+
144
+ // On error: rollback to snapshot
145
+ onError: (_error, productId, context) => {
146
+ if (context?.previous) {
147
+ queryClient.setQueryData(['product', productId], context.previous);
148
+ }
149
+ },
150
+
151
+ // On success or error: refetch to ensure server truth
152
+ onSettled: (_data, _error, productId) => {
153
+ queryClient.invalidateQueries({ queryKey: ['product', productId] });
154
+ },
155
+ });
156
+ }
157
+
158
+ // Usage: simple one-liner
159
+ // const toggleFavorite = useToggleFavorite();
160
+ // <HeartButton onPress={() => toggleFavorite.mutate(product.id)} />
161
+ ```
162
+
163
+ ### Zustand Optimistic Pattern
164
+
165
+ ```typescript
166
+ // stores/useCartStore.ts
167
+ addItem: async (product: Product, quantity: number) => {
168
+ // 1. Snapshot current state
169
+ const snapshot = get().items;
170
+
171
+ // 2. Optimistic update
172
+ set(state => {
173
+ state.items.push({ productId: product.id, quantity, price: product.price });
174
+ });
175
+
176
+ // 3. API call
177
+ try {
178
+ await cartService.addItem(product.id, quantity);
179
+ } catch {
180
+ // 4. Rollback on error
181
+ set(state => { state.items = snapshot; });
182
+ throw new Error('Failed to add to cart. Please try again.');
183
+ }
184
+ },
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Cache Invalidation Strategies
190
+
191
+ ### When to Invalidate
192
+
193
+ ```
194
+ AFTER MUTATION (user changes data):
195
+ → Invalidate the entity that was changed
196
+ → Invalidate lists that contain that entity
197
+
198
+ Example: User edits profile
199
+ queryClient.invalidateQueries({ queryKey: ['user', userId] });
200
+ queryClient.invalidateQueries({ queryKey: ['users'] }); // list
201
+
202
+ ON NAVIGATION BACK (may be stale):
203
+ → Use refetchOnWindowFocus: true (TanStack Query default)
204
+ → Or manual: useFocusEffect(() => { refetch(); });
205
+
206
+ PERIODIC POLLING (real-time-ish data):
207
+ useQuery({
208
+ queryKey: ['notifications'],
209
+ queryFn: fetchNotifications,
210
+ refetchInterval: 30_000, // every 30 seconds
211
+ refetchIntervalInBackground: false, // stop when app backgrounded
212
+ });
213
+
214
+ MANUAL REFRESH (pull-to-refresh):
215
+ <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
216
+
217
+ SELECTIVE vs FULL CLEAR:
218
+ // Selective: only affected queries
219
+ queryClient.invalidateQueries({ queryKey: ['product', productId] });
220
+
221
+ // Full clear (logout):
222
+ queryClient.clear();
223
+ ```
224
+
225
+ ### React Navigation Focus Refetch
226
+
227
+ ```typescript
228
+ // hooks/useRefetchOnFocus.ts
229
+ import { useFocusEffect } from '@react-navigation/native';
230
+ import { useCallback } from 'react';
231
+
232
+ export function useRefetchOnFocus(refetch: () => void) {
233
+ useFocusEffect(
234
+ useCallback(() => {
235
+ refetch();
236
+ }, [refetch])
237
+ );
238
+ }
239
+
240
+ // Usage:
241
+ // const { data, refetch } = useProductDetail(id);
242
+ // useRefetchOnFocus(refetch);
243
+ ```
244
+
245
+ ---
246
+
247
+ ## WebSocket Real-Time
248
+
249
+ ### React Native — Socket Connection Manager
250
+
251
+ ```typescript
252
+ // services/socket.ts
253
+ import { io, Socket } from 'socket.io-client';
254
+ import { useAuthStore } from '@/stores/useAuthStore';
255
+ import { AppState } from 'react-native';
256
+
257
+ class SocketManager {
258
+ private socket: Socket | null = null;
259
+ private reconnectTimer: NodeJS.Timeout | null = null;
260
+ private maxReconnectDelay = 30_000;
261
+ private reconnectAttempts = 0;
262
+
263
+ connect() {
264
+ const token = useAuthStore.getState().token;
265
+ if (!token || this.socket?.connected) return;
266
+
267
+ this.socket = io(process.env.EXPO_PUBLIC_WS_URL!, {
268
+ auth: { token },
269
+ transports: ['websocket'],
270
+ reconnection: false, // we handle reconnection manually
271
+ });
272
+
273
+ this.socket.on('connect', () => {
274
+ this.reconnectAttempts = 0;
275
+ });
276
+
277
+ this.socket.on('disconnect', (reason) => {
278
+ if (reason !== 'io client disconnect') {
279
+ this.scheduleReconnect();
280
+ }
281
+ });
282
+
283
+ this.socket.on('connect_error', () => {
284
+ this.scheduleReconnect();
285
+ });
286
+
287
+ // Reconnect when app comes to foreground
288
+ AppState.addEventListener('change', (state) => {
289
+ if (state === 'active' && !this.socket?.connected) {
290
+ this.connect();
291
+ }
292
+ });
293
+ }
294
+
295
+ private scheduleReconnect() {
296
+ if (this.reconnectTimer) return;
297
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 500, this.maxReconnectDelay);
298
+ this.reconnectAttempts++;
299
+ this.reconnectTimer = setTimeout(() => {
300
+ this.reconnectTimer = null;
301
+ this.connect();
302
+ }, delay);
303
+ }
304
+
305
+ on<T>(event: string, callback: (data: T) => void) {
306
+ this.socket?.on(event, callback);
307
+ return () => { this.socket?.off(event, callback); };
308
+ }
309
+
310
+ emit(event: string, data?: unknown) {
311
+ this.socket?.emit(event, data);
312
+ }
313
+
314
+ disconnect() {
315
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
316
+ this.socket?.disconnect();
317
+ this.socket = null;
318
+ }
319
+ }
320
+
321
+ export const socketManager = new SocketManager();
322
+
323
+ // hooks/useSocket.ts — auto-subscribe/unsubscribe
324
+ export function useSocket<T>(event: string, callback: (data: T) => void) {
325
+ useEffect(() => {
326
+ const unsubscribe = socketManager.on(event, callback);
327
+ return unsubscribe;
328
+ }, [event, callback]);
329
+ }
330
+
331
+ // Usage:
332
+ // useSocket<Message>('new_message', (msg) => {
333
+ // queryClient.setQueryData(['messages', chatId], (old) => [...old, msg]);
334
+ // });
335
+ ```
336
+
337
+ ---
338
+
339
+ ## Request Queuing for Offline
340
+
341
+ ### React Native — Mutation Queue
342
+
343
+ ```typescript
344
+ // services/offlineQueue.ts
345
+ import NetInfo from '@react-native-community/netinfo';
346
+ import AsyncStorage from '@react-native-async-storage/async-storage';
347
+
348
+ interface QueuedRequest {
349
+ id: string;
350
+ method: 'POST' | 'PUT' | 'DELETE';
351
+ url: string;
352
+ data: unknown;
353
+ timestamp: number;
354
+ }
355
+
356
+ const QUEUE_KEY = 'offline_request_queue';
357
+
358
+ export const offlineQueue = {
359
+ async enqueue(request: Omit<QueuedRequest, 'id' | 'timestamp'>) {
360
+ const queue = await this.getQueue();
361
+ queue.push({
362
+ ...request,
363
+ id: Math.random().toString(36).slice(2),
364
+ timestamp: Date.now(),
365
+ });
366
+ await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
367
+ },
368
+
369
+ async getQueue(): Promise<QueuedRequest[]> {
370
+ const raw = await AsyncStorage.getItem(QUEUE_KEY);
371
+ return raw ? JSON.parse(raw) : [];
372
+ },
373
+
374
+ async flush() {
375
+ const queue = await this.getQueue();
376
+ const remaining: QueuedRequest[] = [];
377
+
378
+ for (const req of queue) {
379
+ try {
380
+ await api({ method: req.method, url: req.url, data: req.data });
381
+ } catch {
382
+ remaining.push(req); // retry next time
383
+ }
384
+ }
385
+
386
+ await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
387
+ },
388
+ };
389
+
390
+ // Auto-flush when online
391
+ NetInfo.addEventListener(state => {
392
+ if (state.isConnected) offlineQueue.flush();
393
+ });
394
+ ```
395
+
396
+ ---
397
+
398
+ ## Data Prefetching
399
+
400
+ ```typescript
401
+ // Prefetch next screen's data when user is likely to navigate
402
+ function ProductCard({ product }: { product: Product }) {
403
+ const queryClient = useQueryClient();
404
+
405
+ const prefetchDetail = () => {
406
+ queryClient.prefetchQuery({
407
+ queryKey: ['product', product.id],
408
+ queryFn: () => productService.getById(product.id),
409
+ staleTime: 5 * 60 * 1000,
410
+ });
411
+ };
412
+
413
+ return (
414
+ <TouchableOpacity
415
+ onPress={() => navigation.navigate('ProductDetail', { productId: product.id })}
416
+ onPressIn={prefetchDetail} // prefetch on touch start (before navigation)
417
+ >
418
+ {/* card content */}
419
+ </TouchableOpacity>
420
+ );
421
+ }
422
+ ```