Skip to Content
Mobile Apps5 - React NativeViewModel und Architektur
Folienvorschau
Dauer
15 Minuten
Ziel
Zustand und Daten sauber von der Oberfläche trennen

Themen

  • MVVM-Schichten mit einem Custom Hook
  • UiState als Union-Typ
  • Repository und async/await
  • Laden mit useEffect
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 15 Minuten ↗

ViewModel und Architektur

Bisher stecken die Daten direkt im Code der Oberfläche. Für echte Daten aus dem Netz braucht die App eine klare Aufteilung: Wer lädt die Daten, wer hält den Zustand, wer zeigt ihn an?

Android und Flutter empfehlen dafür in ihrer Dokumentation MVVM (Model, View, ViewModel). React und Expo schreiben keine Architektur vor. Verbreitet ist, die Logik eines Screens in einen eigenen Custom Hook zu legen. Dieser Hook übernimmt die Rolle des ViewModels. So bleibt die App mit der Android- und der Flutter-Version vergleichbar, ganz ohne zusätzliche Pakete.

MVVM-Schichten

Jede Schicht hat eine Aufgabe und kennt nur die Schicht darunter.

MVVM-Schichten der Wetter-App mit einem Custom Hook als ViewModel

  • UI (Komponenten): zeigt den Zustand an und meldet Events
  • ViewModel (Custom Hook useWeather): hält den Zustand des Screens und lädt die Daten
  • Repository: liefert Daten, egal ob aus Fake-Daten, Datenbank oder Netz

Der Hook liegt im Ordner ui/, neben dem Screen, zu dem er gehört.

Der Clou: Das Repository ist nur ein Typ. In Schritt 7 steckt dahinter eine Fake-Implementierung, in Schritt 9 die echte API. UI und Hook merken davon nichts.

UiState als Union-Typ

Der Screen ist immer in genau einem von drei Zuständen.

src/ui/weather-ui-state.ts
import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; export type WeatherUiState = | { status: 'loading' } | { status: 'success'; city: City; weather: CurrentWeather } | { status: 'error'; message: string };

Der Code oben ist die vollständige neue Datei ui/weather-ui-state.ts.

Das ist eine Discriminated Union: Jeder Zustand ist ein Objekt mit dem Feld status, und an dessen Wert erkennt TypeScript, welche weiteren Felder es gibt. Das entspricht sealed interface in Kotlin und sealed class in Dart, ohne eigene Klassen.

Nach case 'success': in einem switch weiß TypeScript, dass uiState.weather existiert; im Fall 'error' gibt es dagegen nur uiState.message. Das entspricht dem Smart Cast in Kotlin und dem Pattern Matching in Dart.

ViewModel als Custom Hook

Ein Custom Hook ist eine Funktion, deren Name mit use beginnt und die selbst Hooks aufruft. Er hält den Zustand und gibt ihn zurück.

src/ui/use-weather.ts
import { useState } from 'react'; import { fakeCities, fakeCurrentWeather } from '@/data/fake-data'; import type { WeatherUiState } from '@/ui/weather-ui-state'; export function useWeather(): WeatherUiState { const [uiState] = useState<WeatherUiState>({ status: 'success', city: fakeCities[0], weather: fakeCurrentWeather[fakeCities[0].name], }); return uiState; }

Der Code oben ist die vollständige neue Datei ui/use-weather.ts.

useState<WeatherUiState>(…) gibt den Typ ausdrücklich an. Sonst würde TypeScript nur den Typ des Startwerts ableiten, und der Zustand könnte später nie loading oder error werden.

Nach außen gibt der Hook nur den Zustand frei. Ändern kann ihn nur der Hook selbst; den Setter brauchen wir erst mit dem Repository.

AndroidFlutterReact Native
Klasse WeatherViewModelKlasse WeatherViewModelFunktion useWeather
StateFlow<WeatherUiState>Getter uiState + notifyListeners()Rückgabewert, useState
collectAsStateWithLifecycle()ListenableBuilderentfällt: neues Rendern
Lebenszyklus vom Systemdispose() von Handlebt mit der Komponente

