Best Practices
React Native engineering practices — project structure, navigation, styling, platform code, TypeScript setup
Best Practices
Production-validated React Native engineering practices. Guiding principles: keep work off the JS thread, isolate platform differences, type the native boundary.
Project Structure
Recommended Layering
src/
├── App.tsx
├── app/ Application entry configuration
│ ├── navigation/ Stack/Tab/Drawer config (react-navigation)
│ ├── providers/ QueryClientProvider, ThemeProvider, GestureHandlerRootView
│ └── theme/ Tokens, palette, typography
├── core/ Cross-cutting reusable utilities
│ ├── api/ Fetch wrapper, interceptors, error normalization
│ ├── storage/ MMKV / AsyncStorage wrappers
│ ├── hooks/ Generic hooks (useDebounce, useAppState)
│ └── utils/ Pure utility functions
├── features/ Sliced by business feature (recommended)
│ └── order/
│ ├── api/ Endpoints, schemas (zod)
│ ├── hooks/ Feature-specific hooks, queries, mutations
│ ├── screens/ Screen components
│ └── components/ Feature-specific UI
└── shared/ UI components shared across features
└── components/Type-based layering scales poorly. A flat screens/, components/, services/ structure works for tutorials but becomes hard to navigate once a project has dozens of screens, and it makes feature extraction expensive later. Feature-based slicing is what most production codebases converge on; default to it unless you have a specific reason otherwise.
File Naming
| Type | Convention | Example |
|---|---|---|
| Component files | PascalCase | OrderDetailScreen.tsx |
| Hook files | camelCase with use prefix | useOrderDetail.ts |
| Util files | camelCase | formatCurrency.ts |
| Platform-specific | .ios.tsx / .android.tsx suffix | Button.ios.tsx, Button.android.tsx |
Navigation
Use React Navigation unless you have a hard requirement otherwise. Typed routes are non-negotiable for any project that ships.
Typed Stack
// app/navigation/types.ts
export type RootStackParamList = {
Home: undefined;
OrderDetail: { orderId: string };
Profile: { userId: string; tab?: 'overview' | 'history' };
};
// app/navigation/RootStack.tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="OrderDetail" component={OrderDetailScreen} />
</Stack.Navigator>
);
}
// In a screen
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
type Props = NativeStackScreenProps<RootStackParamList, 'OrderDetail'>;
export function OrderDetailScreen({ route, navigation }: Props) {
const { orderId } = route.params; // typed
// ...
}Prefer @react-navigation/native-stack over @react-navigation/stack. The native stack uses the platform's native navigator (UINavigationController / Fragment), which means correct gesture transitions, large titles on iOS, and far better performance. The JS stack remains useful only when you need fully custom transitions.
Deep Linking
const linking = {
prefixes: ['onenz://', 'https://app.one.nz'],
config: {
screens: {
OrderDetail: 'order/:orderId',
Profile: 'profile/:userId',
},
},
};
<NavigationContainer linking={linking}>...</NavigationContainer>Styling
Pick One System, Stick With It
| Approach | When to use |
|---|---|
StyleSheet.create | Default. Fast, no extra dependency, plays nicely with TypeScript. |
nativewind (Tailwind) | Team comes from web Tailwind; want utility-first DX |
restyle / dripsy | Theme-driven token system, design system enforcement |
styled-components | Migrating from web; avoid for new RN projects (perf overhead) |
// Recommended: StyleSheet + theme tokens
import { StyleSheet } from 'react-native';
import { useTheme } from '@/app/theme';
export function PrimaryButton({ label, onPress }: Props) {
const theme = useTheme();
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.base,
{ backgroundColor: theme.colors.primary },
pressed && styles.pressed,
]}
>
<Text style={[styles.label, { color: theme.colors.onPrimary }]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: { paddingHorizontal: 16, paddingVertical: 12, borderRadius: 8 },
pressed: { opacity: 0.8 },
label: { fontSize: 16, fontWeight: '600' },
});Avoid inline style objects in FlatList item renderers. Each render creates a new object, defeating PureComponent shallow-equal optimizations. Use StyleSheet.create (returns stable IDs) or pull the array out of the render path.
Platform-Specific Code
Three Levels of Granularity
// 1. Inline branch — for one-liner differences
import { Platform } from 'react-native';
const shadow = Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8 },
android: { elevation: 4 },
});
// 2. File extension — when the component diverges
// Button.ios.tsx
// Button.android.tsx
// Metro picks the right one automatically:
import { Button } from './Button';
// 3. Native module — when you actually need platform APIs
// See native-modules.mdxWhen to Extract
| Diff size | Approach |
|---|---|
| 1–2 props | Platform.select inline |
| Whole render branch | Platform.OS === 'ios' ? <A /> : <B /> |
| Whole component | .ios.tsx / .android.tsx files |
| Native behavior | Native module |
TypeScript Setup
Strict, Always
// tsconfig.json
{
"extends": "@tsconfig/react-native/tsconfig.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
}
}Validate at the API Boundary
// features/order/api/schema.ts
import { z } from 'zod';
export const OrderSchema = z.object({
id: z.string(),
status: z.enum(['pending', 'shipped', 'delivered']),
total: z.number(),
createdAt: z.string().datetime(),
});
export type Order = z.infer<typeof OrderSchema>;
// features/order/api/getOrder.ts
export async function getOrder(id: string): Promise<Order> {
const res = await api.get(`/orders/${id}`);
return OrderSchema.parse(res.data); // throws if backend drifts
}Don't trust TypeScript at the network boundary. TS types do not exist at runtime; a backend change can hand you null where you typed a string and the app blows up at a random call site. Parse incoming JSON with zod (or valibot / io-ts) so the error happens at the source, with a useful message.
Forms
Use react-hook-form for non-trivial forms — uncontrolled inputs avoid re-rendering on every keystroke. Combine with zod resolver for typed validation.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormData = z.infer<typeof schema>;
export function LoginForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<>
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<TextInput value={value} onChangeText={onChange} keyboardType="email-address" />
)}
/>
{errors.email && <Text>{errors.email.message}</Text>}
<Button title="Sign in" onPress={handleSubmit(onSubmit)} />
</>
);
}Internationalization
Use i18next + react-i18next. For RTL support, set I18nManager.forceRTL() early in app boot (requires reload to take effect).
// app/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
i18n.use(initReactI18next).init({
resources: {
en: { translation: { welcome: 'Welcome' } },
mi: { translation: { welcome: 'Nau mai' } }, // Te reo Māori
},
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
});Environment Variables
react-native-config exposes .env values to JS, Java, and Obj-C/Swift. Treat secrets as build-time only — anything baked into the JS bundle is extractable. Real secrets belong on the server.
# .env.development
API_BASE_URL=https://api-dev.one.nz
SENTRY_DSN=...
# .env.production
API_BASE_URL=https://api.one.nz
SENTRY_DSN=...Error Boundaries
Wrap each screen in an error boundary so one component crash does not white-screen the whole app.
import { ErrorBoundary } from 'react-error-boundary';
export function ScreenWrapper({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => Sentry.captureException(error, { extra: info })}
>
{children}
</ErrorBoundary>
);
}Recommended Stack
| Concern | Recommendation |
|---|---|
| Navigation | @react-navigation/native-stack |
| Server state | @tanstack/react-query |
| Client state | zustand (small/medium) or @reduxjs/toolkit (large/strict) |
| Validation | zod |
| Forms | react-hook-form + zod resolver |
| Storage | react-native-mmkv (10–30× faster than AsyncStorage) |
| Animations | react-native-reanimated v3 |
| Gestures | react-native-gesture-handler |
| Lists | @shopify/flash-list for long / heterogeneous lists |
| Images | expo-image (caching, blurhash, transitions) |
| Crash reporting | Sentry |