@buivietphi/skill-mobile-mt 1.0.0

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,743 @@
1
+ # React Native — Production Patterns
2
+
3
+ > Battle-tested patterns from production React Native apps.
4
+ > Supports: React Native CLI + Expo (managed & bare)
5
+ > Language: TypeScript (recommended) + JavaScript
6
+ > State: Redux, MobX, Zustand, TanStack Query, Apollo/GraphQL
7
+ > Navigation: @react-navigation (CLI) / expo-router (Expo)
8
+ > Networking: axios / fetch
9
+ > Package manager: yarn (preferred), npm, pnpm, bun
10
+
11
+ ---
12
+
13
+ ## Table of Contents
14
+
15
+ 1. [Clean Architecture](#clean-architecture)
16
+ 2. [Expo Project Structure](#expo-project-structure)
17
+ 3. [State Management](#state-management)
18
+ 4. [Navigation](#navigation)
19
+ 5. [API Layer](#api-layer)
20
+ 6. [Push Notifications](#push-notifications)
21
+ 7. [Expo SDK Modules](#expo-sdk-modules)
22
+ 8. [Build & Deploy](#build--deploy)
23
+ 9. [Common Libraries](#common-libraries)
24
+ 10. [Multi-Tenant Pattern](#multi-tenant--workspace-pattern)
25
+
26
+ ---
27
+
28
+ ## Clean Architecture
29
+
30
+ ### React Native CLI
31
+
32
+ ```
33
+ src/
34
+ ├── app/ # App entry, providers, root navigator
35
+ ├── domain/ # Business logic (pure, no dependencies)
36
+ │ ├── entities/ # Core business models
37
+ │ ├── usecases/ # Business rules
38
+ │ └── repositories/ # Repository interfaces (contracts)
39
+ ├── data/ # Data layer (implements domain interfaces)
40
+ │ ├── repositories/ # Repository implementations
41
+ │ ├── datasources/ # Remote (API) + Local (AsyncStorage, DB)
42
+ │ └── models/ # DTOs, mappers to domain entities
43
+ ├── presentation/ # UI layer
44
+ │ ├── screens/ # Screen components (1 per route)
45
+ │ ├── components/
46
+ │ │ ├── common/ # Shared: Button, Input, Card, Modal
47
+ │ │ └── [feature]/ # Feature-specific components
48
+ │ ├── hooks/ # Custom hooks (useAuth, useApi)
49
+ │ └── navigation/ # Stack, Tab, Drawer navigators
50
+ ├── services/ # External services (analytics, notifications)
51
+ ├── utils/ # Helpers, formatters, validators
52
+ ├── constants/ # Colors, spacing, API URLs
53
+ ├── types/ # TypeScript types (if TS project)
54
+ └── assets/ # Images, fonts
55
+ ```
56
+
57
+ ### Dependency Rule
58
+ ```
59
+ presentation/ → domain/ ← data/
60
+
61
+ UI depends on Domain. Data depends on Domain.
62
+ Domain depends on NOTHING.
63
+ Never import data/ from presentation/ directly.
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Expo Project Structure
69
+
70
+ ### Expo Router (file-based routing)
71
+
72
+ ```
73
+ app/ # File-based routes (expo-router)
74
+ ├── _layout.tsx # Root layout (providers, fonts, splash)
75
+ ├── (tabs)/ # Tab group
76
+ │ ├── _layout.tsx # Tab navigator config
77
+ │ ├── index.tsx # Home tab (/)
78
+ │ ├── search.tsx # Search tab (/search)
79
+ │ └── profile.tsx # Profile tab (/profile)
80
+ ├── (auth)/ # Auth group (no tabs)
81
+ │ ├── _layout.tsx # Stack navigator for auth
82
+ │ ├── login.tsx # /login
83
+ │ └── register.tsx # /register
84
+ ├── product/
85
+ │ └── [id].tsx # Dynamic route: /product/123
86
+ ├── +not-found.tsx # 404 screen
87
+ └── +html.tsx # Custom HTML wrapper (web)
88
+ src/
89
+ ├── domain/ # Same clean architecture as CLI
90
+ ├── data/
91
+ ├── components/
92
+ ├── hooks/
93
+ ├── services/
94
+ ├── utils/
95
+ ├── constants/
96
+ └── types/
97
+ ```
98
+
99
+ ### app.config.js / app.json
100
+
101
+ ```javascript
102
+ // app.config.js — dynamic config (recommended over app.json)
103
+ export default ({ config }) => ({
104
+ ...config,
105
+ name: "MyApp",
106
+ slug: "my-app",
107
+ version: "1.0.0",
108
+ scheme: "myapp", // Deep linking
109
+ orientation: "portrait",
110
+ icon: "./assets/icon.png",
111
+ splash: { image: "./assets/splash.png", resizeMode: "contain" },
112
+ ios: {
113
+ bundleIdentifier: "com.company.myapp",
114
+ supportsTablet: true,
115
+ },
116
+ android: {
117
+ package: "com.company.myapp",
118
+ adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png" },
119
+ },
120
+ plugins: [
121
+ "expo-router",
122
+ "expo-secure-store",
123
+ ["expo-camera", { cameraPermission: "Allow camera access" }],
124
+ ["expo-location", { locationAlwaysPermission: "Allow location" }],
125
+ ],
126
+ extra: {
127
+ API_URL: process.env.API_URL || "https://api.example.com",
128
+ eas: { projectId: "your-project-id" },
129
+ },
130
+ });
131
+ ```
132
+
133
+ ### Root Layout (Expo Router)
134
+
135
+ ```tsx
136
+ // app/_layout.tsx
137
+ import { Slot, SplashScreen } from 'expo-router';
138
+ import { useFonts } from 'expo-font';
139
+ import { useEffect } from 'react';
140
+
141
+ SplashScreen.preventAutoHideAsync();
142
+
143
+ export default function RootLayout() {
144
+ const [fontsLoaded] = useFonts({ 'Inter': require('../assets/fonts/Inter.ttf') });
145
+
146
+ useEffect(() => {
147
+ if (fontsLoaded) SplashScreen.hideAsync();
148
+ }, [fontsLoaded]);
149
+
150
+ if (!fontsLoaded) return null;
151
+
152
+ return <Slot />;
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## State Management
159
+
160
+ ### Redux Pattern (RTK)
161
+
162
+ ```typescript
163
+ // presentation/store/slices/authSlice.ts
164
+ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
165
+
166
+ export const login = createAsyncThunk('auth/login', async (creds, { rejectWithValue }) => {
167
+ try {
168
+ const response = await authApi.login(creds);
169
+ return response.data;
170
+ } catch (error) {
171
+ return rejectWithValue(error.response?.data);
172
+ }
173
+ });
174
+
175
+ const authSlice = createSlice({
176
+ name: 'auth',
177
+ initialState: { user: null, token: null, loading: false, error: null },
178
+ reducers: {
179
+ logout: (state) => { state.user = null; state.token = null; },
180
+ },
181
+ extraReducers: (builder) => {
182
+ builder
183
+ .addCase(login.pending, (state) => { state.loading = true; state.error = null; })
184
+ .addCase(login.fulfilled, (state, action) => {
185
+ state.loading = false;
186
+ state.user = action.payload.user;
187
+ state.token = action.payload.token;
188
+ })
189
+ .addCase(login.rejected, (state, action) => {
190
+ state.loading = false;
191
+ state.error = action.payload;
192
+ });
193
+ },
194
+ });
195
+ ```
196
+
197
+ ### Zustand Pattern (lightweight alternative)
198
+
199
+ ```typescript
200
+ // stores/useAuthStore.ts
201
+ import { create } from 'zustand';
202
+ import { persist, createJSONStorage } from 'zustand/middleware';
203
+ import AsyncStorage from '@react-native-async-storage/async-storage';
204
+
205
+ interface AuthState {
206
+ user: User | null;
207
+ token: string | null;
208
+ loading: boolean;
209
+ login: (creds: Credentials) => Promise<void>;
210
+ logout: () => void;
211
+ }
212
+
213
+ export const useAuthStore = create<AuthState>()(
214
+ persist(
215
+ (set) => ({
216
+ user: null,
217
+ token: null,
218
+ loading: false,
219
+
220
+ login: async (creds) => {
221
+ set({ loading: true });
222
+ try {
223
+ const res = await authApi.login(creds);
224
+ set({ user: res.data.user, token: res.data.token, loading: false });
225
+ } catch (error) {
226
+ set({ loading: false });
227
+ throw error;
228
+ }
229
+ },
230
+
231
+ logout: () => set({ user: null, token: null }),
232
+ }),
233
+ { name: 'auth-storage', storage: createJSONStorage(() => AsyncStorage) }
234
+ )
235
+ );
236
+ ```
237
+
238
+ **JavaScript version:**
239
+ ```javascript
240
+ // stores/useAuthStore.js
241
+ import { create } from 'zustand';
242
+ import { persist, createJSONStorage } from 'zustand/middleware';
243
+ import AsyncStorage from '@react-native-async-storage/async-storage';
244
+
245
+ export const useAuthStore = create(
246
+ persist(
247
+ (set) => ({
248
+ user: null,
249
+ token: null,
250
+ loading: false,
251
+
252
+ login: async (creds) => {
253
+ set({ loading: true });
254
+ try {
255
+ const res = await authApi.login(creds);
256
+ set({ user: res.data.user, token: res.data.token, loading: false });
257
+ } catch (error) {
258
+ set({ loading: false });
259
+ throw error;
260
+ }
261
+ },
262
+
263
+ logout: () => set({ user: null, token: null }),
264
+ }),
265
+ { name: 'auth-storage', storage: createJSONStorage(() => AsyncStorage) }
266
+ )
267
+ );
268
+ ```
269
+
270
+ ### TanStack Query (server state)
271
+
272
+ ```typescript
273
+ // hooks/useProducts.ts
274
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
275
+
276
+ export function useProducts() {
277
+ return useQuery({
278
+ queryKey: ['products'],
279
+ queryFn: () => api.get('/products').then(r => r.data),
280
+ staleTime: 5 * 60 * 1000, // 5 min cache
281
+ });
282
+ }
283
+
284
+ export function useCreateProduct() {
285
+ const queryClient = useQueryClient();
286
+ return useMutation({
287
+ mutationFn: (product) => api.post('/products', product),
288
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
289
+ });
290
+ }
291
+
292
+ // In component:
293
+ function ProductList() {
294
+ const { data, isLoading, error, refetch } = useProducts();
295
+ if (isLoading) return <LoadingSkeleton />;
296
+ if (error) return <ErrorView message={error.message} onRetry={refetch} />;
297
+ if (!data?.length) return <EmptyState />;
298
+ return <FlatList data={data} keyExtractor={item => item.id} renderItem={...} />;
299
+ }
300
+ ```
301
+
302
+ ### MobX Pattern
303
+
304
+ ```typescript
305
+ import { makeAutoObservable, runInAction } from 'mobx';
306
+
307
+ class AuthStore {
308
+ user = null;
309
+ token = null;
310
+ loading = false;
311
+
312
+ constructor() { makeAutoObservable(this); }
313
+
314
+ async login(creds) {
315
+ this.loading = true;
316
+ try {
317
+ const res = await authApi.login(creds);
318
+ runInAction(() => {
319
+ this.user = res.data.user;
320
+ this.token = res.data.token;
321
+ this.loading = false;
322
+ });
323
+ } catch (error) {
324
+ runInAction(() => { this.loading = false; });
325
+ throw error;
326
+ }
327
+ }
328
+ }
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Navigation
334
+
335
+ ### @react-navigation (React Native CLI)
336
+
337
+ ```typescript
338
+ import { NavigationContainer } from '@react-navigation/native';
339
+ import { createNativeStackNavigator } from '@react-navigation/native-stack';
340
+ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
341
+
342
+ const Stack = createNativeStackNavigator();
343
+ const Tab = createBottomTabNavigator();
344
+
345
+ function MainTabs() {
346
+ return (
347
+ <Tab.Navigator>
348
+ <Tab.Screen name="Home" component={HomeScreen} />
349
+ <Tab.Screen name="Profile" component={ProfileScreen} />
350
+ </Tab.Navigator>
351
+ );
352
+ }
353
+
354
+ function AppNavigator() {
355
+ const isLoggedIn = useSelector(state => state.auth.token);
356
+ return (
357
+ <NavigationContainer>
358
+ <Stack.Navigator screenOptions={{ headerShown: false }}>
359
+ {isLoggedIn ? (
360
+ <Stack.Screen name="Main" component={MainTabs} />
361
+ ) : (
362
+ <Stack.Screen name="Auth" component={AuthStack} />
363
+ )}
364
+ </Stack.Navigator>
365
+ </NavigationContainer>
366
+ );
367
+ }
368
+ ```
369
+
370
+ ### expo-router (Expo)
371
+
372
+ ```tsx
373
+ // app/(tabs)/_layout.tsx
374
+ import { Tabs } from 'expo-router';
375
+ import { Ionicons } from '@expo/vector-icons';
376
+
377
+ export default function TabLayout() {
378
+ return (
379
+ <Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
380
+ <Tabs.Screen name="index" options={{
381
+ title: 'Home',
382
+ tabBarIcon: ({ color }) => <Ionicons name="home" size={24} color={color} />,
383
+ }} />
384
+ <Tabs.Screen name="profile" options={{
385
+ title: 'Profile',
386
+ tabBarIcon: ({ color }) => <Ionicons name="person" size={24} color={color} />,
387
+ }} />
388
+ </Tabs>
389
+ );
390
+ }
391
+
392
+ // Navigation in expo-router:
393
+ import { router } from 'expo-router';
394
+
395
+ // Push screen
396
+ router.push('/product/123');
397
+ // Replace screen
398
+ router.replace('/login');
399
+ // Go back
400
+ router.back();
401
+ // Navigate with params
402
+ router.push({ pathname: '/product/[id]', params: { id: '123' } });
403
+ ```
404
+
405
+ **JavaScript version:**
406
+ ```jsx
407
+ // app/(tabs)/_layout.js
408
+ import { Tabs } from 'expo-router';
409
+ import { Ionicons } from '@expo/vector-icons';
410
+
411
+ export default function TabLayout() {
412
+ return (
413
+ <Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
414
+ <Tabs.Screen name="index" options={{
415
+ title: 'Home',
416
+ tabBarIcon: ({ color }) => <Ionicons name="home" size={24} color={color} />,
417
+ }} />
418
+ <Tabs.Screen name="profile" options={{
419
+ title: 'Profile',
420
+ tabBarIcon: ({ color }) => <Ionicons name="person" size={24} color={color} />,
421
+ }} />
422
+ </Tabs>
423
+ );
424
+ }
425
+ ```
426
+
427
+ ### Auth-Protected Routes (expo-router)
428
+
429
+ ```tsx
430
+ // app/(auth)/_layout.tsx
431
+ import { Redirect, Stack } from 'expo-router';
432
+ import { useAuthStore } from '../../stores/useAuthStore';
433
+
434
+ export default function AuthLayout() {
435
+ const token = useAuthStore(s => s.token);
436
+ if (token) return <Redirect href="/(tabs)" />;
437
+ return <Stack screenOptions={{ headerShown: false }} />;
438
+ }
439
+
440
+ // app/(tabs)/_layout.tsx
441
+ import { Redirect, Tabs } from 'expo-router';
442
+ import { useAuthStore } from '../../stores/useAuthStore';
443
+
444
+ export default function TabLayout() {
445
+ const token = useAuthStore(s => s.token);
446
+ if (!token) return <Redirect href="/(auth)/login" />;
447
+ return <Tabs>...</Tabs>;
448
+ }
449
+ ```
450
+
451
+ ---
452
+
453
+ ## API Layer
454
+
455
+ ### axios (with interceptors)
456
+
457
+ ```typescript
458
+ import axios from 'axios';
459
+
460
+ const api = axios.create({ baseURL: API_URL, timeout: 15000 });
461
+
462
+ api.interceptors.request.use((config) => {
463
+ const token = store.getState().auth.token;
464
+ if (token) config.headers.Authorization = `Bearer ${token}`;
465
+ return config;
466
+ });
467
+
468
+ api.interceptors.response.use(
469
+ (res) => res,
470
+ (error) => {
471
+ if (error.response?.status === 401) {
472
+ store.dispatch(logout());
473
+ }
474
+ return Promise.reject(error);
475
+ }
476
+ );
477
+ ```
478
+
479
+ ### fetch (no dependencies)
480
+
481
+ ```javascript
482
+ // services/api.js — works with both TS and JS
483
+ const API_URL = 'https://api.example.com';
484
+
485
+ async function request(endpoint, options = {}) {
486
+ const token = getToken(); // from secure storage
487
+ const response = await fetch(`${API_URL}${endpoint}`, {
488
+ ...options,
489
+ headers: {
490
+ 'Content-Type': 'application/json',
491
+ ...(token && { Authorization: `Bearer ${token}` }),
492
+ ...options.headers,
493
+ },
494
+ });
495
+
496
+ if (response.status === 401) {
497
+ handleLogout();
498
+ throw new Error('Unauthorized');
499
+ }
500
+
501
+ if (!response.ok) {
502
+ const error = await response.json().catch(() => ({}));
503
+ throw new Error(error.message || `HTTP ${response.status}`);
504
+ }
505
+
506
+ return response.json();
507
+ }
508
+
509
+ export const api = {
510
+ get: (endpoint) => request(endpoint),
511
+ post: (endpoint, data) => request(endpoint, { method: 'POST', body: JSON.stringify(data) }),
512
+ put: (endpoint, data) => request(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
513
+ delete: (endpoint) => request(endpoint, { method: 'DELETE' }),
514
+ };
515
+ ```
516
+
517
+ ---
518
+
519
+ ## Push Notifications
520
+
521
+ ### Firebase (React Native CLI)
522
+
523
+ ```typescript
524
+ import messaging from '@react-native-firebase/messaging';
525
+
526
+ async function requestNotificationPermission() {
527
+ const authStatus = await messaging().requestPermission();
528
+ if (authStatus === messaging.AuthorizationStatus.AUTHORIZED) {
529
+ const token = await messaging().getToken();
530
+ await api.post('/devices/register', { fcm_token: token });
531
+ }
532
+ }
533
+ ```
534
+
535
+ ### expo-notifications (Expo)
536
+
537
+ ```typescript
538
+ import * as Notifications from 'expo-notifications';
539
+ import * as Device from 'expo-device';
540
+ import Constants from 'expo-constants';
541
+
542
+ Notifications.setNotificationHandler({
543
+ handleNotification: async () => ({
544
+ shouldShowAlert: true,
545
+ shouldPlaySound: true,
546
+ shouldSetBadge: true,
547
+ }),
548
+ });
549
+
550
+ async function registerForPushNotifications() {
551
+ if (!Device.isDevice) return null; // Must be physical device
552
+
553
+ const { status: existing } = await Notifications.getPermissionsAsync();
554
+ let finalStatus = existing;
555
+
556
+ if (existing !== 'granted') {
557
+ const { status } = await Notifications.requestPermissionsAsync();
558
+ finalStatus = status;
559
+ }
560
+
561
+ if (finalStatus !== 'granted') return null;
562
+
563
+ const token = (await Notifications.getExpoPushTokenAsync({
564
+ projectId: Constants.expoConfig?.extra?.eas?.projectId,
565
+ })).data;
566
+
567
+ await api.post('/devices/register', { push_token: token });
568
+ return token;
569
+ }
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Expo SDK Modules
575
+
576
+ | Purpose | Package | Usage |
577
+ |---------|---------|-------|
578
+ | Secure Storage | `expo-secure-store` | Tokens, secrets (Keychain/Keystore) |
579
+ | Camera | `expo-camera` | Camera access with permissions |
580
+ | Location | `expo-location` | GPS, geofencing |
581
+ | Image Picker | `expo-image-picker` | Photo library + camera |
582
+ | File System | `expo-file-system` | Read/write local files |
583
+ | Notifications | `expo-notifications` | Push + local notifications |
584
+ | Auth Session | `expo-auth-session` | OAuth (Google, Apple, etc.) |
585
+ | Linking | `expo-linking` | Deep links |
586
+ | Haptics | `expo-haptics` | Vibration feedback |
587
+ | Splash Screen | `expo-splash-screen` | Control splash visibility |
588
+ | Font | `expo-font` | Custom fonts |
589
+ | Constants | `expo-constants` | App config values |
590
+
591
+ ### Expo + Native Modules
592
+
593
+ ```
594
+ ⚠️ If you need a native module not in Expo SDK:
595
+ 1. Use "expo prebuild" to eject to bare workflow
596
+ 2. Or use a config plugin: plugins: ["my-native-package"]
597
+ 3. Or use EAS Build (handles native deps in cloud)
598
+ ⛔ NEVER run "expo eject" — deprecated. Use "expo prebuild" instead.
599
+ ```
600
+
601
+ ---
602
+
603
+ ## Build & Deploy
604
+
605
+ ### React Native CLI
606
+
607
+ ```
608
+ scripts:
609
+ dev: react-native run-android --variant=devDebug
610
+ staging: react-native run-android --variant=stagingDebug
611
+ prod: react-native run-android --variant=prodRelease
612
+ ```
613
+
614
+ ### Expo (EAS Build)
615
+
616
+ ```bash
617
+ # Install EAS CLI
618
+ npm install -g eas-cli
619
+
620
+ # Configure (creates eas.json)
621
+ eas build:configure
622
+
623
+ # Build for stores
624
+ eas build --platform ios --profile production
625
+ eas build --platform android --profile production
626
+
627
+ # Build for testing (internal distribution)
628
+ eas build --platform all --profile preview
629
+
630
+ # OTA update (no store review needed)
631
+ eas update --branch production --message "Fix: cart total bug"
632
+
633
+ # Submit to stores
634
+ eas submit --platform ios
635
+ eas submit --platform android
636
+ ```
637
+
638
+ ### eas.json
639
+
640
+ ```json
641
+ {
642
+ "build": {
643
+ "development": {
644
+ "developmentClient": true,
645
+ "distribution": "internal",
646
+ "env": { "API_URL": "https://dev-api.example.com" }
647
+ },
648
+ "preview": {
649
+ "distribution": "internal",
650
+ "env": { "API_URL": "https://staging-api.example.com" }
651
+ },
652
+ "production": {
653
+ "env": { "API_URL": "https://api.example.com" }
654
+ }
655
+ },
656
+ "submit": {
657
+ "production": {
658
+ "ios": { "appleId": "you@example.com", "ascAppId": "123456789" },
659
+ "android": { "serviceAccountKeyPath": "./google-sa-key.json" }
660
+ }
661
+ }
662
+ }
663
+ ```
664
+
665
+ ---
666
+
667
+ ## Common Libraries
668
+
669
+ ### React Native CLI
670
+
671
+ | Purpose | Library |
672
+ |---------|---------|
673
+ | HTTP | axios |
674
+ | State | redux + redux-persist |
675
+ | State | mobx + mobx-react |
676
+ | State | zustand |
677
+ | Server State | @tanstack/react-query |
678
+ | State | @apollo/client |
679
+ | Nav | @react-navigation |
680
+ | Anim | react-native-reanimated |
681
+ | Gestures | react-native-gesture-handler |
682
+ | Camera | react-native-camera |
683
+ | Maps | react-native-maps |
684
+ | Push | @react-native-firebase/messaging |
685
+ | Image | react-native-image-picker |
686
+ | Fast Image | react-native-fast-image |
687
+ | Bottom Sheet | @gorhom/bottom-sheet |
688
+ | Lottie | lottie-react-native |
689
+ | Socket | socket.io-client |
690
+ | Forms | react-hook-form + zod |
691
+ | Styled | nativewind (Tailwind) / styled-components |
692
+
693
+ ### Expo
694
+
695
+ | Purpose | Library |
696
+ |---------|---------|
697
+ | HTTP | axios / fetch (built-in) |
698
+ | State | zustand (recommended) / redux |
699
+ | Server State | @tanstack/react-query |
700
+ | Nav | expo-router |
701
+ | Camera | expo-camera |
702
+ | Location | expo-location |
703
+ | Image | expo-image-picker |
704
+ | Fast Image | expo-image |
705
+ | Push | expo-notifications |
706
+ | Storage | expo-secure-store |
707
+ | Auth | expo-auth-session |
708
+ | Icons | @expo/vector-icons |
709
+ | Fonts | expo-font + expo-splash-screen |
710
+ | Forms | react-hook-form + zod |
711
+ | Styled | nativewind (Tailwind) / tamagui |
712
+
713
+ ---
714
+
715
+ ## Multi-Tenant / Workspace Pattern
716
+
717
+ ```
718
+ workspace/
719
+ ├── tenants/
720
+ │ ├── tenant-a/config.js # Tenant-specific config
721
+ │ ├── tenant-b/config.js
722
+ │ └── tenant-c/config.js
723
+ ├── source/
724
+ │ ├── src/ # Shared code
725
+ │ └── config/ # Base config
726
+ └── package.json # Yarn workspaces, nohoist for RN deps
727
+ ```
728
+
729
+ ---
730
+
731
+ ## Quick Reference
732
+
733
+ | Project Type | Navigation | Build | Push | Storage |
734
+ |-------------|-----------|-------|------|---------|
735
+ | RN CLI + TS | @react-navigation | Gradle/Xcode | Firebase | react-native-keychain |
736
+ | RN CLI + JS | @react-navigation | Gradle/Xcode | Firebase | react-native-keychain |
737
+ | Expo + TS | expo-router | EAS Build | expo-notifications | expo-secure-store |
738
+ | Expo + JS | expo-router | EAS Build | expo-notifications | expo-secure-store |
739
+
740
+ > **Zustand + TanStack Query** is the modern lightweight choice.
741
+ > **Redux** for complex apps with many cross-feature state dependencies.
742
+ > **MobX** for complex observable state patterns.
743
+ > **Apollo** for GraphQL backends.