DEV Community

Cover image for ๐Ÿ‘จ๐Ÿผโ€๐Ÿ’ป Building a news app with React Native, Expo Router, and Tanstack Query ๐Ÿ“ฐ
David Asaolu
David Asaolu

Posted on • Updated on

๐Ÿ‘จ๐Ÿผโ€๐Ÿ’ป Building a news app with React Native, Expo Router, and Tanstack Query ๐Ÿ“ฐ

Recently, I wrote about my first React Native applicationโ€” a mobile calculator and told you to expect more tutorials from me.

Well, guess what? Here is another one:

Another One

A mobile news application that enables users to read and search for news easily within the application.
At the end of this tutorial, you'll learn how to use Expo Router, query data with Tanstack Query, open webpages within your application, and style your application using Tailwind CSS.


App Demo

To preview the application, download Expo Go app, and paste the links below into the app URL field.

Android: exp://u.expo.dev/update/3be31284-3908-42e5-ad3e-23bf4823278d

iOS: exp://u.expo.dev/update/c98748eb-96a2-4f36-b32d-394b935b3106

App Brief Overview


Project Setup and Installation with Expo

Expo saves us from the complex configurations required to create a native application with the React Native CLI, making it the easiest and fastest way to build and publish React Native apps.

Create an Expo project that uses Expo Router by running the code snippet below within your terminal.

npx create-expo-app@latest --template tabs@49
Enter fullscreen mode Exit fullscreen mode

Expo Router is an open-source file-based routing system that enables users to navigate between screens easily. It is similar to Next.js, where each file name represents its route name.

Start the development server to ensure the app is working as expected.

npx expo start
Enter fullscreen mode Exit fullscreen mode

Next, delete the components and constants folders, and clear out everything inside the app folder except for the _layout.tsx file.

Install the Expo Linear Gradient, React Native Snap Carousel, and React Native Webview packages.

Expo Linear Gradient enables us to add colour gradients to React Native elements, React Native Snap Carousel for creating interactive carousels, and React Native WebView for embedding web content within the mobile application.

npx expo install expo-linear-gradient react-native-webview react-native-snap-carousel @types/react-native-snap-carousel
Enter fullscreen mode Exit fullscreen mode

Fix any compatibility issues within the packages by running the command below.

npx expo install --fix
Enter fullscreen mode Exit fullscreen mode

PS: You may encounter this warning while using React Native Snap Carousel:
ViewPropTypes will be removed from React Native, along with all other PropTypes. We recommend that you migrate away from PropTypes and switch to a type system like TypeScript. If you need to continue using ViewPropTypes, migrate to the 'deprecated-react-native-prop-types' package.

To fix it, navigate into the node_modules/react-native-snap-carousel folder and change the ViewPropTypes import to be from the deprecated package below.

npx expo install deprecated-react-native-prop-types
Enter fullscreen mode Exit fullscreen mode

Navigating between screens with Expo Router

Within the app folder, there is a _layout.tsx file that describes the layout (either Screen / Tab) of the files within the folder and allows you to add some custom settings to each screen.

Consider you have a folder containing a _layout.tsx file, and you need all the screens to use the React Native Stack layout. You can achieve this using the code snippet below:

import { Stack } from "expo-router";

function RootLayoutNav() {
    return (
        <Stack screenOptions={{ headerShown: false }}>
            {/**-- add screens for specific settings --*/}
        </Stack>
    );
}
Enter fullscreen mode Exit fullscreen mode

To navigate between screens, you can use the Link component or useRouter hook provided by Expo Router.

import { Link, useRouter } from "expo-router";

export default function Page() {
    const router = useRouter();
    const handleClick = () => {
        console.log("Pressed");
        router.push("/screen");
    };
    return (
        <View>
            {/** -- using useRouter hook ---*/}
            <Pressable onPress={handleClick}>
                <View>
                    <Text>Hello World</Text>
                </View>
            </Pressable>

            {/** -- using Link component ---*/}
            <Link
                href={{
                    pathname: "/news",
                    params: {id: "1"},
                }}
                asChild
            >
                <Pressable>
                    <View>
                        <Text>Hello World</Text>
                    </View>
                </Pressable>
            </Link>
        </View>
    );
}
Enter fullscreen mode Exit fullscreen mode

