JustToThePoint English Website Version
JustToThePoint en español

React Native Data Files. Bundling and Reading Assets with Expo

Introduction

Every prayer companion needs its data: the daily Bible reading plan, the list of prayer videos, and the collection of online prayer sites.

In the Python app, these lived as plain text files inside ~/dotfiles/myArch/config/bible/. In React Native, they need to be bundled with the app so they’re available as soon as the user installs it – no network, no setup.

This article covers the first two steps of that migration: installing the right Expo packages and telling the Metro bundler to treat .txt files as assets. By the end you’ll have the foundation for reading any bundled file, which we’ll build upon in the next article.

Basic linear ODE

What you will learn

Concept: Bundled assets vs device files

React Native has two completely separate storage worlds. Knowing which one you’re in prevents a lot of frustration.

Bundled assets Device files
Where they live Inside the .apk / .ipa (compiled at build) In FileSystem.documentDirectory (created at runtime)
When they appear Installed with the app, always present Only after the app creates them
Read / Write Read‑only — the user cannot modify them Read and write
Equivalent in Python Files in a package’s data directory (like freebible/ JSON files) ~/dotfiles/myArch/config/bible/prayer_diary/
Use for Seed data, default verse plan, initial video / site lists Diary entries, user preferences, edited prayer plans

Why bundled assets need two steps to read

On iOS, bundled assets are packed in a binary format inside a proprietary .ipa archive. On Android, they live in the APK’s assets/ directory, which is not a real filesystem path.

Neither platform lets you read them with a plain file path. You cannot simply call fs.readFileSync(“data/daily_plan.txt”). Instead, you use Expo’s asset system:

  1. Locate the asset with Asset.fromModule(require("../../data/daily_plan.txt")).
  2. Extract it to a temporary, readable location with asset.downloadAsync(). The word “download” is misleading – no network is involved. It’s a local extraction, and it happens only once per asset.
  3. Read the extracted file with FileSystem.readAsStringAsync(asset.localUri). After step 2, asset.localUri is a real file:// path you can read normally.

Step 1 — Install the required packages

Open your terminal in the project root and run:

cd prayer-companion
# npx expo install is the safe way to add Expo libraries.
# It automatically installs the version that is compatible with your current Expo SDK,
# reducing the risk of version mismatches.
npx expo install expo-asset expo-file-system

What each package does:

expo-asset

expo-file-system

Step 2 — Configure Metro to bundle .txt files

Metro is React Native’s JavaScript bundler – it’s the tool that takes your app/, src/, and data/ files and combines them into one big JavaScript bundle that runs on the device.

By default, Metro only treats certain file extensions as assets – images (png, jpg), fonts (ttf), and media (mp4). However, plain text files (.txt) are not on that list. We extend Metro’s list of asset extensions to include .txt (and later .json for the offline KJV Bible).

Create a file called metro.config.js in the project root:

// metro.config.js
//
// Metro bundler configuration for Prayer Companion.
//
// WHAT IS METRO?
// Metro is the JavaScript bundler used by React Native.
// It takes all your source files and packages them into a single bundle
// that can run on a device.
//
// HOW THE FIX WORKS
// We add "txt" to Metro's assetExts list so Metro includes .txt files
// in the bundle and serves them via the asset server during development.

const { getDefaultConfig } = require("expo/metro-config");

const config = getDefaultConfig(__dirname);
// This line fetches the Expo‑recommended Metro config,
// which already includes settings for React Native and the Expo SDK.

// config.resolver.assetExts is the list of file extensions Metro bundles as assets.
// We spread the existing list and add our custom extensions.
config.resolver.assetExts = [
  ...config.resolver.assetExts, // spreads the existing extensions so we don’t lose the defaults
  "txt",   // plain text files (daily_plan.txt, prayer_videos.txt, etc.)
  "json",  // we will need this for kjv.json
];

module.exports = config;

Important: restart Metro with cache cleared. Metro caches aggressively to speed up development. After changing metro.config.js, you must restart the server with the ‐‐clear flag:

# Stop the current server (Ctrl+C), then restart with --clear to reset the cache
# The `--clear` flag wipes Metro's cache.
npx expo start --clear

Step 3 — Create the data files

These are exact copies of our existing files, moved inside the project.

data/daily_plan.txt

