Skip to content

Mobile Development Overview

Merq mobile app adalah offline-first field application yang dibangun dengan React Native.

  • 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
┌──────────────────────────────────┐
│ 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 │
└──────┘ └───────┘
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 root

ALWAYS use offlineFirst network mode:

// ✅ CORRECT
export 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 mode
export function useOutletsQuery(params: OutletParams) {
return useQuery({
queryKey: ['outlets', params],
queryFn: () => outletService.getList(params),
// Missing networkMode - will fail offline
});
}

NO AsyncStorage, NO SQLite:

// ✅ CORRECT - MMKV
import { storage } from '@core/storage';
storage.set('key', JSON.stringify(data));
// ❌ WRONG - AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('key', JSON.stringify(data));
// ❌ WRONG - SQLite
import SQLite from 'react-native-sqlite-storage';
// ✅ CORRECT
import { Button, Card, Text } from 'react-native-paper';
// ❌ WRONG - Other UI libraries
import { Button } from 'react-native-elements';
import { Card } from 'native-base';
// ✅ CORRECT
export 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 styles
export function OutletCard({ item }: Props) {
return (
<Card style={{ marginBottom: 12, padding: 16 }}>
<Text style={{ fontSize: 16, fontWeight: 'bold' }}>{item.name}</Text>
</Card>
);
}
// ✅ CORRECT - Named exports
export function OutletCard({ item }: Props) { ... }
export function OutletList({ data }: Props) { ... }
// features/outlet/index.ts
export { OutletCard } from './components/OutletCard';
export { OutletList } from './components/OutletList';
export { OutletListScreen } from './screens/OutletListScreen';
// ❌ WRONG - Default exports
export default function OutletCard({ item }: Props) { ... }
// ✅ CORRECT - FlashList for 20+ items
import { 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>
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';

Order of implementation:

  1. Types (core/types/models.ts)
  2. Service (core/services/xxx.service.ts)
  3. Query Hooks (core/states/queries/xxx/)
  4. Components (features/xxx/components/)
  5. Screens (features/xxx/screens/)
  6. Navigation (features/xxx/navigation.tsx)
  7. Barrel Export (features/xxx/index.ts)
core/types/models.ts
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;
}
core/services/brand.service.ts
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;
},
};
core/states/queries/brand/keys.ts
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,
};
core/states/queries/brand/query.ts
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,
});
}
features/brand/components/BrandCard.tsx
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 items
export 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,
},
});
features/brand/screens/BrandListScreen.tsx
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,
},
});
features/brand/navigation.tsx
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>
);
}
features/brand/index.ts
export { BrandCard } from './components/BrandCard';
export { BrandListScreen } from './screens/BrandListScreen';
export { BrandDetailScreen } from './screens/BrandDetailScreen';
export { BrandNavigator } from './navigation';
Terminal window
cd merq-mobile
# Install pods
cd ios && pod install && cd ..
# Run on simulator
pnpm ios
# Run on specific device
pnpm ios --device "iPhone 14"
# Run on physical device
pnpm ios --device "Your iPhone Name"
Terminal window
cd merq-mobile
# Run on emulator/device
pnpm android
# Run on specific device
pnpm android --deviceId=emulator-5554
# Build release
pnpm android --variant=release
Terminal window
# Start Metro bundler
pnpm start
# Clear cache
pnpm start --reset-cache
# Type check
pnpm type-check
# Lint
pnpm lint
// ✅ CORRECT
const profile = useAtomValue(profileAtom);
const { data } = useOutletsQuery(params, {
enabled: !!profile?.id,
});
// ❌ WRONG - 401 loops
const { data } = useOutletsQuery(params);
// ✅ CORRECT
useQuery({
networkMode: 'offlineFirst',
});
// ❌ WRONG - Default is 'online'
useQuery({ ... });
// ✅ CORRECT
export const OutletCard = React.memo(function OutletCard({ item }: Props) {
return <Card>...</Card>;
});
// ❌ WRONG - Re-renders on every list update
export function OutletCard({ item }: Props) {
return <Card>...</Card>;
}
// ✅ CORRECT - Large lists
<FlashList data={items} renderItem={renderItem} estimatedItemSize={80} />
// ❌ WRONG - Performance issues
<ScrollView>
{items.map(item => <Card key={item.id} />)}
</ScrollView>