How to buildOctober 11, 20269 min read
React Native + ExpoStep 3 of 9

React Native and Expo: your first app

In short

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.

TutorialLevel: BeginnerReact Native & ExpoPractice: ~1.5–3 h

After this you will be able to

  • Create a project with create-expo-app, reset the examples and explain what src/app, app.json and package.json are 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, Pressable and ScrollView and style it with StyleSheet, accounting for column-first Flexbox
  • Check the app without a phone: tsc, a Jest test and npx expo export for 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
  1. 01How React Native differs from React for the web
  2. 02Creating the project: create-expo-app and its structure
  3. 03Running: Expo Go, emulator and development build
  4. 04A service card screen: View, Text, Image and Pressable
  5. 05Styles: StyleSheet and column-first Flexbox
  6. 06Safe areas and different screens
  7. 07Checking 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 a View throws an error

Image

React in the browser
<img src>
React Native
<Image source>

Press

React in the browser
onClick on any element
React Native
onPress on Pressable

Styles

React in the browser
CSS files, classes, cascade
React Native
Objects in style, StyleSheet.create, inheritance only inside nested Text

Units

React in the browser
px, rem, %
React Native
Unitless numbers — density-independent pixels; percentages as strings

Layout

React in the browser
display: block by 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
The same task in React for the web and in React Native

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.

Terminal
npx create-expo-app@latest service-card --yes
cd service-card
npm run reset-project   # на вопрос о переносе примеров ответьте n
npx expo start
Creating a project from the default template and resetting the examples; --yes accepts the defaults

The 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.tsx defines 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 to src/*
What the project contains after the reset

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) or i (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:android locally 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.

src/data/service.ts
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 service type and the screen’s data

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.

src/components/service-card.tsx
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',
  },
});
The service card: markup from core components and styles via StyleSheet
  1. Style arrays

    style={[styles.button, pressed && styles.buttonPressed]} — styles apply in order, the last one wins, and false is skipped. This replaces CSS modifier classes.

  2. A button for screen readers

    accessibilityRole="button" tells VoiceOver and TalkBack it is a button, and accessibilityState that it is unavailable after booking.

  3. Text only in Text

    Even the 60 min duration is a separate Text. A string directly inside a View causes an error.

  4. Images

    For a local file use source={require(...)}, for a remote one { uri } with an explicit width and height. Expo also offers the expo-image package 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: flex must be enabled
  • flex-direction: row by default
  • flex-shrink: 1 by default
  • align-content: stretch by default
  • Cascade and style inheritance

Flexbox in React Native

  • Flexbox works on every View
  • flexDirection: 'column' by default
  • flexShrink: 0 by default
  • alignContent: '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.

src/app/index.tsx
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',
  },
});
The screen: safe area, scrolling and booking state

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.

src/app/_layout.tsx
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.

Terminal
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Installing the test packages; in PowerShell the Expo docs write the flag as "--" --dev
package.json
{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "jest-expo"
  }
}
package.json fragment: the script and the Jest preset
__tests__/service-screen-test.tsx
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();
  });
});
Screen test: finds the button by role and name, presses it and checks the new state

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.

Terminal
$ 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!
Real output of the checks (Windows 11, Expo SDK 57); intermediate asset-list lines removed

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 in Text
  • Elements stacked in a column instead of a row — add flexDirection: 'row'
  • ScrollView does not scroll — the parent has no flex: 1 or bounded height
  • A package installed with npm install came in an incompatible version — reinstall it with npx 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.