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

Blog App in React Native

Blog App

~13 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026

The blog app is the CAPSTONE of this reference tutorial series and deliberately bundles especially many previously learned topics into one project: navigation with params (the "Passing Values Between Screens" chapter), Axios data fetching, loading states, pull-to-refresh, and a search field.

1. Description

Two screens via React Navigation: a LIST of all blog posts (loaded from a public test API) with search, and a DETAIL screen that receives the full post via route.params. Pull-to-refresh reloads the list via the native RefreshControl component.

2. Short example

<FlatList
  data={posts}
  refreshControl={
    <RefreshControl refreshing={loading} onRefresh={reload} />
  }
  renderItem={({ item }) => <Text>{item.title}</Text>}
/>

3. Complete project: a 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/PostListScreen.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 PostListScreen({ navigation }) {
  const [posts, setPosts] = useState([]);
  const [query, setQuery] = useState('');
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [errorMessage, setErrorMessage] = useState(null);

  const load = useCallback(async (isRefresh = false) => {
    isRefresh ? setRefreshing(true) : setLoading(true);
    setErrorMessage(null);
    try {
      const response = await blogClient.get('/posts', { params: { _limit: 20 } });
      setPosts(response.data);
    } catch (error) {
      setErrorMessage('Failed to load posts.');
    } finally {
      setLoading(false);
      setRefreshing(false);
    }
  }, []);

  useEffect(() => {
    load();
  }, [load]);

  const filtered = useMemo(
    () => posts.filter((p) => p.title.toLowerCase().includes(query.toLowerCase())),
    [posts, query]
  );

  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.searchField}
        placeholder="Search posts…"
        value={query}
        onChangeText={setQuery}
        autoCapitalize="none"
      />

      {errorMessage && <Text style={styles.error}>{errorMessage}</Text>}

      <FlatList
        data={filtered}
        keyExtractor={(item) => String(item.id)}
        contentContainerStyle={styles.list}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={() => load(true)} />
        }
        ListEmptyComponent={
          <Text style={styles.empty}>No posts found.</Text>
        }
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.card}
            onPress={() => navigation.navigate('Detail', item)}
          >
            <Text style={styles.postTitle} numberOfLines={2}>{item.title}</Text>
            <Text style={styles.postPreview} numberOfLines={2}>{item.body}</Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  centered: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  container: { flex: 1, paddingTop: 16, paddingHorizontal: 16 },
  searchField: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 10, marginBottom: 12 },
  error: { color: '#dc2626', marginBottom: 12, textAlign: 'center' },
  list: { paddingBottom: 24 },
  empty: { textAlign: 'center', marginTop: 40, color: '#9ca3af' },
  card: { backgroundColor: '#f3f4f6', padding: 14, borderRadius: 10, marginBottom: 10 },
  postTitle: { fontSize: 16, fontWeight: 'bold', textTransform: 'capitalize' },
  postPreview: { color: '#6b7280', marginTop: 4 },
});

export default PostListScreen;
screens/PostDetailScreen.js
import { ScrollView, Text, StyleSheet } from 'react-native';

function PostDetailScreen({ route }) {
  const { title, body } = route.params;

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.text}>{body}</Text>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20 },
  title: { fontSize: 22, fontWeight: 'bold', marginBottom: 16, textTransform: 'capitalize' },
  text: { fontSize: 16, lineHeight: 24, color: '#374151' },
});

export default PostDetailScreen;
App.js
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PostListScreen from './screens/PostListScreen';
import PostDetailScreen from './screens/PostDetailScreen';

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Posts" component={PostListScreen} />
        <Stack.Screen name="Detail" component={PostDetailScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

4. Explanation

  • load(isRefresh) deliberately drives TWO separate loading states (loading for the initial full-screen spinner, refreshing for the pull-to-refresh indicator) from the same function – avoids code duplication without mixing up the two UI states.
  • RefreshControl as the refreshControl prop of FlatList (NOT as a separate element in the JSX!) is the only correct way to implement pull-to-refresh in a scrollable list.
  • filtered via useMemo – the SAME technique as in the "Search Bar" chapter, applied here to network data instead of a fixed list, showing the pattern's reusability.
  • navigation.navigate('Detail', item) passes the ENTIRE post as params (the "Passing Values Between Screens" chapter) – the detail screen therefore needs NO network call of its own, the data is already fully available.
  • numberOfLines={{2}} in the list visually truncates long text, WHILE the detail screen shows the same body value in full, untruncated, inside a ScrollView.

5. Outputs

Ausgabe
On start, a centered loading spinner, then a search field and a list of 20 post cards (bold title, two-line preview below it). Pulling the list down triggers pull-to-refresh (spinner at the top, list reloads). Typing in the search field filters the list live. Tapping a card opens the detail screen with the full title and the complete body text, scrollable for long posts, automatic back arrow at the top left.