React Native is an easy way to build native iOS/Android (also Windows, macOS, web) using JavaScript and React.
With Expo (https://expo.dev) the steps are easy:
(First: update expo-cli, see “Upgrade Expo” below)
- Create a new app (
npx create-expo-app@latest --template) - Run it on device or simulator (
expo start) - Publish it to Expo.dev (
npx eas update --branch main --message "first publish") - Build it as native iOS/Android app with EAS
- Add extra packages (location) with
expo installNOT npm/yarn.
- Official getting started: https://facebook.github.io/react-native/docs/getting-started.html
- Guide: https://hackernoon.com/learning-react-native-where-to-start-49df64cf14a2
- Coding guidebook: http://www.reactnativeexpress.com
npx create-expo-app@latest --template
Install https://expo.dev client on your device.
# npm install -g expo-cli
yarn global add expo-cli
yarn global add eas-cli
expo upgrade
Init app:
expo init MyReactNativeApp
Templates:
-
Managed workflow: you only write JavaScript / TypeScript and Expo tools and services take care of everything else for you.
-
Bare workflow: you have full control over every aspect of the native project, and Expo tools and services are a little more limited.
cd MyReactNativeApp yarn start # or: npm start, expo start
Official TS Tabs template:
mkdir assets
mkdir -p components/__tests__
mkdir constants
mkdir hooks
mkdir navigation
mkdir screens
Tom’s:
mkdir assets
mkdir -p components/__tests__
mkdir -p components/common
mkdir screens
mkdir -p screens/StartScreen
touch screens/StartScreen/index.js
mkdir navigation
mkdir hooks
mkdir lib
mkdir config
touch config/config.js
mkdir types
yarn add standard --dev
expo install expo-font
yarn add @react-navigation/native
"upgrade-expo": "yarn add expo@latest; npx expo install --fix; npx expo install --check; npx expo-doctor; yarn; yarn api",
"dev": "yarn start",
"start": "expo start",
"eject": "expo eject",
"test": "echo 'Running Standard.js and Jasmine unit tests...\n' && yarn lint && yarn unit",
"unit": "babel-node spec/run.js",
"lint": "ts-standard",
"fix": "ts-standard --fix",
"news": "mkdir -p components/screens/New; cp components/screens/PlaceHolder.tsx components/screens/New.tsx; echo \"Now rename file 'components/screens/New.tsx' to whatever you want.\"",
"newc": "cp components/common/Centered.tsx components/common/New.tsx; echo \"Now rename/move 'components/common/New.tsx' to whatever you want.\"",
"newn": "cp components/navigators/OnboardingStackNavigator.tsx components/navigators/NewStackNavigator.tsx; echo \"Now rename/move 'components/navigators/NewStackNavigator.tsx' to whatever you want.\"",
"pub": "expo publish",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"submit:ios": "eas submit --platform ios --latest",
"submit:android": "eas submit --platform android --latest",
"submit:hotfix": "eas update",
"api": "eval $(grep '^SUPABASE_URL' .env.local) && eval $(grep '^SUPABASE_API_KEY' .env.local) && npx openapi-typescript@5.4.0 ${SUPABASE_URL}/rest/v1/?apikey=${SUPABASE_API_KEY} --output types/supabase.ts"
NOTE: use expo install primarily (rather than yarn/npm), e.g:
expo install react-native-maps
expo install expo-location
expo install react-native-svg
Managed:
expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
https://docs.expo.dev/ui-programming/react-native-toast/
Profiles (--profile) → come from eas.json.
- Control how the build/update is created: env vars, secrets, build type (debug vs release), distribution.
- Think: “Which config preset should I use?”
- Example: development, preview, production.
Branches (--branch) → live on Expo’s servers.
- Act like Git branches, but for your JS/asset updates.
- Hold a timeline of updates that you publish with eas update.
- Think: “Where should this JS update go so that clients know to fetch it?”
- Example: main, staging, feature-xyz.
https://docs.expo.dev/guides/using-nextjs/ https://github.com/expo/expo-cli/tree/master/packages/next-adapter
npx create-next-app -e with-expo PROJECTNAME
(Fails:) npx create-react-native-app -t with-nextjs PROJECTNAME
yarn next dev # start the Next.js project
expo start # start the Expo project
<Text
accessibilityRole='header'
aria-level={1}
>
This is H1
</Text>
yarn add babel-plugin-transform-class-properties --dev Babel config
module.exports = { presets: ['@expo/next-adapter/babel'], plugins: ['@babel/plugin-proposal-class-properties'] }
import * as React from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createStackNavigator } from '@react-navigation/stack'
import StartScreen from './screens/StartScreen'
// const navigationPersistenceKey = 'NavigationState'
const Stack = createStackNavigator()
function App () {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name='My App Title' component={StartScreen} />
</Stack.Navigator>
</NavigationContainer>
)
}
export default App
expo doctor --fix-dependencies
expo start --web
Get web viewport size:
import { Dimensions } from 'react-native'
const { width, height } = Dimensions.get('window')
- favicon.png: 48px
- icon.png: 1024px
- adaptive-icon.png: 1024px
- splash.png: 1284×2778px
import React from 'react'
import { StyleSheet, Text, View, Button } from 'react-native'
import logo from './assets/logo.png'
export default class App extends React.Component {
render () {
return (
<View style={styles.container}>
<Text>Welcome to My App</Text>
<Image source={logo} />
<MyCustomComponent />
<Button
onPress={this.handleGenerate.bind(this)}
title='Reshuffle the cards'
color='#841584'
accessibilityLabel='Restart the game'
/>
</View>
)
}
}
https://reactnativeelements.com/docs/button/
- Avatar
- Badge
- Bottom Sheet
- Button
- ButtonGroup
- Card
- CheckBox
- Divider
- Header
- Icon
- Image
- Input
- ListItem
- Overlay
- Pricing
- Rating
- SearchBar
- Slider
- SocialIcon
- Text
- Tile
- Tooltip
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center'
}
})
<View
onTouchStart={(e) => console.log('onTouchStart', [e.nativeEvent.locationX, e.nativeEvent.locationY])}
onTouchMove={(e) => console.log('onTouchMove', [e.nativeEvent.locationX, e.nativeEvent.locationY])}
onTouchEnd={(e) => console.log('onTouchEnd', [e.nativeEvent.locationX, e.nativeEvent.locationY])}
/>
__DEV__ variable: true/false
https://docs.expo.dev/guides/environment-variables/
EXPO_PUBLIC_prefix- Commands
eas env:list,eas env:create,eas env:update,eas env:delete
- Material Design: https://github.com/callstack/react-native-paper
- Button: https://github.com/APSL/react-native-button
+not-found.tsx: Custom 404 screen+layout.tsx: Layout component (Tabs, Stack, etc.)+error.tsx: Error boundary for that routeindex.tsx: The main page+page.tsx: The main page (alternative toindex.tsx)
+html.tsx: Custom HTML shell for web platform
import { useRouter, router, useNavigation, useLocalSearchParams } from 'expo-router'
const router = useRouter()
router.navigate('/about')
const { journeyId } = useLocalSearchParams()
router.push({
pathname: '/journeyedit',
params: { journeyId: journey.id }
})
const navigation = useNavigation()
useLayoutEffect(() => {
navigation.setOptions({
title: 'My screen',
})
}, [navigation])
https://reactnavigation.org/docs/en/getting-started.html
import { createStackNavigator, createAppContainer } from 'react-navigation'
const AppNavigator = createStackNavigator({
Screen1: {
screen: Screen1Screen
},
Screen2: {
screen: Screen2Screen
}
},
// Optional settings for all screens:
{
headerMode: 'none',
navigationOptions: {
headerVisible: false,
}
})
export default createAppContainer(AppNavigator)
Purpose: avoid having unique strings in navigators and navigate().
enum NavigationRoutes {
HomeScreen = 'HomeScreen',
UserProfileScreen = 'UserProfileScreen'
}
// navigate, push, replace. Also: dismiss, goBack, pop, popToTop, reset, setParams
this.props.navigation.navigate(NavigationRoutes.UserProfileScreen, { someParam: 'Test' })
https://reactnavigation.org/docs/header-buttons/
useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
title: 'Edit glam',
headerBackTitle: 'Back',
headerStyle: { backgroundColor: COLOR_BLACK },
headerTintColor: COLOR_WHITE,
headerShadowVisible: false,
headerLeft: null,
headerRight: () => (
<TextButton
onPress={handleSaveGlam}
title='Save'
isPrimary
/>
)
})
}, [navigation])
this.props.navigation.setParams({ myParam: 123 })
this.props.navigation.getParam('myParam', 'Default value')
this.props.navigation.state.params.myParam
Set state on another screen:
import { NavigationActions } from 'react-navigation'
const setParamsAction = NavigationActions.setParams({
key: navigation.getParam('fromKey'),
params: { myParam }
})
navigation.dispatch(setParamsAction)
https://reactnative.dev/docs/asyncstorage -> https://github.com/react-native-async-storage/async-storage
expo install @react-native-async-storage/async-storage
Code:
import AsyncStorage from '@react-native-async-storage/async-storage'
await AsyncStorage.setItem('@storage_Key', value)
const value = await AsyncStorage.getItem('@storage_Key') // null if missing
Save navigation state:
https://reactnavigation.org/docs/state-persistence/
Animated/LayoutAnimation
https://docs.expo.dev/ui-programming/using-svgs/
ImagecomponentimportSVG file fromassetsfolder using https://github.com/kristerkari/react-native-svg-transformer- Inline:
- Convert SVGs to React format using https://react-svgr.com/playground/?native=true
- Use
react-native-svg
Example 1: Image component
<Image
src='/images/logo.svg'
alt='Logo'
title='Logo'
width={size}
height={size}
/>
Example 2: SVG component
import Logo from './assets/logo.svg'
// Set fill="currentColor" in the SVG file
<Logo width={120} height={40} color='black' />
Example 3: Inline
import Svg, { Path } from 'react-native-svg'
export default function TriangleDown() {
return (
<View style={styles.container}>
<Svg
width={20}
height={20}
viewBox='0 0 20 20'
>
<Path d='M16.993 6.667H3.227l6.883 6.883 6.883-6.883z' fill='#000' />
</Svg>
</View>
)
}
https://docs.expo.dev/versions/latest/sdk/font/
import * as Font from 'expo-font'
function App() {
const [fontsAreLoaded] = useFonts({
Montserrat: require('./assets/fonts/Montserrat.ttf'),
})
if (!fontsAreLoaded) return null
return <Text style={{ fontFamily: 'Montserrat' }} />
}
import { Platform } from 'react-native'
if (Platform.OS !== 'web') return null
expo.Audio
import { Audio } from 'expo'
const clickSound = new Audio.Sound()
await clickSound.loadAsync(require('../assets/sounds/click.mp3'))
await clickSound.replayAsync()
npx expo install expo-haptics
import * as Haptics from 'expo-haptics'
Haptics.selectionAsync()
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) // Warning, Error
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) // Light, Medium, Heavy
- https://docs.expo.dev/push-notifications/overview/
- https://docs.expo.dev/push-notifications/faq/
- Old? https://blog.logrocket.com/create-send-push-notifications-react-native/
Test tool: https://expo.dev/notifications
Notifications.getPermissionsAsync→ Checks the current notification permission status (e.g. "granted", "denied", "undetermined").Notifications.requestPermissionsAsync→ Asks the user for permission to send notifications (shows the system prompt if not asked before).Notifications.getExpoPushTokenAsync→ Returns a unique Expo push token for that device/app, which your backend uses to send push notifications.Notifications.scheduleNotificationAsync→ Schedules a local notification (created and shown by the app itself, no server needed).Notifications.setNotificationHandler→ Defines how your app should handle notifications when received, e.g. show alert, play sound, or update badge.Notifications.setNotificationChannelAsync→ (Android only) Creates or configures a notification channel (controls sound, vibration, importance).
- Install EAS:
yarn global add eas-cli && eas login - Create
eas.json - Create a new app on https://appstoreconnect.apple.com/apps – note the bundle ID (just characters, avoid underscore/dash, e.g.
com.mydomain.myappname) - Update
app.jsonto avoid “Missing Compliance” status in TestFlight:"config": { "usesNonExemptEncryption": false } - Set up and build with
eas build(oreas build -p ios) - Submit with
eas submit(oreas submit -p ios)
https://docs.expo.dev/build/eas-json/
{
"cli": {
"version": ">= 0.53.0",
"requireCommit": true
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./appstores/googleplay/pc-api-667.json",
"track": "internal"
},
"ios": {
"appleId": "[my appleid login-email]",
"ascAppId": "[NUMERIC ID FROM appstoreconnect.apple.com]"
}
}
}
}
- Create your app on https://appstoreconnect.apple.com/
- Enter the same app Bundle ID (also SKU) in
app.json - Build with
expo build:ios(option:--clear-provisioning-profile) - Upload IPA file with Application Loader on macOS (might need app-specific password on https://appleid.apple.com/)
- App Store Connect takes ~1 hour to process a new build, then you can use TestFlight for testing.
https://developer.apple.com/account/resources/certificates/list
- https://docs.expo.dev/versions/latest/sdk/apple-authentication/
- https://supabase.io/docs/guides/auth/auth-apple
Steps:
- App ID
- https://developer.apple.com/account/resources/identifiers/list
com.mydomain.myappname
- Service ID
- https://developer.apple.com/account/resources/identifiers/list (select "Service IDs" in top-right dropdown)
com.mydomain.myappname.login- Domain: [APP-ID].supabase.co
- Return URLs: https://[APP-ID].supabase.co/auth/v1/callback
- Callback URL: https://[APP-ID].supabase.co/auth/v1/callback
- Key
- Docs: https://docs.expo.dev/submit/android/
- Set up your app on https://play.google.com/console/
app.json: you needexpo.android.versionCode(integer). Suggestion: version0.4.3->"versionCode": 1000004003- Set the Privacy Policy in Dashboard → Privacy Policy (old: Policy → App Content)
- First time: upload AAB file manually to Play Console
Note: You can use a shared service account for all your apps
- Create Service Account: https://expo.fyi/creating-google-service-account
- API Access: https://play.google.com/console/developers/[YOUR-ID]/api-access
- Set permissions and invite Service Account user
- Place JSON key in
mkdir -p appstores/googleplayfolder (optional:.gitignorethis file) - Update
eas.json:
"submit": {
"production": {
"android": {
"track": "internal",
"releaseStatus": "draft",
"serviceAccountKeyPath": "./appstores/googleplay/pc-api-9052926321037412225-601-6bf892805fe0.json"
}
}
}
There is no deobfuscation file associated with this App Bundle. If you use obfuscated code (R8/proguard), uploading a deobfuscation file will make crashes and ANRs easier to analyze and debug. Using R8/proguard can help reduce app size.
https://stackoverflow.com/questions/73193134/obfuscate-an-expo-file
https://github.com/marketplace/actions/expo-github-action
-
Verify you have set up
eas.json -
Create a
.github/workflows/production.yml(see below) -
Get a token on https://expo.dev/accounts/ACCOUNT/settings/access-tokens and add a secret
EXPO_TOKENto https://github.com/ACCOUNT/REPOSITORY/settings/secrets/actions -
Run EAS build non-interactive from command line the first time to set up accounts etc:
eas build --platform ios -
For
eas submit, you need 1) an ASC API key and 2) an Issuer ID from: https://appstoreconnect.apple.com/access/apimkdir -p .github/workflows; touch mkdir -p .github/workflows/production.yml
Example .github/workflows/production.yml:
# Commit to `production` branch → build with EAS
on:
push:
branches:
- production
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repository
uses: actions/checkout@v2
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 16.x
cache: yarn
- name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@7.2.0
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: 📦 Install dependencies
run: yarn install
- name: 🚀 Publish app to Expo
run: expo publish --non-interactive
- name: 🛠️ Build app
run: eas build --non-interactive --platform ios
- name: 🚚 Submit app to TestFlight
run: eas submit --latest --platform ios
Build Xcode project etc:
npx expo prebuild --platform ios
npx expo run:ios
Start server:
npx expo run:ios --device
https://reactnative.dev/docs/view-style-props
https://reactnative.dev/docs/layout-props
https://medium.com/wix-engineering/the-full-react-native-layout-cheat-sheet-a4147802405c
justifyContent: primary axisalignItems: cross (secondary) axisalignContent: bunches children together as if they were one elementalignSelf: overwrites parent’salignItemsproperty
flexDirection: column*/rowflexWrap: nowrap*/wrap
On children:
flex: 1
Styles:
flex, flexBasis, flexDirection, flexGrow, flexShrink, flexWrap
alignContent, justifyContent
alignItems: flex-start, flex-end, center, stretch, baseline
alignSelf: auto, flex-start, flex-end, center, stretch, baseline
aspectRatioon styles.flex: X/flex: Yfor proportions.
https://github.com/DaniAkash/react-native-responsive-dimensions
import { responsiveHeight, responsiveWidth, responsiveFontSize } from 'react-native-responsive-dimensions'
// in styles:
height: responsiveHeight(50), // 50% of screen height
import * as WebBrowser from 'expo-web-browser'
await WebBrowser.openBrowserAsync(url)
React Native provides two complementary animation systems:
Animatedfor granular and interactive control of specific valuesLayoutAnimationfor animated global layout transactions
Animated example:
// From: https://reactnative.dev/docs/animations
import React, { useRef, useEffect } from 'react'
import { Animated } from 'react-native'
interface AnimationFadeInProps {
duration?: number
children: React.ReactNode
style?: any
}
const AnimationFadeIn = ({ duration = 2500, children, style }: AnimationFadeInProps): React.ReactElement => {
const fadeAnim = useRef(new Animated.Value(0)).current // Initial value for opacity: 0
useEffect(() => {
Animated.timing(
fadeAnim,
{
toValue: 1,
duration
}
).start()
}, [fadeAnim])
return (
<Animated.View // Special animatable View
style={{
...style,
opacity: fadeAnim // Bind opacity to animated value
}}
>
{children}
</Animated.View>
)
}
export default AnimationFadeIn
https://github.com/react-native-maps/react-native-maps
- MapView
- showsUserLocation, followsUserLocation
- Marker
- Callout
- Polygon
- Polyline
- Circle
- Overlay
- Heatmap
- Geojson
- react-native-skia
- react-native-reanimated:
npx expo install react-native-reanimated - react-native-gesture-handler:
npx expo install react-native-gesture-handler
3D:
AR:
- https://arvrjourney.com/augmented-reality-with-react-native-15219f36e3f2
- https://github.com/expo/expo-three-ar
- https://github.com/pmndrs/react-xr → pmndrs/xr#156
- Docs: https://r3f.docs.pmnd.rs/ + https://github.com/pmndrs/react-three-fiber
- Objects from Three.js: https://threejs.org/docs/#api/en/geometries/BoxGeometry
- Helpers in Drei: https://github.com/pmndrs/drei
Install:
# Install Expo GL
npx expo install expo-gl
# Install Three.js and R3F
npm install three @react-three/fiber
Note: 'z' is: higher values closer to camera, lower values further away
<pointLight position={[x, y, z]} intensity={1.5} />
<boxGeometry args={[width, length, depth]} />
Rotation: Math.PI = 180°. Here we rotate 90°:
<mesh rotation={[0, 0, Math.PI * 0.5]} position={[0, 0, 0]}>
<planeGeometry args={[1.8, 3.2]} />
<meshStandardMaterial color='lightblue' />
</mesh>
Types:
import { Vector3, Euler, Color } from 'three'
useFrame for animations
useFrame(() => {
if (mesh.current !== null) {
mesh.current.rotation.x += 0.01
}
})
- iOS:
- Set up IAP products first in App Center, with Product ID e.g. “credits50”.
- Make sure to fill out all fields including testing fields, so the status is “Ready for Review”.
- Set up IAP products first in App Center, with Product ID e.g. “credits50”.
- Android: Monetize → Products
- Then import into “Products” in RevenueCat
- Then create Offering with a shared identifier e.g. “credits”
- Create new packages (e.g. “credits-50”) inside the Offering, and attach the two Products for iOS/Android
- “Entitlements” is used for subscriptions