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.

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 |
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:
Asset.fromModule(require("../../data/daily_plan.txt")).asset.downloadAsync(). The word “download” is misleading – no network is involved. It’s a local extraction, and it happens only once per asset.FileSystem.readAsStringAsync(asset.localUri). After step 2, asset.localUri is a real file:// path you can read normally.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
Asset.fromModule() to locate a bundled file. It takes a require() statement and returns an object that knows the asset’s location inside the bundle.downloadAsync() to extract the asset to a readable local path (local UI, e.g., file:///var/mobile/Containers/…)expo-file-system
readAsStringAsync() reads any file:// URI as text (UTF-8).writeAsStringAsync(), deleteAsync(), getInfoAsync() etc..txt filesMetro 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
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
src/utils/assetReader.tsThis 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:
// 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();
}
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",
},
});
# 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.