Core Components, State and Project 1: Tip Calculator

React Native with Expo: 10 Real Projects · Core Skills · 7 min read · Updated

State drives the screen

  1. The user types a bill or taps a tip button.
  2. The handler calls a state setter with the new value.
  3. React runs the component function again with the new state.
  4. The new JSX is compared with the old one, and only the changes are applied to the screen.

A React Native screen is built from components. A few built-in ones cover most needs, and you combine them. This lesson teaches them and the idea of state, then builds your first complete app.

The core components

  • View: a box that groups and positions other things.
  • Text: all visible text. Text cannot sit directly inside a View.
  • TextInput: a box to type in. Use value and onChangeText. keyboardType picks the keyboard.
  • Pressable: anything that can be tapped. It replaces the old TouchableOpacity for most uses.
  • Image: shows a picture from a file or a web address. Remote images need a width and a height.
  • ScrollView and FlatList: ScrollView shows everything at once. FlatList draws only the rows on screen, so use it for long lists.
  • Switch, ActivityIndicator, Modal: a toggle, a spinner and a pop-up panel.
  • SafeAreaView: keeps content away from notches and system bars.

Styling and Flexbox

Styles are JavaScript objects, written with StyleSheet.create. Layout uses Flexbox, with two differences from the web: the default direction is vertical (column), and sizes are numbers without units. flexDirection: "row" puts children side by side. justifyContent places them along the main direction, alignItems across it, flex: 1 makes an item take the available space, and gap adds space between children.

State: data that changes the screen

useState gives a component a value that can change. When you call the setter, React runs the component again with the new value and updates only what changed.

const [count, setCount] = useState(0);
<Pressable onPress={() => setCount(count + 1)}>
  <Text>Clicked {count} times</Text>
</Pressable>

Never change state directly (count++). Always call the setter with a new value. When the new value depends on the old one, use the function form setCount((c) => c + 1).

Project 1: Tip Calculator

The user types a bill, chooses a tip, sets how many people share and sees the totals. All the maths lives in a plain function in its own file, so you can test it without a phone.

No extra libraries are needed for this project.

my-app/
  App.tsx        the screen
  tip.ts         the calculation, with no React in it
  __tests__/
    tip.test.ts
    TipScreen.test.tsx

`tip.ts`

export type TipResult = { tip: number; total: number; perPerson: number };

export function calculateTip(bill: number, tipPercent: number, people: number, roundUp: boolean): TipResult {
  if (bill < 0) throw new Error("bill must not be negative");
  if (people < 1) throw new Error("at least one person");
  let tip = (bill * tipPercent) / 100;
  if (roundUp) tip = Math.ceil(tip);
  const total = bill + tip;
  return { tip, total, perPerson: total / people };
}

export const money = (n: number) => n.toFixed(2);

`App.tsx`

import { useState } from "react";
import { SafeAreaView, View, Text, TextInput, Switch, Pressable, StyleSheet } from "react-native";
import { calculateTip, money } from "./tip";

export default function App() {
  const [billText, setBillText] = useState("");
  const [tipPercent, setTipPercent] = useState(15);
  const [people, setPeople] = useState(1);
  const [roundUp, setRoundUp] = useState(false);

  const bill = parseFloat(billText) || 0;
  const result = calculateTip(bill, tipPercent, people, roundUp);

  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.title}>Tip Calculator</Text>

      <Text style={styles.label}>Bill amount</Text>
      <TextInput
        style={styles.input}
        value={billText}
        onChangeText={setBillText}
        keyboardType="decimal-pad"
        placeholder="0.00"
      />

      <Text style={styles.label}>Tip: {tipPercent}%</Text>
      <View style={styles.row}>
        {[10, 15, 20, 25].map((p) => (
          <Pressable
            key={p}
            onPress={() => setTipPercent(p)}
            style={[styles.chip, p === tipPercent && styles.chipOn]}
          >
            <Text style={p === tipPercent ? styles.chipTextOn : styles.chipText}>{p}%</Text>
          </Pressable>
        ))}
      </View>

      <View style={styles.row}>
        <Text style={[styles.label, styles.grow]}>People: {people}</Text>
        <Pressable style={styles.round} onPress={() => setPeople(Math.max(1, people - 1))}>
          <Text style={styles.roundText}>-</Text>
        </Pressable>
        <Pressable style={styles.round} onPress={() => setPeople(people + 1)}>
          <Text style={styles.roundText}>+</Text>
        </Pressable>
      </View>

      <View style={styles.row}>
        <Text style={[styles.label, styles.grow]}>Round tip up</Text>
        <Switch value={roundUp} onValueChange={setRoundUp} />
      </View>

      <View style={styles.card}>
        <Text style={styles.line}>Tip: {money(result.tip)}</Text>
        <Text style={styles.line}>Total: {money(result.total)}</Text>
        <Text style={styles.big}>Each person pays {money(result.perPerson)}</Text>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
  title: { fontSize: 28, fontWeight: "700", marginBottom: 16 },
  label: { fontSize: 16, marginTop: 12, marginBottom: 6 },
  input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, fontSize: 18, borderWidth: 1, borderColor: "#d0d4dc" },
  row: { flexDirection: "row", alignItems: "center", gap: 8 },
  grow: { flex: 1 },
  chip: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 20, backgroundColor: "#e3e6ee" },
  chipOn: { backgroundColor: "#3b5bdb" },
  chipText: { color: "#222" },
  chipTextOn: { color: "#fff", fontWeight: "600" },
  round: { width: 36, height: 36, borderRadius: 18, backgroundColor: "#3b5bdb", alignItems: "center", justifyContent: "center" },
  roundText: { color: "#fff", fontSize: 20 },
  card: { marginTop: 24, padding: 16, borderRadius: 12, backgroundColor: "#fff" },
  line: { fontSize: 16, marginBottom: 4 },
  big: { fontSize: 20, fontWeight: "700", marginTop: 8 },
});

How it works

  • Four values are in state: the bill text, tip percent, number of people and the round-up switch.
  • The bill stays as text because the user types it. parseFloat(billText) || 0 turns empty or invalid text into 0.
  • The result is calculated on every render, so it can never be out of date.
  • The array [10, 15, 20, 25].map(...) creates one button for each tip choice. Each needs a unique key.

Expected output

The screen shows the title, a bill box, four tip buttons with 15% highlighted, a people row showing 1, a round-up switch and a card. Type 100: the card shows Tip 15.00, Total 115.00 and Each person pays 115.00. Press + twice: each person pays 38.33. With a bill of 33 and 15%, the tip is 4.95, and 5.00 when round up is on.

Your task

  1. Create the project, add the two files and run the app.
  2. Check the three examples.
  3. Add a custom tip box where the user types their own percent.
  4. Run the tests in the testing lesson later and add one for your custom tip.

How to check it worked

  • 100 at 15% gives a total of 115.00.
  • The numbers change as you tap, with no calculate button.
  • You can explain what useState does and why we never change state directly.

Frequently asked questions

Why do list items need a key?

The key tells React which item is which when the list changes, so it can update the right rows and keep their state. Use a stable ID, not the array index, when items can move.

Why can't I put text directly inside a View?

On phones, text is drawn by a special native component. React Native requires all text to be inside Text.

View or Pressable?

Use View to lay things out and Pressable when the user must tap it. Pressable adds press events and pressed-state styling.