Appendix C: Essential Feature Implementations
Ready-to-use code for common app features. For boilerplate-specific features — design system, API routes, fonts, icons, app store listing, local dev, and EAS deployment — see the dedicated docs pages in the Boilerplate Docs section of the sidebar.
New in 2026: VidNotes Design System, 6 Premium Font Families, Expo API Routes, AI System Prompts, Icon & Splash Generation, App Store Listing Templates, and Local Dev Server. See the Boilerplate Docs section for full documentation.
Hard Paywall (Default): The boilerplate ships with a 3-layer hard paywall as the default monetization pattern. Users must subscribe before accessing any app features. This architecture is documented in detail on the Architecture page. The paywall guard runs in
index.tsx, (app)/_layout.tsx, and the usePremiumStatus hook — all working together via a shared usePremiumStore (Zustand).Dark Mode
Theme Context Setup
contexts/ThemeContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: 'light' | 'dark';
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
colors: typeof lightColors;
}
const lightColors = {
primary: '#7C3AED',
background: '#FFFFFF',
surface: '#F9FAFB',
surfaceSecondary: '#F3F4F6',
text: '#111827',
textSecondary: '#6B7280',
textTertiary: '#9CA3AF',
border: '#E5E7EB',
error: '#EF4444',
success: '#22C55E',
warning: '#F59E0B',
};
const darkColors = {
primary: '#A78BFA',
background: '#111827',
surface: '#1F2937',
surfaceSecondary: '#374151',
text: '#F9FAFB',
textSecondary: '#D1D5DB',
textTertiary: '#9CA3AF',
border: '#374151',
error: '#F87171',
success: '#4ADE80',
warning: '#FBBF24',
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemColorScheme = useColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
useEffect(() => {
loadThemePreference();
}, []);
const loadThemePreference = async () => {
const saved = await AsyncStorage.getItem('themeMode');
if (saved) {
setThemeModeState(saved as ThemeMode);
}
};
const setThemeMode = async (mode: ThemeMode) => {
setThemeModeState(mode);
await AsyncStorage.setItem('themeMode', mode);
};
const theme =
themeMode === 'system'
? systemColorScheme || 'light'
: themeMode;
const colors = theme === 'dark' ? darkColors : lightColors;
return (
<ThemeContext.Provider value={{ theme, themeMode, setThemeMode, colors }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}Theme Toggle Component
components/ThemeToggle.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '@/contexts/ThemeContext';
const options = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
] as const;
export function ThemeToggle() {
const { themeMode, setThemeMode, colors } = useTheme();
return (
<View style={styles.container}>
<Text style={[styles.label, { color: colors.text }]}>Theme</Text>
<View style={[styles.options, { backgroundColor: colors.surface }]}>
{options.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.option,
themeMode === option.value && {
backgroundColor: colors.primary,
},
]}
onPress={() => setThemeMode(option.value)}
>
<Text
style={[
styles.optionText,
{
color:
themeMode === option.value ? '#FFFFFF' : colors.textSecondary,
},
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}Deep Linking
Configure app.json
app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.yourcompany.myapp",
"associatedDomains": ["applinks:yourdomain.com"]
},
"android": {
"package": "com.yourcompany.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "yourdomain.com",
"pathPrefix": "/app"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Handle Deep Links
app/_layout.tsx
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { router, useRootNavigationState } from 'expo-router';
export default function RootLayout() {
const navigationState = useRootNavigationState();
useEffect(() => {
if (!navigationState?.key) return;
// Handle deep links
const handleDeepLink = (event: { url: string }) => {
handleUrl(event.url);
};
// Get initial URL (app opened from link)
Linking.getInitialURL().then((url) => {
if (url) handleUrl(url);
});
// Listen for URL events (app already open)
const subscription = Linking.addEventListener('url', handleDeepLink);
return () => subscription.remove();
}, [navigationState?.key]);
const handleUrl = (url: string) => {
const { hostname, pathname, searchParams } = new URL(url);
// Handle different deep link paths
if (pathname.startsWith('/profile/')) {
const userId = pathname.split('/')[2];
router.push(`/profile/${userId}`);
} else if (pathname.startsWith('/post/')) {
const postId = pathname.split('/')[2];
router.push(`/post/${postId}`);
} else if (pathname === '/premium') {
router.push('/paywall');
}
};
return <Stack />;
}Generate Deep Links
utils/deepLinks.ts
import * as Linking from 'expo-linking';
export function createDeepLink(path: string): string {
return Linking.createURL(path);
}
export function createWebLink(path: string): string {
return `https://yourdomain.com/app${path}`;
}
// Usage
const profileLink = createDeepLink('/profile/123');
// => "myapp://profile/123"
const shareableLink = createWebLink('/post/456');
// => "https://yourdomain.com/app/post/456"Share with Deep Links
import { Share } from 'react-native';
import { createWebLink } from '@/utils/deepLinks';
async function sharePost(postId: string, title: string) {
const url = createWebLink(`/post/${postId}`);
await Share.share({
message: `Check out "${title}" on MyApp`,
url,
});
}Push Notifications
Setup
npx expo install expo-notifications expo-device expo-constantsRequest Permission & Get Token
lib/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { convex } from './convex';
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications(): Promise<string | null> {
// Only works on physical devices
if (!Device.isDevice) {
console.log('Push notifications only work on physical devices');
return null;
}
// Check existing permission
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request if not already granted
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Push notification permission not granted');
return null;
}
// Get Expo push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = (
await Notifications.getExpoPushTokenAsync({ projectId })
).data;
// Android-specific channel setup
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#7C3AED',
});
}
return token;
}
export async function savePushToken(userId: string, token: string) {
await convex.from('push_tokens').upsert({
user_id: userId,
token,
platform: Platform.OS,
updated_at: new Date().toISOString(),
});
}Listen for Notifications
hooks/useNotifications.ts
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
export function useNotifications() {
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
// Notification received while app is foregrounded
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
console.log('Notification received:', notification);
});
// User tapped on notification
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
// Handle navigation based on notification data
if (data.type === 'new_message') {
router.push(`/chat/${data.chatId}`);
} else if (data.type === 'generation_complete') {
router.push(`/generation/${data.generationId}`);
}
});
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
}Onboarding Flow
app/onboarding.tsx
import { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Dimensions,
FlatList,
TouchableOpacity,
Image,
} from 'react-native';
import { router } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Button } from '@/components/ui/Button';
const { width } = Dimensions.get('window');
const slides = [
{
id: '1',
title: 'Welcome to MyApp',
description: 'The best way to do amazing things with AI',
image: require('@/assets/onboarding-1.png'),
},
{
id: '2',
title: 'Create Anything',
description: 'Generate images, text, and more in seconds',
image: require('@/assets/onboarding-2.png'),
},
{
id: '3',
title: 'Save & Share',
description: 'Keep your creations and share with friends',
image: require('@/assets/onboarding-3.png'),
},
];
export default function OnboardingScreen() {
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList>(null);
const handleNext = () => {
if (currentIndex < slides.length - 1) {
flatListRef.current?.scrollToIndex({ index: currentIndex + 1 });
} else {
completeOnboarding();
}
};
const completeOnboarding = async () => {
await AsyncStorage.setItem('hasSeenOnboarding', 'true');
router.replace('/(tabs)');
};
const renderSlide = ({ item }) => (
<View style={styles.slide}>
<Image source={item.image} style={styles.image} resizeMode="contain" />
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.description}>{item.description}</Text>
</View>
);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={slides}
renderItem={renderSlide}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={(e) => {
const index = Math.round(e.nativeEvent.contentOffset.x / width);
setCurrentIndex(index);
}}
/>
{/* Dots */}
<View style={styles.dots}>
{slides.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentIndex && styles.activeDot,
]}
/>
))}
</View>
{/* Buttons */}
<View style={styles.buttons}>
<Button
title={currentIndex === slides.length - 1 ? 'Get Started' : 'Next'}
onPress={handleNext}
/>
{currentIndex < slides.length - 1 && (
<TouchableOpacity onPress={completeOnboarding} style={styles.skip}>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}Haptic Feedback
Setup
npx expo install expo-hapticsHaptic Utility
utils/haptics.ts
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
export const haptics = {
// Light tap - button press
light: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
},
// Medium tap - toggle, selection
medium: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
},
// Heavy tap - important action
heavy: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}
},
// Success - task completed
success: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
},
// Warning - attention needed
warning: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
},
// Error - something went wrong
error: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
},
// Selection change
selection: () => {
if (Platform.OS !== 'web') {
Haptics.selectionAsync();
}
},
};Usage
import { haptics } from '@/utils/haptics';
// Button press
<TouchableOpacity onPress={() => {
haptics.light();
handlePress();
}}>
// Success state
const handleSubmit = async () => {
try {
await submitForm();
haptics.success();
Alert.alert('Success!');
} catch (error) {
haptics.error();
Alert.alert('Error', error.message);
}
};Localization (i18n)
Setup
npm install i18next react-i18nextTranslation Files
locales/en.ts
export default {
common: {
loading: 'Loading...',
error: 'Something went wrong',
retry: 'Try again',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
confirm: 'Confirm',
},
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
signOut: 'Sign Out',
email: 'Email',
password: 'Password',
forgotPassword: 'Forgot password?',
},
settings: {
title: 'Settings',
language: 'Language',
theme: 'Theme',
notifications: 'Notifications',
privacy: 'Privacy Policy',
terms: 'Terms of Service',
version: 'Version',
},
paywall: {
title: 'Upgrade to Pro',
subtitle: 'Unlock all features',
monthly: 'Monthly',
yearly: 'Yearly',
restore: 'Restore Purchases',
freeTrial: '7-day free trial',
},
};i18n Setup
lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import AsyncStorage from '@react-native-async-storage/async-storage';
import en from '@/locales/en';
import es from '@/locales/es';
const resources = {
en: { translation: en },
es: { translation: es },
};
const LANGUAGE_KEY = 'user_language';
export const initI18n = async () => {
const savedLanguage = await AsyncStorage.getItem(LANGUAGE_KEY);
const deviceLanguage = Localization.locale.split('-')[0];
await i18n.use(initReactI18next).init({
resources,
lng: savedLanguage || deviceLanguage || 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
};
export const changeLanguage = async (lang: string) => {
await AsyncStorage.setItem(LANGUAGE_KEY, lang);
await i18n.changeLanguage(lang);
};
export default i18n;Usage
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return (
<View>
<Text>{t('common.loading')}</Text>
<Button title={t('auth.signIn')} onPress={handleSignIn} />
</View>
);
}Form Validation
Setup
npm install react-hook-form zod @hookform/resolversValidation Schema
schemas/auth.schema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const signupSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
export type LoginInput = z.infer<typeof loginSchema>;
export type SignupInput = z.infer<typeof signupSchema>;Form Component
components/LoginForm.tsx
import { View, TextInput, Text, StyleSheet } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, LoginInput } from '@/schemas/auth.schema';
import { Button } from '@/components/ui/Button';
interface LoginFormProps {
onSubmit: (data: LoginInput) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
return (
<View style={styles.form}>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
placeholder="Email"
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
{errors.email && (
<Text style={styles.error}>{errors.email.message}</Text>
)}
</View>
)}
/>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<TextInput
style={[styles.input, errors.password && styles.inputError]}
placeholder="Password"
value={value}
onChangeText={onChange}
onBlur={onBlur}
secureTextEntry
autoComplete="password"
/>
{errors.password && (
<Text style={styles.error}>{errors.password.message}</Text>
)}
</View>
)}
/>
<Button
title="Sign In"
onPress={handleSubmit(onSubmit)}
loading={isSubmitting}
/>
</View>
);
}OTA Updates (EAS Update)
Setup
npx expo install expo-updates
eas update:configureapp.json Configuration
{
"expo": {
"updates": {
"url": "https://u.expo.dev/your-project-id"
},
"runtimeVersion": {
"policy": "appVersion"
}
}
}Publish an Update
# Publish to all users
eas update --branch production --message "Bug fixes"
# Publish to preview channel
eas update --branch preview --message "New feature testing"Check for Updates in App
hooks/useAppUpdate.ts
import { useEffect } from 'react';
import * as Updates from 'expo-updates';
import { Alert } from 'react-native';
export function useAppUpdate() {
useEffect(() => {
checkForUpdates();
}, []);
const checkForUpdates = async () => {
if (__DEV__) return; // Skip in development
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
Alert.alert(
'Update Available',
'A new version is ready. Restart to apply?',
[
{ text: 'Later', style: 'cancel' },
{
text: 'Restart',
onPress: () => Updates.reloadAsync(),
},
]
);
}
} catch (error) {
console.log('Error checking for updates:', error);
}
};
}App Review Request
Setup
npx expo install expo-store-reviewSmart Review Prompt
lib/reviewPrompt.ts
import * as StoreReview from 'expo-store-review';
import AsyncStorage from '@react-native-async-storage/async-storage';
const REVIEW_KEY = 'app_review_status';
const MIN_ACTIONS = 5; // Show after 5 successful actions
const MIN_DAYS = 3; // Wait at least 3 days after install
interface ReviewStatus {
hasAsked: boolean;
actionCount: number;
installDate: string;
}
export async function trackAction() {
const status = await getReviewStatus();
status.actionCount++;
await saveReviewStatus(status);
// Check if we should ask for review
if (shouldAskForReview(status)) {
await requestReview();
}
}
function shouldAskForReview(status: ReviewStatus): boolean {
if (status.hasAsked) return false;
if (status.actionCount < MIN_ACTIONS) return false;
const daysSinceInstall = getDaysSince(status.installDate);
if (daysSinceInstall < MIN_DAYS) return false;
return true;
}
async function requestReview() {
const isAvailable = await StoreReview.isAvailableAsync();
if (isAvailable) {
await StoreReview.requestReview();
const status = await getReviewStatus();
status.hasAsked = true;
await saveReviewStatus(status);
}
}Usage
// After successful generation, purchase, or positive action
import { trackAction } from '@/lib/reviewPrompt';
const handleGenerationSuccess = async () => {
// ... generation logic
await trackAction(); // May trigger review prompt
};Splash Screen
Design Requirements
| Platform | Size | Format |
|---|---|---|
| iOS | 2732 x 2732 | PNG (centered, will be cropped) |
| Android | 1284 x 2778 | PNG |
| Universal | 1284 x 2778 | PNG (recommended) |
Canva Template: Search for "App Splash Screen 1284x2778" or create custom dimensions.
Setup
npx expo install expo-splash-screenapp.json Configuration
{
"expo": {
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#7C3AED"
},
"ios": {
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#7C3AED",
"dark": {
"image": "./assets/splash-dark.png",
"backgroundColor": "#1F2937"
}
}
}
}
}Keep Splash Visible During Loading
app/_layout.tsx
import { useEffect, useState, useCallback } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import * as Font from 'expo-font';
import { View } from 'react-native';
// Prevent auto-hide
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useState(false);
useEffect(() => {
async function prepare() {
try {
// Load fonts, fetch initial data, etc.
await Font.loadAsync({
'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
});
// Artificial delay for branding (optional)
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (e) {
console.warn(e);
} finally {
setAppIsReady(true);
}
}
prepare();
}, []);
const onLayoutRootView = useCallback(async () => {
if (appIsReady) {
await SplashScreen.hideAsync();
}
}, [appIsReady]);
if (!appIsReady) {
return null;
}
return (
<View style={{ flex: 1 }} onLayout={onLayoutRootView}>
<Stack />
</View>
);
}App Initialization
Complete Loading Pattern
app/_layout.tsx
import { useEffect, useState, useCallback } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import * as Font from 'expo-font';
import { Asset } from 'expo-asset';
import { View } from 'react-native';
import { initI18n } from '@/lib/i18n';
import { convex } from '@/lib/convex';
import { useAuthStore } from '@/stores/authStore';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useState(false);
const setUser = useAuthStore((state) => state.setUser);
const setSession = useAuthStore((state) => state.setSession);
useEffect(() => {
async function prepare() {
try {
// 1. Load fonts
await Font.loadAsync({
'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('@/assets/fonts/Inter-Medium.ttf'),
'Inter-SemiBold': require('@/assets/fonts/Inter-SemiBold.ttf'),
'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
});
// 2. Preload critical images
await Asset.loadAsync([
require('@/assets/images/logo.png'),
require('@/assets/images/onboarding-1.png'),
require('@/assets/images/onboarding-2.png'),
require('@/assets/images/onboarding-3.png'),
]);
// 3. Initialize i18n
await initI18n();
// 4. Check existing auth session
const { data: { session } } = await convex.auth.getSession();
if (session) {
setSession(session);
setUser(session.user);
}
// 5. Any other initialization...
} catch (e) {
console.warn('App initialization error:', e);
} finally {
setAppIsReady(true);
}
}
prepare();
}, []);
const onLayoutRootView = useCallback(async () => {
if (appIsReady) {
await SplashScreen.hideAsync();
}
}, [appIsReady]);
if (!appIsReady) {
return null;
}
return (
<View style={{ flex: 1 }} onLayout={onLayoutRootView}>
<Providers>
<Stack />
</Providers>
</View>
);
}Font Loading Hook
hooks/useFonts.ts
import * as Font from 'expo-font';
import { useEffect, useState } from 'react';
const customFonts = {
'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('@/assets/fonts/Inter-Medium.ttf'),
'Inter-SemiBold': require('@/assets/fonts/Inter-SemiBold.ttf'),
'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
};
export function useFonts() {
const [fontsLoaded, setFontsLoaded] = useState(false);
useEffect(() => {
async function loadFonts() {
await Font.loadAsync(customFonts);
setFontsLoaded(true);
}
loadFonts();
}, []);
return fontsLoaded;
}Asset Preloading Utility
utils/preloadAssets.ts
import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
const images = [
require('@/assets/images/logo.png'),
require('@/assets/images/placeholder.png'),
];
const fonts = {
'Inter-Regular': require('@/assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('@/assets/fonts/Inter-Bold.ttf'),
};
export async function preloadAssets(): Promise<void> {
const imageAssets = images.map((image) => Asset.loadAsync(image));
const fontAssets = Font.loadAsync(fonts);
await Promise.all([...imageAssets, fontAssets]);
}Keyboard Handling
components/KeyboardAvoidingWrapper.tsx
import { ReactNode } from 'react';
import {
KeyboardAvoidingView,
Platform,
StyleSheet,
TouchableWithoutFeedback,
Keyboard,
} from 'react-native';
interface Props {
children: ReactNode;
}
export function KeyboardAvoidingWrapper({ children }: Props) {
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
{children}
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});Safe Area Handling
Setup
npx expo install react-native-safe-area-contextProvider Setup
app/_layout.tsx
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack />
</SafeAreaProvider>
);
}Using SafeAreaView
import { SafeAreaView } from 'react-native-safe-area-context';
import { View, Text, StyleSheet } from 'react-native';
export function HomeScreen() {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
<Text>Content safe from notches and home indicators</Text>
</View>
</SafeAreaView>
);
}Using useSafeAreaInsets Hook
import { View, Text, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export function CustomHeader({ title }: { title: string }) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
<Text style={styles.title}>{title}</Text>
</View>
);
}Testing
Setup
npm install --save-dev jest @testing-library/react-native @testing-library/jest-native jest-expoTesting Components
__tests__/Button.test.tsx
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '@/components/ui/Button';
describe('Button', () => {
it('renders correctly', () => {
const { getByText } = render(<Button title="Press me" onPress={() => {}} />);
expect(getByText('Press me')).toBeTruthy();
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(<Button title="Press me" onPress={onPress} />);
fireEvent.press(getByText('Press me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('shows loading state', () => {
const { getByTestId } = render(
<Button title="Press me" onPress={() => {}} loading />
);
expect(getByTestId('loading-indicator')).toBeTruthy();
});
});Running Tests
# Run all tests
npm test
# Run with coverage
npm test -- --coverage
# Run in watch mode
npm test -- --watchImage Caching
Setup
npx expo install expo-imageCached Image Component
components/CachedImage.tsx
import { Image, ImageProps } from 'expo-image';
const blurhash = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4';
interface CachedImageProps extends Omit<ImageProps, 'source'> {
uri: string;
}
export function CachedImage({ uri, style, ...props }: CachedImageProps) {
return (
<Image
source={{ uri }}
placeholder={blurhash}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
style={style}
{...props}
/>
);
}
// Usage
<CachedImage
uri="https://example.com/image.jpg"
style={{ width: 200, height: 200, borderRadius: 12 }}
/>Offline Detection
hooks/useOffline.ts
import { useState, useEffect } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useOffline() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOffline(!state.isConnected);
});
return () => unsubscribe();
}, []);
return isOffline;
}components/OfflineBanner.tsx
import { View, Text, StyleSheet } from 'react-native';
import { useOffline } from '@/hooks/useOffline';
export function OfflineBanner() {
const isOffline = useOffline();
if (!isOffline) return null;
return (
<View style={styles.banner}>
<Text style={styles.text}>No internet connection</Text>
</View>
);
}Official Documentation
For the latest APIs and best practices, refer to the official documentation:
- Dark Mode: reactnative.dev/docs/usecolorscheme
- Deep Linking: docs.expo.dev/guides/deep-linking
- Push Notifications: docs.expo.dev/push-notifications
- Expo Image: docs.expo.dev/versions/latest/sdk/image
- EAS Update: docs.expo.dev/eas-update
- i18next: react.i18next.com
- React Hook Form: react-hook-form.com
- Store Review: docs.expo.dev/versions/latest/sdk/storereview
- Jest Expo: docs.expo.dev/guides/testing-with-jest
- Splash Screen: docs.expo.dev/versions/latest/sdk/splash-screen
- Safe Area Context: docs.expo.dev/versions/latest/sdk/safe-area-context
- Keyboard Avoiding View: reactnative.dev/docs/keyboardavoidingview
Features Built-In
Dark mode, push, deep links included
Essential features already implemented and tested on both platforms.
Dark mode toggle
Push notifications
Deep linking ready
Get ShipReactNative
Save 40+ hours of setup