# daily_plan.txt — Bible reading plan for Prayer Companion
# One reference per line. Today's verse = day-of-year % total lines.
# Lines starting with # and blank lines are skipped.
# ─────────────────────────────────────────────────────────────────
# Genesis
Genesis 1:1
Genesis 1:26
Genesis 2:7
Genesis 3:15
Genesis 12:1
# Psalms
Psalm 1:1
Psalm 23:1
Psalm 46:1
Psalm 91:1
Psalm 119:105
# Proverbs
Proverbs 3:5
Proverbs 4:7
[...]

data/prayer_videos.txt

# prayer_videos.txt — YouTube video IDs for the Prayer Videos tab
# Format:  VIDEO_ID  Title of the video
# Get the ID from: youtube.com/watch?v=VIDEO_ID
# Example: for https://www.youtube.com/watch?v=Ac_nVJASVfc
#          the ID is Ac_nVJASVfc
#
Ac_nVJASVfc  Peaceful Morning Prayer Music - 1 Hour
eYdmcZUSt0s  10 Minute Morning Prayer with Scripture
3ixV9raPKMo  Gregorian Chant - Ave Maria
nQWFzMvCfAU  The Rosary - Joyful Mysteries
JkFbYoFJiSY  Evening Prayer - Taizé Community

data/prayeronline.txt

# prayeronline.txt — Online prayer sites for the Online Prayer tab
# Format:  url:name:description
# The colon (:) separates the three fields.
# Note: the URL contains :// so we split on the THIRD colon onwards.
#
https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer by the Irish Jesuits — 10 minutes of quiet reflection
https://pray-as-you-go.org:Pray As You Go:Daily audio prayer from Jesuit Media — music, scripture and reflection
https://www.universalis.com:Universalis:Full Liturgy of the Hours — Morning, Evening and Night Prayer
https://www.pray.com:Pray.com:Guided prayers, bedtime stories and daily devotionals
https://www.biblegateway.com:Bible Gateway:Full Bible in hundreds of translations with daily reading plans
https://www.jesuitprayer.org:Jesuit Prayer:Daily Examen and Ignatian prayer resources
https://www.catholic.org/prayers:Catholic Online:Large library of Catholic prayers for all occasions
https://www.loyolapress.com/catholic-resources/prayer:Loyola Press:Ignatian prayer resources and daily reflections

Step 4 — Write src/utils/assetReader.ts

This file will later be used by the verse service, video service, and online service to load data like daily_plan.txt, prayer_videos.txt, and prayeronline.txt.

In the Python app, reading a data file was a one‑liner:

with open("data/daily_plan.txt") as f:
    lines = f.readlines()

In React Native with Expo, it takes three steps:

  1. Locate the file inside the app bundle (via expo-asset).
  2. Extract it to a temporary local path (still called downloadAsync even though it’s local).
  3. Read the extracted file with expo-file-system.
// src/utils/assetReader.ts
//
// BUNDLED ASSET READER
// ────────────────────
// Reads text files bundled inside the app (in data/).
//
// WHY expo-file-system/legacy:
//  Expo’s file‑system library recently introduced a new class‑based API (File, Directory) while keeping the older
// function‑based API (readAsStringAsync, writeAsStringAsync) under a /legacy path. The legacy API is not deprecated; it’s a
// supported stable interface. We use /legacy to avoid a deprecation warning.

import { Asset } from "expo-asset";
import * as FileSystem from "expo-file-system/legacy";

// ── In-memory cache ──────────────────────────────────────────────────────────
const _cache = new Map<number, string>();
// Every call to downloadAsync() copies the asset from the bundle to device storage.
// It’s fast (local only), but it’s still unnecessary to do it multiple times for the same file.
// By keeping a Map keyed by the module ID (the number returned by require()),
// we can serve the content from memory after the first read.

/**
 * Read a bundled asset file and return its contents as a string.
 *
 * @param moduleId  The result of require("../../data/myfile.txt")
 * @returns         The file contents as a UTF-8 string
 */