Ein Hook ist keine eigene Instanz wie ein ViewModel. Jede Komponente, die useWeather() aufruft, bekommt ihren eigenen Zustand, und er verschwindet, wenn die Komponente verschwindet. Für unsere App mit einem Hook pro Screen passt das genau.

Stateful und stateless

Eine Komponente holt den Zustand aus dem Hook, eine zweite zeigt ihn nur an.

src/ui/weather-screen.tsx
export function WeatherScreen() { const uiState = useWeather(); const [isCelsius, setIsCelsius] = useState(true); return ( <WeatherContent uiState={uiState} isCelsius={isCelsius} onToggleUnit={() => setIsCelsius(!isCelsius)} /> ); }

Beide stehen in ui/weather-screen.tsx. Aus dem bisherigen Inhalt von WeatherScreen werden die neuen Komponenten WeatherContent und WeatherBody. WeatherScreen bekommt keine Daten mehr als Props; app/index.tsx setzt nur noch <WeatherScreen /> ein:

src/app/index.tsx
import { WeatherScreen } from '@/ui/weather-screen'; export default function Index() { return <WeatherScreen />; }

Der Anfang von ui/weather-screen.tsx mit den neuen Imports:

src/ui/weather-screen.tsx
import { Stack } from 'expo-router'; import { type ReactElement, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; import { useWeather } from '@/ui/use-weather'; import { conditionEmoji, conditionLabel, formatTemperature, } from '@/ui/weather-condition-ui'; import type { WeatherUiState } from '@/ui/weather-ui-state';

Danach folgt WeatherScreen von oben. WeatherContent setzt die Optionen des Headers und überlässt den Inhalt WeatherBody:

src/ui/weather-screen.tsx
type WeatherContentProps = { uiState: WeatherUiState; isCelsius: boolean; onToggleUnit: () => void; }; // Zeigt nur an, was es über Props bekommt function WeatherContent({ uiState, isCelsius, onToggleUnit, }: WeatherContentProps) { return ( <> <Stack.Screen options={{ title: 'Wetter', headerRight: () => ( <UnitToggle isCelsius={isCelsius} onToggle={onToggleUnit} /> ), }} /> <WeatherBody uiState={uiState} isCelsius={isCelsius} /> </> ); }

<>…</> ist ein Fragment: Es fasst mehrere Elemente zusammen, ohne selbst etwas anzuzeigen. Der Header zeigt vorerst den festen Titel „Wetter“; den Namen der Stadt bekommt er mit der Navigation.

WeatherBody entscheidet mit einem switch, was angezeigt wird:

src/ui/weather-screen.tsx
type WeatherBodyProps = { uiState: WeatherUiState; isCelsius: boolean; }; function WeatherBody({ uiState, isCelsius }: WeatherBodyProps): ReactElement { switch (uiState.status) { case 'loading': return ( <View style={styles.centered}> <ActivityIndicator size="large" /> </View> ); case 'success': return ( <ScrollView> <CurrentWeatherCard city={uiState.city} weather={uiState.weather} isCelsius={isCelsius} /> </ScrollView> ); case 'error': return ( <View style={styles.centered}> <Text>{uiState.message}</Text> </View> ); } }

Der Rückgabetyp ReactElement sorgt dafür, dass TypeScript alle Fälle verlangt: Fehlt ein case, liefert die Funktion in diesem Fall nichts, und TypeScript meldet „Function lacks ending return statement“. Kommt ein Zustand hinzu, findet der Compiler so jede Stelle, die ihn noch nicht behandelt. Ausprobieren: den Fall 'error' löschen und npx tsc --noEmit ausführen.

ActivityIndicator ist die Ladeanzeige der Plattform. In styles kommt centered hinzu, das Gegenstück zu Center in Flutter:

src/ui/weather-screen.tsx
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, },

