Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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-context
api/blogClient.js
import axios from 'axios';

const blogClient = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 8000,
});

export default blogClient;
screens/BeitragslisteScreen.js
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;
screens/BeitragDetailScreen.js
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;
App.js
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 (ladeVorgang für den ersten Vollbild-Ladekreis, aktualisiert für den Pull-to-Refresh-Indikator) mit derselben Funktion – vermeidet Code-Duplikation, ohne die beiden UI-Zustände zu vermischen.
  • RefreshControl als refreshControl-Prop der FlatList (nicht als separates Element im JSX!) ist die einzige korrekte Art, Pull-to-Refresh in einer scrollbaren Liste zu realisieren.
  • gefiltert per useMemo – 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 denselben body-Wert ungekürzt in einer ScrollView vollständig zeigt.

5. Ausgaben

Ausgabe
Beim Start ein zentrierter Ladekreis, danach ein Suchfeld und eine Liste von 20 Beitragskarten (Titel fett, zweizeilige Vorschau darunter). Herunterziehen der Liste löst Pull-to-Refresh aus (Kreis oben, Liste lädt neu). Tippen in das Suchfeld filtert die Liste live. Antippen einer Karte öffnet den Detail-Screen mit vollständigem Titel und vollem Fließtext, scrollbar bei langen Beiträgen, automatischer Zurück-Pfeil oben links.