The easiest way to start a React Native project is npx create-expo-app@latest: it creates an Expo app with TypeScript and file-based navigation, and npx expo start opens it in Expo Go on your phone, in an emulator or in the browser. A screen is built from the View, Text, Image, Pressable and ScrollView components, and styles are objects passed through StyleSheet — no CSS files, with Flexbox that lays elements out in a column by default.
After this you will be able to
- Create a project with
create-expo-app, reset the examples and explain whatsrc/app,app.jsonandpackage.jsonare for - Choose how to run the app — Expo Go, emulator, browser or development build — and explain their limitations
- Build a screen from
View,Text,Image,PressableandScrollViewand style it withStyleSheet, accounting for column-first Flexbox - Check the app without a phone:
tsc, a Jest test andnpx expo exportfor Android
Verified with
Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
- How React Native differs from React for the web
- Creating the project: create-expo-app and its structure
- Running: Expo Go, emulator and development build
- A service card screen: View, Text, Image and Pressable
- Styles: StyleSheet and column-first Flexbox
- Safe areas and different screens
- Checking without a phone, and exercises
How React Native differs from React for the web
If you know React, components, props, state and hooks stay the same. Everything tied to the browser changes: React Native has no DOM, no HTML tags and no CSS. The View and Text components become real Android and iOS UI elements, so they follow their own layout rules. We compared native development, React Native and Flutter in a separate article; this one is hands-on.
Container
- React in the browser
<div>- React Native
<View>
Text
- React in the browser
<p>,<span>or a bare string- React Native
- Only inside
<Text>: a string directly in aViewthrows an error
Image
- React in the browser
<img src>- React Native
<Image source>
Press
- React in the browser
onClickon any element- React Native
onPressonPressable
Styles
- React in the browser
- CSS files, classes, cascade
- React Native
- Objects in
style,StyleSheet.create, inheritance only inside nestedText
Units
- React in the browser
px,rem,%- React Native
- Unitless numbers — density-independent pixels; percentages as strings
Layout
- React in the browser
display: blockby default- React Native
- Flexbox everywhere,
flexDirection: 'column'by default
Navigation
- React in the browser
- Page URLs in the browser
- React Native
- A stack of screens; in Expo, Expo Router file-based routes
Creating the project: create-expo-app and its structure
The React Native team recommends starting new apps with a framework, and Expo is the main option. This lesson was tested on Expo SDK 57 — the current version at the time of writing: it includes React Native 0.86 and React 19.2, and the default template installs TypeScript 6.0. You need Node.js LTS; according to the compatibility table, SDK 57 requires Node.js 22.13 or later.
npx create-expo-app@latest service-card --yes
cd service-card
npm run reset-project # на вопрос о переносе примеров ответьте n
npx expo startThe default template is already a working app with tabs and examples. The reset-project script removes the examples and leaves a minimal skeleton: two files in src/app. From there we add our own code.
src/app/
- Purpose
- Screens. Every file here is an Expo Router route;
_layout.tsxdefines the navigator
src/components/, src/data/
- Purpose
- Our components and data: anything that is not a screen stays outside
src/app
assets/images/
- Purpose
- App icon, splash screen and images
app.json
- Purpose
- Expo configuration: name, icons, link scheme, plugins, experimental flags
package.json
- Purpose
- Dependencies; the
main: "expo-router/entry"field enables file-based navigation
tsconfig.json
- Purpose
- Extends
expo/tsconfig.base; the@/*alias points tosrc/*
Running: Expo Go, emulator and development build
npx expo start launches Metro — the JavaScript bundler for React Native — and shows a QR code. From there you have three paths, and they differ in what native code ends up in the app.
Expo Go on a phone
- How to open
- Install Expo Go and scan the QR code
- Limitations
- Only Expo SDK libraries and packages without their own native code; the docs call Expo Go a playground for learning
Emulator or simulator
- How to open
- Press
a(Android) ori(iOS Simulator) in the terminal - Limitations
- Requires Android Studio or Xcode; the iOS Simulator runs only on a Mac
Browser
- How to open
- Press
w - Limitations
- Runs through React Native for Web: handy for quick checks, but it is not a mobile platform
Development build
- How to open
npx expo run:androidlocally or a cloud build with EAS- Limitations
- Your own “version of Expo Go” with any native libraries; recommended for real projects
Expo Go is enough for this lesson: we use only React Native components and the react-native-safe-area-context package from the template. As soon as you need a library with its own native code or a native configuration change, move to a development build: from then on the app is rebuilt when you install native libraries, edit app.json or upgrade the SDK.
A service card screen: View, Text, Image and Pressable
The lesson’s practice is a salon service screen with a booking button. Start with the data: in a real app it would come from a server; here it is a typed object so the lesson stays about the UI.
export type Service = {
id: string;
title: string;
description: string;
durationMin: number;
includes: string[];
};
export const service: Service = {
id: 'haircut',
title: 'Haircut and styling',
description: 'Consultation, wash, haircut and styling with a senior stylist.',
durationMin: 60,
includes: ['Consultation', 'Wash', 'Haircut', 'Styling'],
};The card is a regular React component with props. View is a container, Text holds any text, Image shows a local image from require, and Pressable detects presses on its content. You can pass Pressable a style function: it receives { pressed }, so the button darkens while a finger is on it.
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import type { Service } from '@/data/service';
type Props = {
service: Service;
booked: boolean;
onBook: () => void;
};
export function ServiceCard({ service, booked, onBook }: Props) {
return (
<View style={styles.card}>
<Image source={require('../../assets/images/icon.png')} style={styles.cover} resizeMode="cover" />
<View style={styles.body}>
<Text style={styles.title}>{service.title}</Text>
<Text style={styles.meta}>{service.durationMin} min</Text>
<Text style={styles.description}>{service.description}</Text>
<View style={styles.tags}>
{service.includes.map((item) => (
<Text key={item} style={styles.tag}>
{item}
</Text>
))}
</View>
<Pressable
onPress={onBook}
disabled={booked}
accessibilityRole="button"
accessibilityState={{ disabled: booked }}
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed,
booked && styles.buttonDone,
]}>
<Text style={styles.buttonText}>{booked ? 'Request sent' : 'Book a visit'}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
borderRadius: 20,
borderWidth: 1,
borderColor: '#e4e4e7',
overflow: 'hidden',
},
cover: {
width: '100%',
height: 180,
},
body: {
padding: 20,
gap: 12,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#18181b',
},
meta: {
fontSize: 14,
color: '#71717a',
},
description: {
fontSize: 16,
lineHeight: 24,
color: '#3f3f46',
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
tag: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: '#f4f4f5',
fontSize: 13,
color: '#3f3f46',
},
button: {
marginTop: 8,
minHeight: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#18181b',
},
buttonPressed: {
opacity: 0.7,
},
buttonDone: {
backgroundColor: '#16a34a',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#ffffff',
},
});Style arrays
style={[styles.button, pressed && styles.buttonPressed]}— styles apply in order, the last one wins, andfalseis skipped. This replaces CSS modifier classes.A button for screen readers
accessibilityRole="button"tells VoiceOver and TalkBack it is a button, andaccessibilityStatethat it is unavailable after booking.Text only in Text
Even the
60 minduration is a separateText. A string directly inside aViewcauses an error.Images
For a local file use
source={require(...)}, for a remote one{ uri }with an explicit width and height. Expo also offers theexpo-imagepackage with caching and smooth image transitions.
Styles: StyleSheet and column-first Flexbox
Styles in React Native are JavaScript objects. Property names are camelCase (backgroundColor instead of background-color), numbers are density-independent pixels, so fontSize: 16 looks comparable on screens of different density. According to the docs, StyleSheet.create is an identity function: its main benefit is type checking and editor suggestions, and named styles are easier to read.
Flexbox in CSS
display: flexmust be enabledflex-direction: rowby defaultflex-shrink: 1by defaultalign-content: stretchby default- Cascade and style inheritance
Flexbox in React Native
- Flexbox works on every
View flexDirection: 'column'by defaultflexShrink: 0by defaultalignContent: 'flex-start'by default- Inheritance only in nested
Text
That is why the card’s elements stack vertically without a single layout line, while the row of tags needed flexDirection: 'row' and flexWrap: 'wrap'. Spacing between elements comes from gap — React Native supports it just like CSS.
Safe areas and different screens
Phones have notches, a status bar and a gesture bar. To keep content from going under them you use safe areas. The SafeAreaView component from React Native itself is deprecated and works only on iOS, so we take the component of the same name from react-native-safe-area-context — the template already has it as an Expo Router dependency.
import { useState } from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ServiceCard } from '@/components/service-card';
import { service } from '@/data/service';
export default function ServiceScreen() {
const [booked, setBooked] = useState(false);
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.heading}>Our services</Text>
<ServiceCard service={service} booked={booked} onBook={() => setBooked(true)} />
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#fafafa',
},
content: {
width: '100%',
maxWidth: 560,
alignSelf: 'center',
padding: 16,
gap: 16,
},
heading: {
fontSize: 32,
fontWeight: '800',
color: '#18181b',
},
});ScrollView scrolls the content if it does not fit, and contentContainerStyle styles the inner container. For tablets and the browser the width is capped with maxWidth: 560 and centred — on a phone the card fills the width, on a large screen it does not stretch into a strip.
The root layout hides the stack’s system header: this screen does not need it, and we cover navigation in the next lesson on Expo Router.
import { Stack } from 'expo-router';
export default function RootLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}ScrollView renders all of its children at once. For one card that does not matter, but for a long list the React Native docs recommend FlatList: it creates items as you scroll and frees memory from the ones that have left the screen.
Checking without a phone, and exercises
Running on a device is your step: open the app in Expo Go and press the booking button. But some checks need no phone, and they are worth running before every commit. Tests in Expo are written with Jest, the jest-expo preset and React Native Testing Library.
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev{
"scripts": {
"test": "jest"
},
"jest": {
"preset": "jest-expo"
}
}import { describe, expect, test } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';
import ServiceScreen from '@/app/index';
describe('<ServiceScreen />', () => {
test('shows the service and books it once', async () => {
await render(<ServiceScreen />);
expect(screen.getByText('Haircut and styling')).toBeTruthy();
expect(screen.getByText('60 min')).toBeTruthy();
const button = screen.getByRole('button', { name: 'Book a visit' });
await fireEvent.press(button);
expect(screen.getByText('Request sent')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Request sent' })).toBeDisabled();
});
});The test looks for the button the way a screen reader does: by the button role and its label. Remove accessibilityRole and the test fails — so it doubles as an accessibility check. Importing describe, test and expect from @jest/globals is needed because of TypeScript 6: without an explicit import or a types setting in tsconfig.json, type checking does not see Jest’s global functions.
$ npx tsc --noEmit
$ npm test
PASS __tests__/service-screen-test.tsx
<ServiceScreen />
√ shows the service and books it once (427 ms)
Tests: 1 passed, 1 total
$ npx expo export --platform android
Using src/app as the root directory for Expo Router.
React Compiler enabled
Starting Metro Bundler
› android bundles (1):
_expo/static/js/android/entry-943190dc34f449e6a0d00f14bf2ed486.hbc (2.7MB)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!npx expo export --platform android compiles the JavaScript bundle to Hermes bytecode — the same way as for a release — and fails on import errors that tsc does not catch. With --platform ios the iOS bundle builds on Windows too: Xcode is needed only for the native build.
- Text directly inside a
View— wrap it inText - Elements stacked in a column instead of a row — add
flexDirection: 'row' ScrollViewdoes not scroll — the parent has noflex: 1or bounded height- A package installed with
npm installcame in an incompatible version — reinstall it withnpx expo install - A library with native code does not work in Expo Go — you need a development build
Next step: navigation — several screens, tabs, moving to a service’s details with a parameter in the URL. That is the Expo Router lesson.
How this was verified4 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.