Styling Expo applications with Tailwind CSS

Tailwind CSS is a CSS framework that enables us to create modern and stunning applications easily.

However, to style Expo applications using Tailwind CSS, you need to install NativeWind - a library that uses Tailwind CSS as its scripting language.

Run the code snippet below to install NativeWind and its dependencies.

yarn add nativewind@^4.0.1 react-native-reanimated
yarn add -D tailwindcss
Enter fullscreen mode Exit fullscreen mode

Run npx tailwindcss init to create a tailwind.config.js file. Update the file with the code snippet below.

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./app/**/*.{js,jsx,ts,tsx}"],
    presets: [require("nativewind/preset")],
    theme: {
        extend: {},
    },
    plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

Create a globals.css file within the root of your project and add the Tailwind directives below.

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Update the babel.config.js file as done below.

module.exports = function (api) {
    api.cache(true);
    return {
        presets: [
            ["babel-preset-expo", { jsxImportSource: "nativewind" }],
            "nativewind/babel",
        ],
        plugins: [
            // Required for expo-router
            "expo-router/babel",
            "react-native-reanimated/plugin",
        ],
    };
};
Enter fullscreen mode Exit fullscreen mode

Create a metro.config.js file within the root of your project and paste the code snippet below into the file.

// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
    // [Web-only]: Enables CSS support in Metro.
    isCSSEnabled: true,
});

module.exports = withNativeWind(config, { input: "./globals.css" });
Enter fullscreen mode Exit fullscreen mode

Finally, import the ./globals.css file into the app/_layout.tsx file to enable you to style your application with Tailwind CSS.

//๐Ÿ‘‰๐Ÿป Within ./app/_layout.tsx

import "../globals.css";
Enter fullscreen mode Exit fullscreen mode

Congratulations, you can start styling your application using Tailwind CSS. If you encounter any issues, you can visit the documentation for a complete how-to guide.


Setting up TanStack Query in React Native

TanStack Query is a data fetching and state management library that handles API requests effectively within your applications. It provides various features such as caching, auto-refetching, paginated queries, and many others.

Run the code snippet to install TanStack Query to your Expo application.

yarn add @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Wrap the entire screens of the application with the QueryClientProvider component via the app/_layout.tsx file.

import { QueryClientProvider, QueryClient } from "@tanstack/react-query";

function RootLayoutNav() {
    const queryClient = new QueryClient();

    return (
        <QueryClientProvider client={queryClient}>
            <Stack screenOptions={{ headerShown: false }}>
                <Stack.Screen name='index' options={{ title: "Home" }} />
            </Stack>
        </QueryClientProvider>
    );
}
Enter fullscreen mode Exit fullscreen mode

Now, you can query data using TanStack Query.


Building the application interface

In this section, I'll walk you through building the application screens and fetching news from the News API.

Create an account on the website and copy your API token into a .env.local file.

EXPO_PUBLIC_NEWS_API_KEY=<your_API_key>
Enter fullscreen mode Exit fullscreen mode

News API

Next, create a fetchNews.ts file within the assets folder and copy the code snippet below into the file.

//๐Ÿ‘‡๐Ÿป base URL
const apiBaseUrl = "https://newsapi.org/v2";

//๐Ÿ‘‡๐Ÿป breaking news endpoint
const breakingNewsUrl = `${apiBaseUrl}/top-headlines?country=ng&apiKey=${process
    .env.EXPO_PUBLIC_NEWS_API_KEY!}`;

//๐Ÿ‘‡๐Ÿป recommended news endpoint
const recommendedNewsUrl = `${apiBaseUrl}/top-headlines?country=ng&category=business&apiKey=${process
    .env.EXPO_PUBLIC_NEWS_API_KEY!}`;

//๐Ÿ‘‡๐Ÿป fetch by category  endpoint
const discoverNewsUrl = (discover: string) =>
    `${apiBaseUrl}/top-headlines?country=ng&category=${discover}&apiKey=${process
        .env.EXPO_PUBLIC_NEWS_API_KEY!}`;

//๐Ÿ‘‡๐Ÿป search news endpoint
const searchNewsUrl = (query: string) =>
    `${apiBaseUrl}/everything?q=${query}&apiKey=${process.env
        .EXPO_PUBLIC_NEWS_API_KEY!}`;