// The function is async because it performs file I/O. It returns a Promise.
export async function readAsset(moduleId: number): Promise<string> {
  // 1. Check the cache. If we’ve already loaded this asset, return the cached string immediately.
  if (_cache.has(moduleId)) {
    return _cache.get(moduleId)!;
  }

  // 2. Locate the asset
  // This converts the module ID (a number) into an Expo Asset object.
  // How do you get that number? By calling require("../../data/myfile.txt")
  const asset = Asset.fromModule(moduleId);

  // 3. Extract the asset
  // Despite the name, no network is used. This copies the file from the app bundle
  // to a local temporary directory and sets asset.localUri (a file:// URI).
  await asset.downloadAsync();

  // 4. Error handling: After the download, we check asset.localUri.
  // If it’s null or undefined, something went wrong.
  // We throw a detailed error that explains the most common cause.
  if (!asset.localUri) {
    throw new Error(
      `Asset extraction failed for module ${moduleId}. ` +
      `Check that metro.config.js includes "txt" in assetExts and ` +
      `run npx expo start --clear.`
    );
  }

  // 5. Read the file: FileSystem.readAsStringAsync(asset.localUri)
  // This reads the entire file as a UTF‑8 string and returns it.
  const contents = await FileSystem.readAsStringAsync(asset.localUri);

  // 6. Cache and return: We store the content in _cache and return it.
  _cache.set(moduleId, contents);
  return contents;
}

/**
 * Parse raw text (cleaning up text) into an array of non-empty, non-comment lines.
 * Strips lines starting with # and blank lines.
 */
export function parseLines(text: string): string[] {
  return text
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .filter((line) => !line.startsWith("#"));
}
// Many of our data files will have comments starting with # (like the Python versions).
// This function turns raw file content into a clean array of meaningful lines:
// 1. split into individual lines.
// 2. trim each line to remove leading/trailing whitespace.
// 3. filter out empty lines (trimmed length 0).
// 4. filter out lines that start with # (comments).

/**
 * Read a bundled asset and return its parsed lines in one call.
 * Most services only need the parsed lines, not the raw file content.
 * By wrapping readAsset + parseLines, we reduce boilerplate in every service.
 */
export async function readLines(moduleId: number): Promise<string[]> {
  const text = await readAsset(moduleId);
  return parseLines(text);
}

/** Clear the in-memory cache (for testing and debugging).
 * A simple function that empties the cache.
 * This is useful in test suites where you want to ensure a fresh read.
*/
export function clearCache(): void {
  _cache.clear();
}

Step 5 — Add a test screen to the Verse tab

Replace the contents of app/(tabs)/verse.tsx with this temporary test screen.

// app/(tabs)/verse.tsx
//
// This screen reads each of the three data files and prints the first 5 lines.
// It confirms that:
//   1. metro.config.js correctly bundles .txt files
//   2. expo-asset correctly locates the bundled files
//   3. expo-file-system correctly reads the file contents
//   4. parseLines() correctly strips comments and blank lines
//


import React, { useEffect, useState } from "react";
import {
  View,
  Text,
  ScrollView,
  StyleSheet,
  ActivityIndicator,
} from "react-native";
import { readLines } from "../../src/utils/assetReader";

/**
 * ──────────────────────────────────────────────────────────────────────────────
 * FileResult
 * ──────────────────────────────────────────────────────────────────────────────
 * Represents the outcome of loading a single text asset.
 *
 *  name   – Display name of the file (e.g., "daily_plan.txt").
 *  lines  – The first 5 parsed lines of the file.
 *  total  – Total number of lines in the file.
 *  error  – Error message if loading failed, otherwise null.
 *
 * This type is intentionally local to this screen; it does not belong in
 * a shared types.ts because it is specific to this diagnostic UI.
 */
interface FileResult {
  name:   string;       // display name, e.g. "daily_plan.txt"
  lines:  string[];     // first 5 lines (after parsing)
  total:  number;       // total line count
  error:  string | null;
}