UnitToggle und CurrentWeatherCard bleiben unverändert.

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

src/ui/weather-screen.tsx
import { Stack } from 'expo-router'; import { type ReactElement, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; import { useWeather } from '@/ui/use-weather'; import { conditionEmoji, conditionLabel, formatTemperature, } from '@/ui/weather-condition-ui'; import type { WeatherUiState } from '@/ui/weather-ui-state'; // Holt den Zustand aus dem Hook und hält den UI-State für °C/°F export function WeatherScreen() { const uiState = useWeather(); const [isCelsius, setIsCelsius] = useState(true); return ( <WeatherContent uiState={uiState} isCelsius={isCelsius} onToggleUnit={() => setIsCelsius(!isCelsius)} /> ); } type WeatherContentProps = { uiState: WeatherUiState; isCelsius: boolean; onToggleUnit: () => void; }; // Zeigt nur an, was es über Props bekommt function WeatherContent({ uiState, isCelsius, onToggleUnit, }: WeatherContentProps) { return ( <> <Stack.Screen options={{ title: 'Wetter', headerRight: () => ( <UnitToggle isCelsius={isCelsius} onToggle={onToggleUnit} /> ), }} /> <WeatherBody uiState={uiState} isCelsius={isCelsius} /> </> ); } type WeatherBodyProps = { uiState: WeatherUiState; isCelsius: boolean; }; function WeatherBody({ uiState, isCelsius }: WeatherBodyProps): ReactElement { switch (uiState.status) { case 'loading': return ( <View style={styles.centered}> <ActivityIndicator size="large" /> </View> ); case 'success': return ( <ScrollView> <CurrentWeatherCard city={uiState.city} weather={uiState.weather} isCelsius={isCelsius} /> </ScrollView> ); case 'error': return ( <View style={styles.centered}> <Text>{uiState.message}</Text> </View> ); } } 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({ centered: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, }, 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, }, });

Warum die Aufteilung? WeatherContent bekommt alles über Props und lässt sich deshalb mit jedem Zustand anzeigen, z. B. in einem Test, ohne Hook und ohne Daten.

Der Umschalter °C/°F bleibt als useState in WeatherScreen. Er ist reiner Anzeige-Zustand (UI-Element-State) und gehört nicht in den Hook.

Code: schritt-06 · Änderungen 

Repository

Das Repository liefert die Daten. Der Hook kennt nur den Typ.

src/data/weather-repository.ts
import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; export type WeatherRepository = { getCurrentWeather(city: City): Promise<CurrentWeather>; };
src/data/fake-weather-repository.ts
import { fakeCurrentWeather } from '@/data/fake-data'; import type { WeatherRepository } from '@/data/weather-repository'; export function createFakeWeatherRepository({ shouldFail = false, } = {}): WeatherRepository { return { async getCurrentWeather(city) { // wie ein langsamer Server await new Promise((resolve) => setTimeout(resolve, 1500)); if (shouldFail) { throw new Error('Keine Verbindung zum Server (simuliert)'); } const weather = fakeCurrentWeather[city.name]; if (!weather) { throw new Error(`Keine Wetterdaten für ${city.name}`); } return weather; }, }; }

Neue Dateien data/weather-repository.ts und data/fake-weather-repository.ts.

Ein interface mit Klassen wie in Kotlin wäre auch in TypeScript möglich. In React-Code sind aber Objekte und Funktionen üblicher: Der Typ WeatherRepository beschreibt ein Objekt mit der Methode getCurrentWeather, und jedes Objekt mit dieser Methode passt dazu. createFakeWeatherRepository ist eine Factory: eine Funktion, die so ein Objekt erzeugt.

{ shouldFail = false } = {} ist ein optionales Objekt mit benannten Einstellungen und Standardwert. Aufruf mit createFakeWeatherRepository() oder createFakeWeatherRepository({ shouldFail: true }).

Ein Promise<T> ist ein Wert, der später ankommt, wie Future<T> in Dart. Eine async-Funktion liefert immer ein Promise; mit await wartet sie darauf, ohne die Oberfläche zu blockieren. Das entspricht suspend in Kotlin. Mit setTimeout verhält sich das Fake-Repository wie ein langsamer Server: Der Ladezustand wird sichtbar.

Laden mit useEffect

useEffect führt Code aus, nachdem die Komponente angezeigt wurde. Damit lädt der Hook die Daten.

Die neue Fassung von ui/use-weather.ts:

src/ui/use-weather.ts
import { useEffect, useState } from 'react'; import { createFakeWeatherRepository } from '@/data/fake-weather-repository'; import type { WeatherRepository } from '@/data/weather-repository'; import type { City } from '@/model/city'; import type { WeatherUiState } from '@/ui/weather-ui-state'; const defaultRepository = createFakeWeatherRepository(); export function useWeather( city: City, repository: WeatherRepository = defaultRepository, ): WeatherUiState { const [uiState, setUiState] = useState<WeatherUiState>({ status: 'loading', }); useEffect(() => { // wird true, sobald die Antwort nicht mehr gebraucht wird let ignore = false; async function loadWeather() { try { const weather = await repository.getCurrentWeather(city); if (!ignore) setUiState({ status: 'success', city, weather }); } catch (error) { if (!ignore) setUiState({ status: 'error', message: String(error) }); } } loadWeather(); // Aufräumen: beim Verlassen des Screens oder vor dem nächsten Laden return () => { ignore = true; }; }, [city, repository]); return uiState; }

Der Startwert ist loading. Nach dem ersten Anzeigen startet der Effekt das Laden; kommt die Antwort, setzt er success oder error, und React rendert neu.

Das Array am Ende, [city, repository], sind die Abhängigkeiten: React führt den Effekt erneut aus, wenn sich einer dieser Werte ändert. npx expo lint prüft, dass dort alles steht, was der Effekt verwendet.

Die Funktion, die der Effekt zurückgibt, ist das Aufräumen. React ruft sie auf, bevor der Effekt erneut läuft und wenn die Komponente verschwindet. Weil sich ein Promise nicht abbrechen lässt, merkt sich der Effekt mit ignore, dass die Antwort nicht mehr gebraucht wird. So kann keine alte Antwort einen neueren Zustand überschreiben. In Android übernimmt das der viewModelScope, in Flutter das Feld _disposed.

String(error) macht aus dem Fehler einen Text, z. B. „Error: Keine Verbindung zum Server (simuliert)“. Der Typ von error ist unknown, weil in JavaScript alles geworfen werden kann, nicht nur Error.

Die Abhängigkeiten vergleicht React mit ===, bei Objekten also die Identität, nicht den Inhalt. Ein Objekt, das bei jedem Rendern neu entsteht, z. B. useWeather({ name: 'Lörrach', … }) oder useWeather(city, createFakeWeatherRepository()), wäre bei jedem Rendern „neu“. Der Effekt liefe dann immer wieder, lädt, setzt State, rendert neu und so weiter. Deshalb ist defaultRepository eine Konstante außerhalb des Hooks, und fakeCities[0] ist immer dasselbe Objekt.

Repository als Default-Parameter

Der Hook bekommt sein Repository als optionalen Parameter. Für die echte API ändert sich später nur eine Zeile.

src/ui/use-weather.ts
const defaultRepository = createFakeWeatherRepository(); export function useWeather( city: City, repository: WeatherRepository = defaultRepository, ): WeatherUiState {

Ein Default-Wert wie in Kotlin, hier ohne den Umweg über ?? wie in Dart. Das macht den Hook auch testbar, z. B. mit useWeather(city, failingRepository) und einem Repository mit shouldFail: true.

Zum Ausprobieren des Fehlerzustands genügt es, die Konstante zu ändern: createFakeWeatherRepository({ shouldFail: true }). Nach dem Speichern lädt Fast Refresh den Hook neu; weil sich repository geändert hat, läuft der Effekt erneut und zeigt den Fehler.

In größeren Apps übernimmt ein Context von React oder eine Bibliothek die Bereitstellung solcher Objekte. Für diese App reicht der Default-Parameter, und das Prinzip ist dasselbe: Der Hook ist nicht fest an eine Datenquelle gebunden.

WeatherScreen bekommt dafür die Prop city und gibt sie an den Hook weiter. Der Typ WeatherScreenProps kommt neu dazu, der Rest der Datei bleibt gleich:

src/ui/weather-screen.tsx
type WeatherScreenProps = { city: City; }; // Holt den Zustand aus dem Hook und hält den UI-State für °C/°F export function WeatherScreen({ city }: WeatherScreenProps) { const uiState = useWeather(city); const [isCelsius, setIsCelsius] = useState(true); return ( <WeatherContent uiState={uiState} isCelsius={isCelsius} onToggleUnit={() => setIsCelsius(!isCelsius)} /> ); }

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

src/ui/weather-screen.tsx
import { Stack } from 'expo-router'; import { type ReactElement, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import type { City } from '@/model/city'; import type { CurrentWeather } from '@/model/current-weather'; import { useWeather } from '@/ui/use-weather'; import { conditionEmoji, conditionLabel, formatTemperature, } from '@/ui/weather-condition-ui'; import type { WeatherUiState } from '@/ui/weather-ui-state'; type WeatherScreenProps = { city: City; }; // Holt den Zustand aus dem Hook und hält den UI-State für °C/°F export function WeatherScreen({ city }: WeatherScreenProps) { const uiState = useWeather(city); const [isCelsius, setIsCelsius] = useState(true); return ( <WeatherContent uiState={uiState} isCelsius={isCelsius} onToggleUnit={() => setIsCelsius(!isCelsius)} /> ); } type WeatherContentProps = { uiState: WeatherUiState; isCelsius: boolean; onToggleUnit: () => void; }; // Zeigt nur an, was es über Props bekommt function WeatherContent({ uiState, isCelsius, onToggleUnit, }: WeatherContentProps) { return ( <> <Stack.Screen options={{ title: 'Wetter', headerRight: () => ( <UnitToggle isCelsius={isCelsius} onToggle={onToggleUnit} /> ), }} /> <WeatherBody uiState={uiState} isCelsius={isCelsius} /> </> ); } type WeatherBodyProps = { uiState: WeatherUiState; isCelsius: boolean; }; function WeatherBody({ uiState, isCelsius }: WeatherBodyProps): ReactElement { switch (uiState.status) { case 'loading': return ( <View style={styles.centered}> <ActivityIndicator size="large" /> </View> ); case 'success': return ( <ScrollView> <CurrentWeatherCard city={uiState.city} weather={uiState.weather} isCelsius={isCelsius} /> </ScrollView> ); case 'error': return ( <View style={styles.centered}> <Text>{uiState.message}</Text> </View> ); } } 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({ centered: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16, }, 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, }, });

app/index.tsx übergibt vorerst Lörrach:

src/app/index.tsx
import { fakeCities } from '@/data/fake-data'; import { WeatherScreen } from '@/ui/weather-screen'; export default function Index() { return <WeatherScreen city={fakeCities[0]} />; }

Ab Schritt 8 übergibt die Navigation die angetippte Stadt.

Code: schritt-07 · Änderungen 

Laden mit useEffect ist der Weg mit Bordmitteln von React. In größeren Apps übernimmt das meist eine Bibliothek wie TanStack Query : Sie kümmert sich um Laden, Fehler, Caching und erneutes Laden. Der Hook schrumpft dann auf useQuery({ queryKey: ['weather', city.name], queryFn: () => repository.getCurrentWeather(city) }); Screen und Repository bleiben gleich. Für diese App verzichten wir darauf, um zu sehen, was dabei passiert.

Weiterführendes Material