Blog-App in React Native
Blog-App
~13 Min. Lesezeit Zuletzt aktualisiert am 8. August 2026
Die Blog-App ist der ABSCHLUSS dieser Referenz-Tutorial-Reihe und bündelt bewusst besonders viele bereits gelernte Themen in einem Projekt: Navigation mit Params (Kapitel "Werte zwischen Screens übergeben"), Axios-Datenabruf, Ladezustände, Pull-to-Refresh und ein Suchfeld.
1. Beschreibung
Zwei Screens über React Navigation: eine LISTE aller Blogbeiträge (von einer öffentlichen Test-API geladen) mit Suchfunktion, und ein DETAIL-Screen, der den vollständigen Beitrag über route.params erhält. Pull-to-Refresh lädt die Liste über die native RefreshControl-Komponente neu.
2. Kurzes Beispiel
<FlatList
data={beitraege}
refreshControl={
<RefreshControl refreshing={ladeVorgang} onRefresh={neuLaden} />
}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>3. Komplettes Projekt: Blog-Reader
npx create-expo-app blog-app
cd blog-app
npm install axios @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-contextimport axios from 'axios';
const blogClient = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
timeout: 8000,
});
export default blogClient;import { useState, useEffect, useCallback, useMemo } from 'react';
import {
View, Text, TextInput, FlatList, TouchableOpacity,
ActivityIndicator, RefreshControl, StyleSheet,
} from 'react-native';
import blogClient from '../api/blogClient';
function BeitragslisteScreen({ navigation }) {
const [beitraege, setBeitraege] = useState([]);
const [suche, setSuche] = useState('');
const [ladeVorgang, setLadeVorgang] = useState(true);
const [aktualisiert, setAktualisiert] = useState(false);
const [fehlermeldung, setFehlermeldung] = useState(null);
const laden = useCallback(async (istAktualisierung = false) => {
istAktualisierung ? setAktualisiert(true) : setLadeVorgang(true);
setFehlermeldung(null);
try {
const antwort = await blogClient.get('/posts', { params: { _limit: 20 } });
setBeitraege(antwort.data);
} catch (fehler) {
setFehlermeldung('Beiträge konnten nicht geladen werden.');
} finally {
setLadeVorgang(false);
setAktualisiert(false);
}
}, []);
useEffect(() => {
laden();
}, [laden]);
const gefiltert = useMemo(
() => beitraege.filter((b) => b.title.toLowerCase().includes(suche.toLowerCase())),
[beitraege, suche]
);
if (ladeVorgang) {
return (
<View style={styles.zentriert}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<View style={styles.container}>
<TextInput
style={styles.suchfeld}
placeholder="Beiträge durchsuchen…"
value={suche}
onChangeText={setSuche}
autoCapitalize="none"
/>
{fehlermeldung && <Text style={styles.fehler}>{fehlermeldung}</Text>}
<FlatList
data={gefiltert}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.liste}
refreshControl={
<RefreshControl refreshing={aktualisiert} onRefresh={() => laden(true)} />
}
ListEmptyComponent={
<Text style={styles.leer}>Keine Beiträge gefunden.</Text>
}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.karte}
onPress={() => navigation.navigate('Detail', item)}
>
<Text style={styles.beitragsTitel} numberOfLines={2}>{item.title}</Text>
<Text style={styles.beitragsVorschau} numberOfLines={2}>{item.body}</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
zentriert: { flex: 1, alignItems: 'center', justifyContent: 'center' },
container: { flex: 1, paddingTop: 16, paddingHorizontal: 16 },
suchfeld: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 10, marginBottom: 12 },
fehler: { color: '#dc2626', marginBottom: 12, textAlign: 'center' },
liste: { paddingBottom: 24 },
leer: { textAlign: 'center', marginTop: 40, color: '#9ca3af' },
karte: { backgroundColor: '#f3f4f6', padding: 14, borderRadius: 10, marginBottom: 10 },
beitragsTitel: { fontSize: 16, fontWeight: 'bold', textTransform: 'capitalize' },
beitragsVorschau: { color: '#6b7280', marginTop: 4 },
});
export default BeitragslisteScreen;import { ScrollView, Text, StyleSheet } from 'react-native';
function BeitragDetailScreen({ route }) {
const { title, body } = route.params;
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.titel}>{title}</Text>
<Text style={styles.text}>{body}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
titel: { fontSize: 22, fontWeight: 'bold', marginBottom: 16, textTransform: 'capitalize' },
text: { fontSize: 16, lineHeight: 24, color: '#374151' },
});
export default BeitragDetailScreen;import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import BeitragslisteScreen from './screens/BeitragslisteScreen';
import BeitragDetailScreen from './screens/BeitragDetailScreen';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Beiträge" component={BeitragslisteScreen} />
<Stack.Screen name="Detail" component={BeitragDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}4. Erklärung
laden(istAktualisierung)steuert bewusst ZWEI getrennte Ladezustände (ladeVorgangfür den ersten Vollbild-Ladekreis,aktualisiertfür den Pull-to-Refresh-Indikator) mit derselben Funktion – vermeidet Code-Duplikation, ohne die beiden UI-Zustände zu vermischen.RefreshControlalsrefreshControl-Prop derFlatList(nicht als separates Element im JSX!) ist die einzige korrekte Art, Pull-to-Refresh in einer scrollbaren Liste zu realisieren.gefiltertperuseMemo– SELBE Technik wie im Kapitel "Suchleiste", hier auf Netzwerk-Daten statt einer festen Liste angewendet, zeigt die Wiederverwendbarkeit des Musters.navigation.navigate('Detail', item)übergibt den KOMPLETTEN Beitrag als Params (Kapitel "Werte zwischen Screens übergeben") – der Detail-Screen braucht dadurch KEINEN eigenen Netzwerk-Aufruf, die Daten sind schon vollständig vorhanden.numberOfLines={{2}}in der Liste kürzt lange Texte optisch, WÄHREND der Detail-Screen denselbenbody-Wert ungekürzt in einerScrollViewvollständig zeigt.