export default function VerseScreen() {
  /**
   * ────────────────────────────────────────────────────────────────────────────
   * Component State. useState gives the component persistent state across re-renders.
   * ────────────────────────────────────────────────────────────────────────────
   * We declare two pieces of state:
   * (1) results – Array of FileResult objects. Initially empty.
   * [] means "empty array" as the initial value.
   * (2) loading – A boolean that controls the loading spinner. Starts true until files are read.
   *
   * useState gives the component persistent state across re-renders.
   */
  const [results, setResults] = useState<FileResult[]>([]);
  const [loading, setLoading] = useState(true);

  // useEffect: runs after the component renders for the first time.
  // The empty [] dependency array means "Run this effect exactly once, after the first render."
  // — equivalent to Python's __init__ or a constructor.
  // Inside it, we call loadFiles() – an async function that reads the assets and updates state.
  useEffect(() => {
    loadFiles();
  }, []);

  async function loadFiles() {
    // We test all three files in parallel using Promise.allSettled().
    //
    // WHY Promise.allSettled() instead of Promise.all()?
    //   Promise.all() fails fast: if ONE file fails, the entire call rejects.
    //   Promise.allSettled() waits for ALL promises and returns each result
    //   individually — so we can show partial results if one file fails.
    //
    // WHY this pattern instead of three separate awaits?
    //   Three separate awaits would be sequential: file1, then file2, then file3.
    //   Promise.allSettled() loads all files concurrently instead of sequentially.

    // We prepare an array of objects, each holding a name and a moduleId.
    // require() is called with a static literal string.
    // This is crucial: Metro replaces these at build time with numeric IDs and packs the files.
    const fileSpecs = [
      {
        name:     "daily_plan.txt",
        // require() with a static string — Metro resolves this at build time.
        // You CANNOT use a variable here: require(someVariable) does NOT work.
        // Metro needs to know the exact path at build time to bundle the file.
        moduleId: require("../../data/daily_plan.txt"),
      },
      {
        name:     "prayer_videos.txt",
        moduleId: require("../../data/prayer_videos.txt"),
      },
      {
        name:     "prayeronline.txt",
        moduleId: require("../../data/prayeronline.txt"),
      },
    ];

    // Promise.allSettled runs all three readLines calls concurrently.
    // It returns an array where each element has a status ("fulfilled" or "rejected")
    // and either value (the lines) or reason (the error).
    const settled = await Promise.allSettled(
      fileSpecs.map(({ moduleId }) => readLines(moduleId))
    );

    /**
     * Build the final results array by pairing each settled outcome
     * with its corresponding file specification.
     * fileResults will be an array of FileResult objects. each element must have { name, lines, total, error }
     * fileSpecs looks like:
     * [
        { name: "daily_plan.txt", moduleId: ... },
        { name: "prayer_videos.txt", moduleId: ... },
        { name: "prayeronline.txt", moduleId: ... }
      ]
     */
    /*
     * .map() always passes two arguments:
     * spec → the current element of fileSpecs
     * index → its position (0, 1, 2)
    */
    const fileResults: FileResult[] = fileSpecs.map((spec, index) => {
      // We use index to match each file with its corresponding entry in:
      const outcome = settled[index];

      if (outcome.status === "fulfilled") {
        // Success: outcome.value is the string[] of lines
        return {
          name:  spec.name,
          lines: outcome.value.slice(0, 5), // we grab the first 5 lines with outcome.value.slice(0, 5).
          total: outcome.value.length,
          error: null,
        };
      } else {
        // Failure: outcome.reason is the Error object, store it as a string
        return {
          name:  spec.name,
          lines: [],
          total: 0,
          error: String(outcome.reason),
        };
      }
    });

    //Finally, we call setResults and setLoading(false),
    // causing React to re‑render the component with the new data.
    setResults(fileResults);
    setLoading(false);
  }

  // ── Render ─────────────────────────────────────────────────────────────────
  if (loading) {
    // When loading is true, we show a completely different UI tree, a centered gold spinner and a message.
    return (
      // This is a container that: fills the screen, centers its children (because styles.center uses flexbox)
      <View style={styles.center}>
        {/*
          ActivityIndicator is React Native's built‑in loading spinner.
          size="large" makes it bigger.
          color sets the spinner colour.
        */}
        <ActivityIndicator size="large" color="#c9a84c" />
        <Text style={styles.loadingText}>Reading data files…</Text>
      </View>
    );
  }

  // Once loading becomes false, React drops the previous whole subtree and renders the ScrollView instead.
  return (
    // Displaying results with cards
    // The main view is a ScrollView – a container or View that scrolls when content overflows.
    // Two style props: (i) style → styles the ScrollView itself (background, padding, etc.)
    // (ii) contentContainerStyle applies styles to the inner content container.
    <ScrollView
      style={styles.scroll}
      contentContainerStyle={styles.content}
    >
      {/* Heading + Subtitle */}
      <Text style={styles.heading}>Data Files Test</Text>
      <Text style={styles.subtitle}>
        Verifying that bundled .txt files can be read at runtime.
      </Text>

      {/* Render one card per file. results is an array of FileResult objects. So results looks like this:
        * [
        *  { name: "daily_plan.txt", lines: [...], total: 123, error: null },
        *  { name: "prayer_videos.txt", lines: [...], total: 87,  error: null },
        *  { name: "prayeronline.txt", lines: [...], total: 0,   error: "ENOENT: file not found" }
        * ]
       * .map() transforms each result into a View card. Each card must have a unique key → result.name.
       */}
      {results.map((result) => (
        <View key={result.name} style={styles.card}>

          {/* Card header: filename + status */}
          <View style={styles.cardHeader}>
            <Text style={styles.filename}>{result.name}</Text>
            {result.error ? (
              <Text style={styles.badgeError}>❌ FAIL</Text>
            ) : (
              <Text style={styles.badgeOk}>✅ {result.total} lines</Text>
            )}
          </View>

          {/* Error message (if loading failed) */}
          {result.error && (
            <Text style={styles.errorText}>{result.error}</Text>
          )}

          {/* First 5 lines (if loading succeeded). result.lines contains the first 5 lines.
          * .map() renders each line as a Text element. i + 1 converts 0‑based index → human‑friendly numbering.
          */}
          {result.lines.map((line, i) => (
            <Text key={i} style={styles.line}>
              {/*
                String template literal: ${i + 1} inserts the line number.
                i is 0-based, so +1 makes it 1-based.
              */}
              {`${i + 1}. ${line}`}
            </Text>
          ))}

          {/* Show "..." if there are more lines (than 5) than we are displaying */}
          {result.total > 5 && (
            <Text style={styles.more}>
              {`… and ${result.total - 5} more lines`}
            </Text>
          )}
        </View>
      ))}

      {/* Summary */}
      <View style={styles.summary}>
        {/* Array.prototype.every() tests whether all elements satisfy a condition.
          * For each result object r, we check: !r.error, “Does this file have no error?”
          * results.every((r) => !r.error) “Return true only if every file has error === null.”
         */}
        <Text style={styles.summaryText}>
          {results.every((r) => !r.error)
            ? "✅ All data files loaded successfully!."
            : "❌ Some files failed to load. Check metro.config.js and run npx expo start --clear."}
        </Text>
      </View>
    </ScrollView>
  );
}

