Mobile Development Overview
Mobile Development Guide
Section titled “Mobile Development Guide”Merq mobile app adalah offline-first field application yang dibangun dengan React Native.
Tech Stack
Section titled “Tech Stack”- Framework: React Native 0.80.2
- Language: TypeScript 5.5
- UI Library: React Native Paper MD3
- Storage: MMKV (offline data)
- State Management: Jotai (global state) + TanStack Query (server state)
- Navigation: React Navigation v7
- Lists: FlashList (performance)
- Forms: React Hook Form + Zod
Architecture Overview
Section titled “Architecture Overview”┌──────────────────────────────────┐│ Screens (Routes) ││ - Screen components ││ - Navigation setup │└────────────┬─────────────────────┘ │┌────────────▼─────────────────────┐│ Components Layer ││ - Feature components ││ - Paper MD3 components ││ - Memoized list items │└────────────┬─────────────────────┘ │┌────────────▼─────────────────────┐│ Hooks Layer ││ - Query hooks (offlineFirst) ││ - Custom hooks ││ - Jotai atoms │└────────────┬─────────────────────┘ │┌────────────▼─────────────────────┐│ Services Layer ││ - API calls ││ - Data transformation │└────────────┬─────────────────────┘ │┌────────────▼─────────────────────┐│ Storage Layer ││ - MMKV persistence ││ - TanStack Query cache │└────────────┬─────────────────────┘ │ ┌─────┴─────┐ │ │ ┌───▼──┐ ┌──▼────┐ │ MMKV │ │ API │ │Cache │ │Server │ └──────┘ └───────┘Project Structure
Section titled “Project Structure”src/├── core/│ ├── atoms/ # Jotai global state│ │ ├── auth.atom.ts│ │ ├── profile.atom.ts│ │ └── workspace.atom.ts│ ├── hooks/ # Shared hooks│ │ ├── useAuth.ts│ │ ├── useNetwork.ts│ │ └── useDebounce.ts│ ├── services/ # API service layer│ │ ├── api.service.ts│ │ ├── principal.service.ts│ │ ├── project.service.ts│ │ └── outlet.service.ts│ ├── states/│ │ └── queries/ # TanStack Query hooks│ │ ├── principal/│ │ │ ├── keys.ts│ │ │ └── query.ts│ │ ├── project/│ │ └── outlet/│ ├── storage/ # MMKV storage│ │ ├── storage.ts│ │ ├── keys.ts│ │ └── queryPersister.ts│ ├── theme/ # Paper MD3 theme│ │ └── theme.ts│ ├── types/ # TypeScript types│ │ ├── api.ts│ │ ├── models.ts│ │ └── navigation.ts│ └── utils/ # Utilities│ ├── date.ts│ └── format.ts├── features/ # Feature modules│ ├── auth/│ │ ├── screens/│ │ │ ├── LoginScreen.tsx│ │ │ └── RegisterScreen.tsx│ │ ├── components/│ │ │ └── LoginForm.tsx│ │ ├── navigation.tsx│ │ └── index.ts # Barrel export│ ├── principal/│ │ ├── screens/│ │ │ ├── PrincipalListScreen.tsx│ │ │ └── PrincipalDetailScreen.tsx│ │ ├── components/│ │ │ └── PrincipalCard.tsx│ │ ├── navigation.tsx│ │ └── index.ts│ ├── outlet/│ └── visit/├── navigation/ # Root navigation│ ├── RootNavigator.tsx│ └── types.ts└── App.tsx # Application rootKey Conventions
Section titled “Key Conventions”1. Offline-First MANDATORY
Section titled “1. Offline-First MANDATORY”ALWAYS use offlineFirst network mode:
// ✅ CORRECTexport function useOutletsQuery(params: OutletParams) { const profile = useAtomValue(profileAtom);
return useQuery({ queryKey: ['outlets', params], queryFn: () => outletService.getList(params), networkMode: 'offlineFirst', // CRITICAL enabled: !!profile?.id, });}
// ❌ WRONG - Default online modeexport function useOutletsQuery(params: OutletParams) { return useQuery({ queryKey: ['outlets', params], queryFn: () => outletService.getList(params), // Missing networkMode - will fail offline });}2. MMKV Storage ONLY
Section titled “2. MMKV Storage ONLY”NO AsyncStorage, NO SQLite:
// ✅ CORRECT - MMKVimport { storage } from '@core/storage';storage.set('key', JSON.stringify(data));
// ❌ WRONG - AsyncStorageimport AsyncStorage from '@react-native-async-storage/async-storage';await AsyncStorage.setItem('key', JSON.stringify(data));
// ❌ WRONG - SQLiteimport SQLite from 'react-native-sqlite-storage';3. Paper MD3 Components ONLY
Section titled “3. Paper MD3 Components ONLY”// ✅ CORRECTimport { Button, Card, Text } from 'react-native-paper';
// ❌ WRONG - Other UI librariesimport { Button } from 'react-native-elements';import { Card } from 'native-base';4. StyleSheet.create at Bottom
Section titled “4. StyleSheet.create at Bottom”// ✅ CORRECTexport function OutletCard({ item }: Props) { return ( <Card style={styles.card}> <Text style={styles.title}>{item.name}</Text> </Card> );}
const styles = StyleSheet.create({ card: { marginBottom: 12, padding: 16 }, title: { fontSize: 16, fontWeight: 'bold' },});
// ❌ WRONG - Inline stylesexport function OutletCard({ item }: Props) { return ( <Card style={{ marginBottom: 12, padding: 16 }}> <Text style={{ fontSize: 16, fontWeight: 'bold' }}>{item.name}</Text> </Card> );}5. Named Exports + Barrel Files
Section titled “5. Named Exports + Barrel Files”// ✅ CORRECT - Named exportsexport function OutletCard({ item }: Props) { ... }export function OutletList({ data }: Props) { ... }
// features/outlet/index.tsexport { OutletCard } from './components/OutletCard';export { OutletList } from './components/OutletList';export { OutletListScreen } from './screens/OutletListScreen';
// ❌ WRONG - Default exportsexport default function OutletCard({ item }: Props) { ... }6. FlashList for Large Lists
Section titled “6. FlashList for Large Lists”// ✅ CORRECT - FlashList for 20+ itemsimport { FlashList } from '@shopify/flash-list';
<FlashList data={outlets} renderItem={renderItem} estimatedItemSize={80}/>
// ❌ WRONG - ScrollView + map<ScrollView> {outlets.map(item => <OutletCard key={item.id} item={item} />)}</ScrollView>7. Path Aliases
Section titled “7. Path Aliases”import { apiClient } from '@core/services/api.service';import { profileAtom } from '@core/atoms/profile.atom';import { OutletCard } from '@features/outlet';import type { Outlet } from '@core/types/models';Development Workflow
Section titled “Development Workflow”Creating New Feature
Section titled “Creating New Feature”Order of implementation:
- Types (
core/types/models.ts) - Service (
core/services/xxx.service.ts) - Query Hooks (
core/states/queries/xxx/) - Components (
features/xxx/components/) - Screens (
features/xxx/screens/) - Navigation (
features/xxx/navigation.tsx) - Barrel Export (
features/xxx/index.ts)
Example: Creating “Brand” Feature
Section titled “Example: Creating “Brand” Feature”Step 1: Types
Section titled “Step 1: Types”export interface Brand { id: number; workspace_id: number; name: string; description: string; logo_url: string; status: 'active' | 'inactive'; created_at: string; updated_at: string;}
export interface BrandFilterParams { page?: number; limit?: number; status?: string; search?: string;}Step 2: Service
Section titled “Step 2: Service”import { apiClient } from './api.service';import type { Brand, BrandFilterParams } from '@core/types/models';import type { ApiResponse, PaginatedResponse } from '@core/types/api';
export const brandService = { getList: async (params: BrandFilterParams): Promise<PaginatedResponse<Brand>> => { const response = await apiClient.get<PaginatedResponse<Brand>>('/app/v1/brands', { params }); return response.data; },
getById: async (id: number): Promise<Brand> => { const response = await apiClient.get<ApiResponse<Brand>>(`/app/v1/brands/${id}`); return response.data.data; },};Step 3: Query Hooks
Section titled “Step 3: Query Hooks”export const brandKeys = { all: ['brands'] as const, lists: () => [...brandKeys.all, 'list'] as const, list: (params: BrandFilterParams) => [...brandKeys.lists(), params] as const, details: () => [...brandKeys.all, 'detail'] as const, detail: (id: number) => [...brandKeys.details(), id] as const,};import { useQuery } from '@tanstack/react-query';import { useAtomValue } from 'jotai';import { profileAtom } from '@core/atoms/profile.atom';import { brandService } from '@core/services/brand.service';import { brandKeys } from './keys';import type { BrandFilterParams } from '@core/types/models';
export function useBrandsQuery(params: BrandFilterParams) { const profile = useAtomValue(profileAtom);
return useQuery({ queryKey: brandKeys.list(params), queryFn: () => brandService.getList(params), networkMode: 'offlineFirst', // CRITICAL staleTime: 5 * 60 * 1000, enabled: !!profile?.id, });}
export function useBrandQuery(id: number) { const profile = useAtomValue(profileAtom);
return useQuery({ queryKey: brandKeys.detail(id), queryFn: () => brandService.getById(id), networkMode: 'offlineFirst', enabled: !!id && !!profile?.id, });}Step 4: List Item Component
Section titled “Step 4: List Item Component”import React from 'react';import { StyleSheet } from 'react-native';import { Card, Text, Badge } from 'react-native-paper';import type { Brand } from '@core/types/models';
interface BrandCardProps { item: Brand; onPress: (id: number) => void;}
// MUST use React.memo for list itemsexport const BrandCard = React.memo(function BrandCard({ item, onPress }: BrandCardProps) { return ( <Card style={styles.card} onPress={() => onPress(item.id)}> <Card.Content> <Text variant="titleMedium" style={styles.title}> {item.name} </Text> <Text variant="bodyMedium" numberOfLines={2}> {item.description} </Text> <Badge style={styles.badge}>{item.status}</Badge> </Card.Content> </Card> );});
const styles = StyleSheet.create({ card: { marginHorizontal: 16, marginVertical: 8, }, title: { fontWeight: 'bold', marginBottom: 4, }, badge: { alignSelf: 'flex-start', marginTop: 8, },});Step 5: List Screen
Section titled “Step 5: List Screen”import React, { useCallback, useState } from 'react';import { View, StyleSheet } from 'react-native';import { FlashList } from '@shopify/flash-list';import { Appbar, Searchbar, FAB } from 'react-native-paper';import { useBrandsQuery } from '@core/states/queries/brand/query';import { BrandCard } from '../components/BrandCard';import type { Brand } from '@core/types/models';
export function BrandListScreen({ navigation }: any) { const [search, setSearch] = useState(''); const { data, isLoading } = useBrandsQuery({ search, limit: 50 });
const handlePress = useCallback((id: number) => { navigation.navigate('BrandDetail', { id }); }, [navigation]);
const renderItem = useCallback(({ item }: { item: Brand }) => ( <BrandCard item={item} onPress={handlePress} /> ), [handlePress]);
return ( <View style={styles.container}> <Appbar.Header> <Appbar.Content title="Brands" /> </Appbar.Header>
<Searchbar placeholder="Search brands..." value={search} onChangeText={setSearch} style={styles.searchbar} />
<FlashList data={data?.data ?? []} renderItem={renderItem} estimatedItemSize={100} keyExtractor={item => item.id.toString()} />
<FAB icon="plus" style={styles.fab} onPress={() => navigation.navigate('BrandCreate')} /> </View> );}
const styles = StyleSheet.create({ container: { flex: 1, }, searchbar: { margin: 16, }, fab: { position: 'absolute', right: 16, bottom: 16, },});Step 6: Navigation
Section titled “Step 6: Navigation”import { createNativeStackNavigator } from '@react-navigation/native-stack';import { BrandListScreen } from './screens/BrandListScreen';import { BrandDetailScreen } from './screens/BrandDetailScreen';
const Stack = createNativeStackNavigator();
export function BrandNavigator() { return ( <Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Screen name="BrandList" component={BrandListScreen} /> <Stack.Screen name="BrandDetail" component={BrandDetailScreen} /> </Stack.Navigator> );}Step 7: Barrel Export
Section titled “Step 7: Barrel Export”export { BrandCard } from './components/BrandCard';export { BrandListScreen } from './screens/BrandListScreen';export { BrandDetailScreen } from './screens/BrandDetailScreen';export { BrandNavigator } from './navigation';Running the Mobile App
Section titled “Running the Mobile App”cd merq-mobile
# Install podscd ios && pod install && cd ..
# Run on simulatorpnpm ios
# Run on specific devicepnpm ios --device "iPhone 14"
# Run on physical devicepnpm ios --device "Your iPhone Name"Android
Section titled “Android”cd merq-mobile
# Run on emulator/devicepnpm android
# Run on specific devicepnpm android --deviceId=emulator-5554
# Build releasepnpm android --variant=releaseDevelopment
Section titled “Development”# Start Metro bundlerpnpm start
# Clear cachepnpm start --reset-cache
# Type checkpnpm type-check
# Lintpnpm lintBest Practices
Section titled “Best Practices”1. Gate Queries on Profile
Section titled “1. Gate Queries on Profile”// ✅ CORRECTconst profile = useAtomValue(profileAtom);const { data } = useOutletsQuery(params, { enabled: !!profile?.id,});
// ❌ WRONG - 401 loopsconst { data } = useOutletsQuery(params);2. Always Use offlineFirst
Section titled “2. Always Use offlineFirst”// ✅ CORRECTuseQuery({ networkMode: 'offlineFirst',});
// ❌ WRONG - Default is 'online'useQuery({ ... });3. Memo List Items
Section titled “3. Memo List Items”// ✅ CORRECTexport const OutletCard = React.memo(function OutletCard({ item }: Props) { return <Card>...</Card>;});
// ❌ WRONG - Re-renders on every list updateexport function OutletCard({ item }: Props) { return <Card>...</Card>;}4. Use FlashList
Section titled “4. Use FlashList”// ✅ CORRECT - Large lists<FlashList data={items} renderItem={renderItem} estimatedItemSize={80} />
// ❌ WRONG - Performance issues<ScrollView> {items.map(item => <Card key={item.id} />)}</ScrollView>