//๐Ÿ‘‡๐Ÿป API function call
const newsApiCall = async (endpoints: string) => {
    try {
        const response = await fetch(endpoints);
        const data = await response.json();
        return data;
    } catch (err) {
        console.error(err);
    }
};

//๐Ÿ‘‡๐Ÿป returns breaking news
export const fetchBreakingNews = async () => {
    return await newsApiCall(breakingNewsUrl);
};

//๐Ÿ‘‡๐Ÿป returns recommended news
export const fetchRecommendedNews = async () => {
    return await newsApiCall(recommendedNewsUrl);
};

//๐Ÿ‘‡๐Ÿป returns news based on a category
export const fetchDiscoverNews = async (discover: string) => {
    return await newsApiCall(discoverNewsUrl(discover));
};

//๐Ÿ‘‡๐Ÿป returns search query news
export const fetchSearchNews = async (query: string) => {
    const endpoint = searchNewsUrl(query);
    return await newsApiCall(endpoint);
};
Enter fullscreen mode Exit fullscreen mode

The code snippet above fetches the breaking news, recommended news, and discover news from the API endpoint. The search news endpoint enables us to retrieve news based on a given input (query string).

Finally, create a util.ts file within the assets folder and copy the code snippet below into the file. It contains variables and functions used within the application.

//๐Ÿ‘‡๐Ÿป converts the date data from the API to a readable format
export function convertToReadableDate(
    utcDateString: string | undefined
): string {
    if (utcDateString === undefined) return "";
    const utcDate = new Date(utcDateString);
    const options: Intl.DateTimeFormatOptions = {
        year: "numeric",
        month: "long",
        day: "numeric",
    };
    const readableDate: string = utcDate.toLocaleDateString("en-US", options);
    return readableDate;
}