// ── Styles ─────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
  center: {
    flex: 1,
    backgroundColor: "#1a1f2e",
    alignItems: "center",
    justifyContent: "center",
    gap: 16,   // space between ActivityIndicator and text
  },
  loadingText: {
    color: "#9ca3af",
    fontSize: 14,
  },
  scroll: {
    flex: 1,
    backgroundColor: "#1a1f2e",
  },
  content: {
    padding: 20,
    paddingBottom: 40,
  },
  heading: {
    fontSize: 20,
    fontWeight: "bold",
    color: "#c9a84c",
    marginBottom: 4,
  },
  subtitle: {
    fontSize: 13,
    color: "#6b7280",
    marginBottom: 24,
  },
  card: {
    backgroundColor: "#222840",
    borderRadius: 10,
    padding: 16,
    marginBottom: 16,
    borderWidth: 1,
    borderColor: "#2d3550",
  },
  cardHeader: {
    flexDirection: "row",       // side by side (like QHBoxLayout)
    justifyContent: "space-between",
    alignItems: "center",
    marginBottom: 10,
  },
  filename: {
    fontSize: 15,
    fontWeight: "bold",
    color: "#dde4f0",
    fontFamily: "monospace",    // monospace makes filenames stand out
  },
  badgeOk: {
    fontSize: 12,
    color: "#4ade80",           // green for success
    fontWeight: "600",
  },
  badgeError: {
    fontSize: 12,
    color: "#f87171",           // red for failure
    fontWeight: "600",
  },
  errorText: {
    fontSize: 12,
    color: "#f87171",
    fontFamily: "monospace",
    marginBottom: 8,
  },
  line: {
    fontSize: 13,
    color: "#9ca3af",
    fontFamily: "monospace",
    lineHeight: 22,
  },
  more: {
    fontSize: 12,
    color: "#4b5563",
    marginTop: 4,
    fontStyle: "italic",
  },
  summary: {
    backgroundColor: "#1e2d1e",
    borderRadius: 8,
    padding: 16,
    marginTop: 8,
    borderWidth: 1,
    borderColor: "#2d4a2d",
  },
  summaryText: {
    fontSize: 13,
    color: "#86efac",
    lineHeight: 20,
    textAlign: "center",
  },
});

Step 6 — Run and test

# IMPORTANT: always use --clear when you change metro.config.js
npx expo start --clear
# Press 'a' to open on the Android emulator

Navigate to the Verse tab. You should see a loading spinner briefly, then three cards showing the first 5 lines of each file.

Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.