Skip to Content
Folienvorschau
Dauer
10 Minuten
Ziel
Verstehen, wie React auf Zustandsänderungen reagiert

Themen

  • Hook useState
  • Neu rendern
  • Lifting State Up
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 10 Minuten ↗

State

Bisher zeigt die App nur feste Daten an. Jetzt soll die Oberfläche auf Eingaben reagieren: Ein Umschalter im Header wechselt die Temperaturanzeige zwischen °C und °F.

Code: schritt-05 · Änderungen 

Erster Versuch

Eine normale Variable reicht nicht: Antippen ändert den Wert, aber die Anzeige bleibt gleich.

export function WeatherScreen({ city, weather }: WeatherScreenProps) { let isCelsius = true; // funktioniert nicht! return ( <Pressable onPress={() => (isCelsius = !isCelsius)}> <Text>{isCelsius ? '°C' : '°F'}</Text> </Pressable> ); }

React weiß nicht, dass sich isCelsius geändert hat, und ruft die Funktion deshalb nicht erneut auf. Selbst wenn, stünde isCelsius danach wieder auf true, weil die Funktion von vorne ausgeführt wird.

Pressable macht seinen Inhalt antippbar; onPress bekommt eine Funktion, die beim Antippen aufgerufen wird.

Der Name isCelsius statt useCelsius wie in Android und Flutter ist Absicht: In React beginnen nur Hooks mit use (siehe unten). Für Werte vom Typ boolean ist is… üblich.

Hook useState

useState gibt den aktuellen Wert und eine Funktion zum Ändern zurück. React merkt sich den Wert über das erneute Aufrufen hinweg.

const [isCelsius, setIsCelsius] = useState(true); <Pressable onPress={() => setIsCelsius(!isCelsius)}> <Text>{isCelsius ? '°C' : '°F'}</Text> </Pressable>

useState kommt aus dem Paket react, nicht aus react-native: import { useState } from 'react';. Hooks gehören zu React selbst und funktionieren genauso im Web.

useState(true) legt den Startwert fest. Zurück kommt ein Array mit zwei Elementen, das wir gleich auf zwei Variablen aufteilen. Den Typ boolean leitet TypeScript aus dem Startwert ab.

setIsCelsius ändert den Wert und sagt React, dass die Komponente neu gerendert werden muss. Damit übernimmt useState die Rolle von remember { mutableStateOf(…) } in Compose bzw. von Feld und setState in Flutter, ohne eigene Klasse.

useState ist ein Hook: eine Funktion von React, deren Name mit use beginnt und die sich etwas über das Rendern hinweg merkt. Hooks dürfen nur ganz oben in einer Komponente aufgerufen werden, nicht in if, Schleifen oder Callbacks. React erkennt sie an der Reihenfolge der Aufrufe. Bei Verstößen meldet sich npx expo lint.

Neu rendern

Merksatz wie bei Compose und Flutter: UI = f(State). Ändert sich der State, ruft React die Komponente neu auf.

Das erneute Rendern lässt sich beobachten: in CurrentWeatherCard die Zeile console.log('render CurrentWeatherCard'); vor dem return einfügen. Die Ausgabe erscheint im Terminal von npx expo start. Jedes Umschalten erzeugt eine neue Zeile.

Wie Flutter rendert React beim Ändern von State den ganzen Teilbaum unterhalb der Komponente neu. Das ist billig: Gerendert wird nur eine Beschreibung, die nativen Views ändert React Native nur dort, wo sich wirklich etwas unterscheidet. Die Vorlage von Expo schaltet außerdem den React Compiler  ein ("reactCompiler": true in app.json), der unveränderte Teile automatisch überspringt.

Beim Drehen des Bildschirms bleibt der State erhalten; React Native erzeugt dabei nichts neu. In unserer App ist das Drehen allerdings abgeschaltet (siehe app.json).

Lifting State Up

Den State dort halten, wo alle ihn brauchen, und ihn nach unten weitergeben. Änderungen kommen als Callbacks zurück nach oben.

Unidirectional Data Flow: State nach unten, Events nach oben

Der Umschalter (UnitToggle) und die Karte (CurrentWeatherCard) brauchen beide isCelsius. Deshalb liegt der State in der gemeinsamen Eltern-Komponente WeatherScreen. React nennt das Muster Lifting State Up, wie Flutter; in Compose heißt es State Hoisting.

const [isCelsius, setIsCelsius] = useState(true); <UnitToggle isCelsius={isCelsius} // State ↓ onToggle={() => setIsCelsius(!isCelsius)} // Event ↑ /> <CurrentWeatherCard city={city} weather={weather} isCelsius={isCelsius} />

In ui/weather-screen.tsx steckt UnitToggle in der Option headerRight des Headers, die Karte in der ScrollView. Der Anfang der Datei mit den neuen Imports und WeatherScreen:

src/ui/weather-screen.tsx
import { Stack } from 'expo-router'; import { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; import { conditionEmoji, conditionLabel, formatTemperature, } from '@/ui/weather-condition-ui'; type WeatherScreenProps = { city: City; weather: CurrentWeather; }; export function WeatherScreen({ city, weather }: WeatherScreenProps) { const [isCelsius, setIsCelsius] = useState(true); return ( <ScrollView> <Stack.Screen options={{ title: city.name, headerRight: () => ( <UnitToggle isCelsius={isCelsius} // State ↓ onToggle={() => setIsCelsius(!isCelsius)} // Event ↑ /> ), }} /> <CurrentWeatherCard city={city} weather={weather} isCelsius={isCelsius} /> </ScrollView> ); }

headerRight erwartet eine Funktion, die den Inhalt für die rechte Seite des Headers zurückgibt. Weil WeatherScreen bei jeder Änderung neu gerendert wird, bekommt auch der Header den neuen Wert.

UnitToggle steht ebenfalls in ui/weather-screen.tsx. Es hat keinen eigenen State, bekommt den aktuellen Wert und meldet das Antippen über onToggle:

src/ui/weather-screen.tsx
type UnitToggleProps = { isCelsius: boolean; onToggle: () => void; }; function UnitToggle({ isCelsius, onToggle }: UnitToggleProps) { return ( <Pressable onPress={onToggle} hitSlop={12}> <Text style={styles.toggle}>{isCelsius ? '°C' : '°F'}</Text> </Pressable> ); }

() => void ist der Typ einer Funktion ohne Parameter und Rückgabewert, wie () -> Unit in Kotlin. hitSlop={12} vergrößert die antippbare Fläche um 12 dp in jede Richtung, ohne dass sich das Aussehen ändert.

In styles kommt dafür ein Eintrag hinzu:

src/ui/weather-screen.tsx
toggle: { fontSize: 18, color: '#1565C0', paddingHorizontal: 8 },

CurrentWeatherCard bekommt eine neue Prop isCelsius und zeigt die Temperatur mit formatTemperature an:

src/ui/weather-screen.tsx
type CurrentWeatherCardProps = { city: City; weather: CurrentWeather; isCelsius: boolean; }; function CurrentWeatherCard({ city, weather, isCelsius, }: CurrentWeatherCardProps) { return ( <View style={styles.card}> <Text style={styles.city}>{city.name}</Text> <Text style={styles.emoji}>{conditionEmoji[weather.condition]}</Text> <Text style={styles.temperature}> {formatTemperature(weather.temperature, isCelsius)} </Text> <View style={styles.row}> <Text>Wind: {Math.round(weather.windSpeed)} km/h</Text> <Text>{conditionLabel[weather.condition]}</Text> </View> </View> ); }

Code: src/ui/weather-screen.tsx (schritt-05)

src/ui/weather-screen.tsx
import { Stack } from 'expo-router'; import { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; import { conditionEmoji, conditionLabel, formatTemperature, } from '@/ui/weather-condition-ui'; type WeatherScreenProps = { city: City; weather: CurrentWeather; }; export function WeatherScreen({ city, weather }: WeatherScreenProps) { const [isCelsius, setIsCelsius] = useState(true); return ( <ScrollView> <Stack.Screen options={{ title: city.name, headerRight: () => ( <UnitToggle isCelsius={isCelsius} // State ↓ onToggle={() => setIsCelsius(!isCelsius)} // Event ↑ /> ), }} /> <CurrentWeatherCard city={city} weather={weather} isCelsius={isCelsius} /> </ScrollView> ); } type UnitToggleProps = { isCelsius: boolean; onToggle: () => void; }; function UnitToggle({ isCelsius, onToggle }: UnitToggleProps) { return ( <Pressable onPress={onToggle} hitSlop={12}> <Text style={styles.toggle}>{isCelsius ? '°C' : '°F'}</Text> </Pressable> ); } type CurrentWeatherCardProps = { city: City; weather: CurrentWeather; isCelsius: boolean; }; function CurrentWeatherCard({ city, weather, isCelsius, }: CurrentWeatherCardProps) { return ( <View style={styles.card}> <Text style={styles.city}>{city.name}</Text> <Text style={styles.emoji}>{conditionEmoji[weather.condition]}</Text> <Text style={styles.temperature}> {formatTemperature(weather.temperature, isCelsius)} </Text> <View style={styles.row}> <Text>Wind: {Math.round(weather.windSpeed)} km/h</Text> <Text>{conditionLabel[weather.condition]}</Text> </View> </View> ); } const styles = StyleSheet.create({ toggle: { fontSize: 18, color: '#1565C0', paddingHorizontal: 8 }, card: { margin: 16, // außen padding: 24, // innen borderRadius: 24, backgroundColor: '#BBDEFB', alignItems: 'center', }, city: { fontSize: 28 }, emoji: { fontSize: 72 }, temperature: { fontSize: 48 }, row: { flexDirection: 'row', justifyContent: 'space-between', alignSelf: 'stretch', marginTop: 16, }, });

Die Umrechnung steckt in einer Hilfsfunktion am Ende von ui/weather-condition-ui.ts:

src/ui/weather-condition-ui.ts
export function formatTemperature(celsius: number, isCelsius: boolean): string { return isCelsius ? `${Math.round(celsius)} °C` : `${Math.round((celsius * 9) / 5 + 32)} °F`; }

Benannte Parameter wie in Kotlin und Dart gibt es in TypeScript nicht. Weil die Variable beim Aufruf isCelsius heißt, ist trotzdem klar, was true bedeutet.

Übung: Zähler

In Expo Snack  einen Zähler bauen:

  • eine große Zahl, darunter Buttons „+1“, „+10“ und „Zurücksetzen“
  • Bonus: Button „−1“, der nicht unter 0 zählt
  • Bonus: die Buttons in eine eigene Komponente CounterButtons auslagern

Ein neuer Snack enthält schon eine App.tsx bzw. App.js mit Beispielcode; deren Inhalt wird ersetzt. Ein Button von React Native braucht die Props title und onPress.

Bei der zweiten Bonus-Aufgabe kommt Lifting State Up ins Spiel: Der State bleibt in App, CounterButtons bekommt nur Callbacks.

Lösung

App.tsx
import { useState } from 'react'; import { Button, StyleSheet, Text, View } from 'react-native'; export default function App() { const [count, setCount] = useState(0); return ( <View style={styles.container}> <Text style={styles.count}>{count}</Text> <CounterButtons onAdd={(amount) => setCount(Math.max(0, count + amount))} onReset={() => setCount(0)} /> </View> ); } type CounterButtonsProps = { onAdd: (amount: number) => void; onReset: () => void; }; function CounterButtons({ onAdd, onReset }: CounterButtonsProps) { return ( <View style={styles.buttons}> <Button title="−1" onPress={() => onAdd(-1)} /> <Button title="+1" onPress={() => onAdd(1)} /> <Button title="+10" onPress={() => onAdd(10)} /> <Button title="Zurücksetzen" onPress={onReset} /> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, count: { fontSize: 96 }, buttons: { flexDirection: 'row', gap: 8 }, });

Math.max(0, …) verhindert negative Werte. (amount: number) => void ist der Typ eines Callbacks mit einem Parameter, wie (Int) -> Unit in Kotlin. gap setzt einen Abstand zwischen den Kindern einer View.

Zusammenfassung

State in React: useState liefert Wert und Setter, der Setter löst neues Rendern aus, State so weit oben wie nötig, Events als Callbacks nach oben.

ComposeFlutterReact Native
remember { mutableStateOf(…) }Feld im StateuseState(…)
Zuweisung useCelsius = …setState(() => …)setIsCelsius(…)
Recompositionerneuter Aufruf von builderneutes Rendern
rememberSaveablebeim Drehen nicht nötigbeim Drehen nicht nötig
State HoistingLifting State UpLifting State Up
() -> UnitVoidCallback() => void

Für einen einzelnen Schalter reicht useState. In der nächsten Lektion wandert der Zustand des ganzen Screens in einen eigenen Hook, der die Rolle des ViewModels übernimmt.

Weiterführendes Material