Steven's Knowledge

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

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

TypeConventionExample
Component filesPascalCaseOrderDetailScreen.tsx
Hook filescamelCase with use prefixuseOrderDetail.ts
Util filescamelCaseformatCurrency.ts
Platform-specific.ios.tsx / .android.tsx suffixButton.ios.tsx, Button.android.tsx

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

ApproachWhen to use
StyleSheet.createDefault. Fast, no extra dependency, plays nicely with TypeScript.
nativewind (Tailwind)Team comes from web Tailwind; want utility-first DX
restyle / dripsyTheme-driven token system, design system enforcement
styled-componentsMigrating 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.mdx

When to Extract

Diff sizeApproach
1–2 propsPlatform.select inline
Whole render branchPlatform.OS === 'ios' ? <A /> : <B />
Whole component.ios.tsx / .android.tsx files
Native behaviorNative 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>
  );
}
ConcernRecommendation
Navigation@react-navigation/native-stack
Server state@tanstack/react-query
Client statezustand (small/medium) or @reduxjs/toolkit (large/strict)
Validationzod
Formsreact-hook-form + zod resolver
Storagereact-native-mmkv (10–30× faster than AsyncStorage)
Animationsreact-native-reanimated v3
Gesturesreact-native-gesture-handler
Lists@shopify/flash-list for long / heterogeneous lists
Imagesexpo-image (caching, blurhash, transitions)
Crash reportingSentry

On this page