//๐Ÿ‘‡๐Ÿป list of news categories
export const categories: Categories[] = [
    {
        id: "business",
        name: "Business",
        description: "Business news",
        image_url:
            "https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTMyMzYyNg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        id: "entertainment",
        name: "Entertainment",
        description: "Entertainment news",
        image_url:
            "https://images.unsplash.com/photo-1598743400863-0201c7e1445b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4NjIyMDI3Nw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        id: "general",
        name: "General",
        description: "General news",
        image_url:
            "https://images.unsplash.com/photo-1557992260-ec58e38d363c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTc1MTkwNg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        id: "health",
        name: "Health",
        description: "Health news",
        image_url:
            "https://images.unsplash.com/photo-1495638488670-437e54b3bab4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTc2MDI3Mw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        id: "science",
        name: "Science",
        description: "Science news",
        image_url:
            "https://images.unsplash.com/photo-1614935151651-0bea6508db6b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTM0MzA0OA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        id: "sports",
        name: "Sports",
        description: "Sports news",
        image_url:
            "https://images.unsplash.com/photo-1476480862126-209bfaa8edc8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTQ1MTE5NQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
];

//๐Ÿ‘‡๐Ÿป images array
const images: Image[] = [
    {
        url: "https://images.unsplash.com/photo-1579532536935-619928decd08?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTM3OTI3Ng&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://images.unsplash.com/photo-1482160549825-59d1b23cb208?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTQxNzk3Mg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://plus.unsplash.com/premium_photo-1664297878197-0f50d094db72?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjQ2Ng&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://images.unsplash.com/photo-1572375992501-4b0892d50c69?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjUxOQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://images.unsplash.com/photo-1503694978374-8a2fa686963a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTMyMTY5MA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://plus.unsplash.com/premium_photo-1682098211431-6fbbaac9be2c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjYwMQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
    {
        url: "https://images.uns.lengthplash.com/photo-1529243856184-fd5465488984?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4NjA3NzExNA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
    },
];
//๐Ÿ‘‡๐Ÿป returns a random image for news without an image header
export const generateRandomImage = () => {
    const index = Math.floor(Math.random() * images.length);
    return images[index].url;
};
//๐Ÿ‘‡๐Ÿป Required TypeScript interface
export interface Categories {
    id: string;
    name: string;
    description: string;
    image_url: string;
}
export interface Image {
    url: string;
}
Enter fullscreen mode Exit fullscreen mode

App Overview

The application is divided into six screens, including the entry point to the application. Therefore, create a (tabs) folder containing a home.tsx, discover.tsx, and search.tsx files.

cd apps
mkdir (tabs)
touch _layout.tsx home.tsx discover.tsx search.tsx
Enter fullscreen mode Exit fullscreen mode

The Home screen displays the breaking and recommended news, The Discover screen allows users to read news based on a particular category, and the Search screen enables users to search for news.

App Overview

Next, create a (stack) folder containing a _layout.tsx,[title].tsx, and news.tsx files.

cd apps
mkdir (stack)
touch _layout.tsx [title].tsx news.tsx
Enter fullscreen mode Exit fullscreen mode

The news.tsx file displays all the news based on a particular category, and the [title.tsx] file displays the content of a particular news.

Display two screens

PS: The brackets around the tabs and stack folder names enable us to navigate between screens using the route name instead of the relative path.
For instance when navigating to the news page, instead of /(tabs)/news, you can use /news from any point within the application.

Finally, add the stack and tab routes to the app/_layout.tsx file.

function RootLayoutNav() {
    const queryClient = new QueryClient();

    return (
        <QueryClientProvider client={queryClient}>
            <Stack screenOptions={{ headerShown: false }}>
                <Stack.Screen name='index' options={{ title: "Home" }} />
                <Stack.Screen name='(tabs)' />
                <Stack.Screen name='(stack)' />
            </Stack>
        </QueryClientProvider>
    );
}
Enter fullscreen mode Exit fullscreen mode

The Welcome Screen

The welcome screen displays the content of the index.tsx file, showing a brief overview of the application and a button that redirects users to the home screen.

Welcome Screen

Update the app/index.tsx file with the code snippet below:

import { ImageBackground, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { LinearGradient } from "expo-linear-gradient";

export default function TabOneScreen() {
    const router = useRouter();
    return (
        <ImageBackground
            source={require("../assets/images/background.jpg")}
            className='flex-1 items-center justify-center pb-10 bg-gradient-to-bl from-gray-200 to-gray-900'
        >
            <LinearGradient
                colors={["transparent", "rgba(0,0,0,0.9)"]}
                style={{
                    position: "absolute",
                    bottom: 0,
                    width: "100%",
                    height: "100%",
                }}
                start={{ x: 0.5, y: 0 }}
                end={{ x: 0.5, y: 1 }}
            />
            <View className='absolute bottom-14 flex flex-col items-center justify-center w-full  bg-gradient-to-t from-gray-900 px-4'>
                <Text
                    className='text-4xl text-white font text-center mb-4'
                    style={{ fontFamily: "Bold" }}
                >
                    Breaking Boundaries, Breaking News
                </Text>

                <Text
                    className='text-gray-300 text-center text-xl mb-6'
                    style={{ fontFamily: "Medium" }}
                >
                    Explore the world through our lens. Your passport to a connected and
                    informed world, right at your fingertips.
                </Text>
                <Pressable
                    onPress={() => router.push("/home")}
                    className='bg-stone-700 rounded-full p-4 w-full items-center justify-center shadow-lg'
                >
                    <Text
                        className='text-white text-2xl'
                        style={{ fontFamily: "Medium" }}
                    >
                        Get Started
                    </Text>
                </Pressable>
            </View>
            <StatusBar style='light' />
        </ImageBackground>
    );
}
Enter fullscreen mode Exit fullscreen mode

The Tab Screens

It contains the Home, Discover, and Search screens. Update the (tabs)/_layout.tsx file to display the tab icons and default settings for each screen within the Tab layout.

import { Tabs } from "expo-router";
import { FontAwesome5, MaterialIcons, FontAwesome } from "@expo/vector-icons";

export default function Page() {
    return (
        <Tabs
            screenOptions={{
                tabBarShowLabel: false,
                tabBarActiveBackgroundColor: "#fff",
                tabBarActiveTintColor: "#a16207",
                headerShown: false,
            }}
        >
            <Tabs.Screen
                name='home'
                options={{
                    title: "Home",
                    tabBarIcon: ({ color }) => (
                        <FontAwesome5 name='home' size={24} color={color} />
                    ),
                }}
            />
            <Tabs.Screen
                name='discover'
                options={{
                    title: "Discover",
                    tabBarIcon: ({ color }) => (
                        <MaterialIcons name='explore' size={24} color={color} />
                    ),
                }}
            />

            <Tabs.Screen
                name='search'
                options={{
                    title: "Search",
                    tabBarIcon: ({ color }) => (
                        <FontAwesome name='search' size={24} color={color} />
                    ),
                }}
            />
        </Tabs>
    );
}
Enter fullscreen mode Exit fullscreen mode

Tab layout

Import the functions from the fetchNews.ts file declared earlier, execute the functions using TanStack Query, and display the results within the Home screen.

import { FlatList, StatusBar } from "react-native";
import Carousel from "react-native-snap-carousel";
import { useQuery } from "@tanstack/react-query";
import {
    fetchBreakingNews,
    fetchRecommendedNews,
} from "../../assets/fetchNews";

export default function Page() {
    //๐Ÿ‘‡๐Ÿป fetch the breaking news
    const breakingNewsQuery = useQuery({
        queryKey: ["breakingNews"],
        queryFn: fetchBreakingNews,
    });
    //๐Ÿ‘‡๐Ÿป fetch the recommended news
    const recommendedNewsQuery = useQuery({
        queryKey: ["recommendedNews"],
        queryFn: fetchRecommendedNews,
    });
    return (
        <SafeAreaView className='flex-1'>
            <View>
                {breakingNewsQuery.data && (
                    <Carousel
                        data={breakingNewsQuery.data.articles}
                        renderItem={renderBreakingNewsItem}
                        firstItem={1}
                        inactiveSlideScale={0.86}
                        sliderWidth={width}
                        itemWidth={width * 0.8}
                        slideStyle={{ display: "flex", alignItems: "center" }}
                    />
                )}
            </View>

            <View>
                {recommendedNewsQuery.data && (
                    <FlatList
                        data={recommendedNewsQuery.data.articles}
                        renderItem={renderRecommendedNewsItem}
                        showsVerticalScrollIndicator={false}
                        keyExtractor={(item, index) => item.url}
                    />
                )}
            </View>

            <StatusBar style='dark' />
        </SafeAreaView>
    );
}
Enter fullscreen mode Exit fullscreen mode

Create the functions that render each result within the Carousel and the Flatlist.

//๐Ÿ‘‡๐Ÿป Renders the Breaking News UI (horizontal row)
const renderBreakingNewsItem = ({ item }: any) => {
    return (
        <Link
            href={{
                pathname: "/[title]",
                params: {
                    data: JSON.stringify([item.url, item.title]),
                },
            }}
            asChild
        >
            <Pressable>
                <View className='relative'>
                    <Image
                        source={{ uri: item.urlToImage || generateRandomImage() }}
                        style={{
                            width: width * 0.8,
                            height: height * 0.22,
                            borderRadius: 10,
                        }}
                        resizeMode='cover'
                        className='rounded-3xl'
                    />
                    <LinearGradient
                        colors={["transparent", "rgba(0,0,0,0.9)"]}
                        start={{ x: 0.5, y: 0 }}
                        end={{ x: 0, y: 1 }}
                        style={{
                            position: "absolute",
                            bottom: 0,
                            width: "100%",
                            height: "100%",
                            borderBottomLeftRadius: 24,
                            borderBottomRightRadius: 24,
                        }}
                    />

                    <View className='absolute bottom-0 left-4 right-0 justify-end h-[80%] px-4 pb-4'>
                        <Text
                            className='text-xl text-white mb-2'
                            style={{ fontFamily: "Bold" }}
                        >
                            {item.title.length > 48
                                ? item.title.slice(0, 47) + "..."
                                : item.title}
                        </Text>
                        <Text className=' text-stone-200' style={{ fontFamily: "Medium" }}>
                            {item.author}
                        </Text>
                    </View>
                </View>
            </Pressable>
        </Link>
    );
};

//๐Ÿ‘‡๐Ÿป Renders the Recommended News UI (vertical row)
const renderRecommendedNewsItem = ({ item }: any) => {
    return (
        <Link
            href={{
                pathname: "/[title]",
                params: {
                    data: JSON.stringify([item.url, item.title]),
                },
            }}
            asChild
        >
            <Pressable className='px-4 w-full'>
                <View className='flex flex-row items-center justify-between w-full mb-4 bg-white shadow-xl rounded-xl'>
                    <Image
                        source={{ uri: item.urlToImage || generateRandomImage() }}
                        style={{
                            width: width * 0.4,
                            height: width * 0.3,
                            borderRadius: 5,
                        }}
                        resizeMode='cover'
                        className='rounded-3xl mr-[1px]'
                    />

                    <View className='px-3 flex-1'>
                        <Text
                            style={{ fontFamily: "Medium" }}
                            className='text-stone-500 text-sm'
                        >
                            {item.author}
                        </Text>
                        <Text className='text-lg mb-[1px]' style={{ fontFamily: "Bold" }}>
                            {item.title.length > 48
                                ? item.title.slice(0, 47) + "..."
                                : item.title}
                        </Text>
                        <Text
                            style={{ fontFamily: "Medium" }}
                            className='text-stone-500 text-sm'
                        >
                            {convertToReadableDate(item.publishedAt)}
                        </Text>
                    </View>
                </View>
            </Pressable>
        </Link>
    );
};
Enter fullscreen mode Exit fullscreen mode

The discover.tsx file displays the news categories within the application and redirects users to the (stack)/news screens containing all the news under that category. Copy the code snippet below into the discover.tsx file to render the news categories:

import { Categories, categories } from "../../assets/util";

export default function Page() {
    return (
        <View className='rounded-2xl shadow-xl'>
            <FlatList
                data={categories}
                renderItem={renderItem}
                keyExtractor={(item, index) => item.id}
                numColumns={2}
                contentContainerStyle={{
                    justifyContent: "space-between",
                    alignItems: "center",
                    padding: 10,
                    width: "100%",
                }}
            />
        </View>
    );
}
Enter fullscreen mode Exit fullscreen mode

The renderItem function below represents the layout of each item rendered within the FlatList.

const renderItem = ({ item }: { item: Categories }) => {
    return (
        <Link
            href={{
                pathname: "/news",
                params: {
                    category: item.id,
                },
            }}
            asChild
        >
            <Pressable>
                <View className='relative m-[7px]'>
                    <Image
                        source={{ uri: item.image_url }}
                        style={{
                            width: width * 0.47,
                            height: width * 0.45,
                            borderRadius: 10,
                        }}
                        resizeMode='cover'
                        className='rounded-xl'
                    />
                    <LinearGradient
                        colors={["transparent", "rgba(0,0,0,0.9)"]}
                        start={{ x: 0.5, y: 0 }}
                        end={{ x: 0, y: 1 }}
                        style={{
                            position: "absolute",
                            bottom: 0,
                            width: "100%",
                            height: "100%",
                            borderBottomLeftRadius: 20,
                            borderBottomRightRadius: 20,
                        }}
                    />
                    <View className='absolute bottom-0 left-4 right-0 justify-end h-[80%] px-4 pb-4'>
                        <Text
                            className='text-xl text-white mb-2'
                            style={{ fontFamily: "Bold" }}
                        >
                            {item.name}
                        </Text>
                    </View>
                </View>
            </Pressable>
        </Link>
    );
};
Enter fullscreen mode Exit fullscreen mode

Update the search.tsx file to enable users enter an input to the search field and displays the results within a FlatList.

import {
    View,
    Text,
    Pressable,
    Image,
    Dimensions,
    FlatList,
} from "react-native";
import { Link } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { TextInput } from "react-native-gesture-handler";
import { FontAwesome, MaterialIcons } from "@expo/vector-icons";
import { useState } from "react";
import { fetchSearchNews } from "../../assets/fetchNews";
import {
    News,
    convertToReadableDate,
    generateRandomImage,
} from "../../assets/util";
const { width } = Dimensions.get("window");

export default function Page() {
    const [query, onChangeQuery] = useState<string>("");
    const [results, setResults] = useState<any[]>([]);
    const [resultsCount, setResultsCount] = useState<number>(0);

    const handleTextChange = (text: string) => {
        onChangeQuery(text);
        if (text.length > 2) {
            fetchSearchNews(text).then((res) => {
                setResults(res.articles);
                setResultsCount(res.totalResults);
            });
        }
    };

    return (
        <SafeAreaView>
            <View className='px-4 '>
                <Text
                    className='text-3xl text-stone-500 mb-3'
                    style={{ fontFamily: "Bold" }}
                >
                    Search
                </Text>
                <View className='flex flex-row items-center justify-between w-full rounded-2xl bg-gray-100 border-[1px] px-3 border-stone-300'>
                    <FontAwesome name='search' size={24} color='gray' className='mr-2' />
                    <TextInput
                        className='flex-1  
                rounded-xl px-4 py-4'
                        placeholder='Search for news'
                        style={{ fontFamily: "Medium" }}
                        value={query}
                        onChangeText={(text) => handleTextChange(text)}
                    />
                </View>
                <Text className='text-lg mt-4 mb-4' style={{ fontFamily: "Semibold" }}>
                    Total Results: {resultsCount}
                </Text>

                <View>
                    {results && (
                        <FlatList
                            data={results}
                            renderItem={newsItem}
                            showsVerticalScrollIndicator={false}
                            keyExtractor={(item) => item.url}
                        />
                    )}
                </View>
            </View>
        </SafeAreaView>
    );
}
Enter fullscreen mode Exit fullscreen mode

The results are rendered via a newItem component created as shown below.

export interface News {
    title: string;
    url: string;
    image?: string;
    publishedAt?: string;
    author?: string;
    urlToImage?: string;
}

const newsItem = ({ item }: { item: News }) => {
    return (
        <Link
            href={{
                pathname: "/[title]",
                params: {
                    url: item.url,
                    title: item.title,
                },
            }}
            asChild
        >
            <Pressable className='px-4 w-full'>
                <View className='flex flex-row items-center justify-between w-full mb-4 bg-white shadow-xl rounded-xl p-3'>
                    <Image
                        source={{ uri: item.urlToImage || generateRandomImage() }}
                        style={{ width: width * 0.2, height: width * 0.2, borderRadius: 5 }}
                        resizeMode='cover'
                        className='rounded-3xl mr-[1px]'
                    />

                    <View className='px-3 flex-1'>
                        <Text
                            style={{ fontFamily: "Medium" }}
                            className='text-stone-500 text-sm'
                        >
                            {item.author}
                        </Text>
                        <Text className='text-lg mb-[1px]' style={{ fontFamily: "Bold" }}>
                            {item.title.length > 48
                                ? item.title.slice(0, 47) + "..."
                                : item.title}
                        </Text>
                        <Text
                            style={{ fontFamily: "Medium" }}
                            className='text-stone-500 text-sm'
                        >
                            {convertToReadableDate(item.publishedAt)}
                        </Text>
                    </View>
                    <MaterialIcons name='keyboard-arrow-right' size={26} color='brown' />
                </View>
            </Pressable>
        </Link>
    );
};
Enter fullscreen mode Exit fullscreen mode

Search Screen


The Stack Screens

Update the (stack)/_layout.tsx file to display its files using the Stack layout.

import { Stack } from "expo-router";

export default function Page() {
    return <Stack></Stack>;
}
Enter fullscreen mode Exit fullscreen mode

Modify the news.tsx file to display the list of news based on a chosen category. When a user selects a news category, the user is redirected to the news route where the API results (category news) are displayed.

export default function Page() {
    const { category }: {category: string} = useLocalSearchParams();

    if (category === "breaking") {
        const breakingNewsQuery = useQuery({
            queryKey: ["breakingNews"],
            queryFn: fetchBreakingNews,
        });
        return <DisplayNews news={breakingNewsQuery} title='Breaking News' />;

    } else if (category === "recommended") {
        const recommendedNewsQuery = useQuery({
            queryKey: ["recommendedNews"],
            queryFn: fetchRecommendedNews,
        });
        return <DisplayNews news={recommendedNewsQuery} title='Recommended News' />;

    } else {
        const discoverNewsQuery = useQuery({
            queryKey: ["discoverNews", category],
            queryFn: () => fetchDiscoverNews(category),
        });
        return (
            <DisplayNews
                news={discoverNewsQuery}
                title={`${category[0].toUpperCase() + category.slice(1)} News`}
            />
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The DisplayNews component above displays the results in a FlatList.

DiplayNews


How to open web resources with React Native WebView

React Native WebView enables us to embed web content, such as HTML, CSS, and JavaScript, within a mobile app. It provides a way to display web pages in a React Native application.

Copy the code snippet below into the [title].tsx file:

import {
    View,
    Pressable,
    ActivityIndicator,
    Dimensions,
    Text,
} from "react-native";
import React, { useState } from "react";
import { AntDesign } from "@expo/vector-icons";
import { WebView } from "react-native-webview";
import { Stack, router, useLocalSearchParams } from "expo-router";
const { width, height } = Dimensions.get("window");

export default function Page() {
    const [visible, setVisible] = useState<boolean>(false);
    const params = useLocalSearchParams();
    const data: [string, string] = JSON.parse(params.data)
    const pageTitle = data[1];
    const pageURL = data[0]

    return (
        <>
            <Stack.Screen options={{ headerTitle: `${pageTitle}` }} />
            <View className='pt-4 p-6 flex flex-row justify-between items-center bg-stone-200 fixed top-0'>
                <Pressable
                    className='bg-stone-100 rounded-xl p-3 shadow-2xl'
                    onPress={() => router.back()}
                >
                    <AntDesign name='back' size={28} color='brown' />
                </Pressable>
                <Text style={{ fontFamily: "Medium" }}>Sponsored by Global Pulse </Text>
            </View>
            <WebView
                style={{ flex: 1 }}
                source={{ uri: pageURL }}
                onLoadStart={() => setVisible(true)}
                onLoadEnd={() => setVisible(false)}
            />
            {visible && (
                <ActivityIndicator
                    size={"large"}
                    color={"green"}
                    style={{
                        position: "absolute",
                        top: height / 2,
                        left: width / 2,
                    }}
                />
            )}
        </>

    );
}

Enter fullscreen mode Exit fullscreen mode
  • From the code snippet above,
    • the [title].tsx route accepts the news title and source URL from the Link component.
    • the WebView component from React Native WebView accepts the news source URL and displays the news content within the app.
    • the Stack.Screen component changes its header title to the news title.

News View

Congratulation!๐ŸŽ‰ You've completed the project for this tutorial.


Conclusion

So far, you've learnt how to do the following:

  • Create a mobile application with Expo,
  • Install and navigate between screens with Expo Router,
  • Style React Native applications with TailwindCSS, and
  • Open web pages within a mobile application.

If you prefer a video format, check out this tutorial on YouTube:

The source code for this tutorial is available here:
https://github.com/dha-stix/global-pulse-app

Thank you for reading!๐ŸŽ‰


Open to work๐Ÿ™‚

Did you enjoy this article or need an experienced Technical Writer / React Developer for a remote, full-time, or contract-based role? Feel free to contact me.
GitHub || LinkedIn || Twitter

Buy David a coffee
Thank You

Top comments (7)

Collapse
 
dheerajtp profile image
Info Comment hidden by post author - thread only accessible via permalink
Dheeraj

Why I'm getting this error for importing queryClient and queryClientProvider from @tanstack/react-query

Unable to resolve module '@tanstack/query-core.js' Evaluating @tanstack/query-core.js Evaluating @tanstack/react-query.js Evaluating App.js Loading App.js

Collapse
 
arshadayvid profile image
David Asaolu

You need to install '@tanstack/query-core.js'

Collapse
 
dheerajtp profile image
Info Comment hidden by post author - thread only accessible via permalink
Dheeraj

But you haven't installed that package right ?

When i try to install that package it's showing not found. And you haven't written anything about installation of that package you just installed @tanstack/react-query

 
arshadayvid profile image
David Asaolu

Start a new project and follow the step in the article.

Thread Thread
 
dheerajtp profile image
Dheeraj

๐Ÿ‘Ž

Collapse
 
pestodrizzle profile image
Dan • Edited

Great blog David! Are you building anything new with Expo these days?

Expo Router V3 is coming out soon. Curious to see how to build with it.

Collapse
 
arshadayvid profile image
David Asaolu

Thanks.
Yes, I am.
It should be ready before the month ends.

Some comments have been hidden by the post's author - find out more