
If you’re going through hell, keep on going, Winston Churchill.
Sometimes people don’t want to hear the truth because they don’t want their illusions destroyed, Friedrich Nietzsche.

This guide teaches you how to build a robust React hook that delivers a Bible verse instantly from local data, then silently upgrades it when a network source responds. Along the way you’ll learn about cleanup functions, race conditions, mutable refs, and testing offline behaviour without touching your app.
src/hooks/useVerse.ts – a custom hook that manages all verse data for a Verse tab:
In the app’s Verse tab, enable airplane mode and tap “Refresh”. The verse still appears, sourced from KJV (offline). No network errors, no blank screen, no spinner.
useEffect cleanupWhen a component unmounts (user navigates away, the parent re-renders conditionally, or the component is removed), React calls the cleanup function returned from a useEffect. This is your chance to cancel subscriptions, abort fetches, or set flags that prevent updating state on a component that no longer exists.
/* The classic pattern with a local variable */
useEffect(() => {
let cancelled = false; // ① flag for this effect's lifetime
/* A plain let variable lives inside the effect closure.
Each time the effect runs, a new variable cancelled is created. */
async function load() {
const result = await someSlowOperation();
if (!cancelled) { // ② only update if still mounted
/* If the component unmounted while fetchData() was pending, cancelled is true and we skip the setState. */
setState(result);
}
}
load();
// The cleanup function sets cancelled = true exactly when
// (i) the component unmounts or (ii) the dependency -deps- array changes (if any).
return () => {
cancelled = true; // ③ cleanup: future setState() calls are ignored
};
}, []);
Why this matters: Calling setState on an unmounted component triggers React’s warning “Can’t perform a React state update on an unmounted component”. That warning indicates a memory leak (the promise stays alive) and a potential bug if the state update would have side‑effects.
Your prayer app must feel fast: when a user opens the Verse tab, the verse should appear immediately – no spinner, no delay. This is the “offline‑first, online‑upgrade” pattern, used by Progressive Web Apps, email clients, and RSS readers.
Mount component
│
▼
Load daily plan verse ← offline, ~0ms after first load (synchronous after initial JSON parse)
│
▼
Show verse immediately ← user sees text right away
│
▼
Try OurManna in background ← online request, ~300-1000ms
│
├─ Success → update verse silently (user sees the update)
└─ Failure → keep showing KJV (no error, no spinner)
The key insight: the offline source is always guaranteed to be fast. The online source is an enhancement that may never arrive. By showing the offline version first, the UI is never empty.
You might be tempted to race the two sources:
const result = await Promise.race([
getOfflineVerse(), // ~2 ms
fetchOnlineVerse(), // ~500 ms
]);
Promise.race() resolves with whichever promise finishes first.
The offline lookup is effectively synchronous (reading from a pre‑parsed in‑memory object). The online fetch takes hundreds of milliseconds. The offline promise will always win – the online result is never used, and the user never sees the freshest data.
The correct approach is sequential:
This guarantees the user always sees something straight away, and also gets the benefit of updated content whenever the network cooperates.
useRef creates a mutable object that persists across renders. Changing its .current property does not trigger a re‑render. This makes it perfect for:
Using useRef for the cleanup flag (alternative)
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true; // set on mount
return () => {
mountedRef.current = false; // clear on unmount
};
}, []);
useEffect(() => {
async function load() {
const result = await fetchData();
if (mountedRef.current) { // check after await
setData(result);
}
}
load();
}, [dependency]);
src/hooks/useVerse.tsThe useVerse hook manages the entire Bible‑verse state for the app’s Verse tab. It must:
┌─────────────┐ ┌──────────────────┐
│ Phase 1 │───▶│ KJV from plan │ ← instantaneous (sync after cache)
│ offline KJV │ │ appears on UI │
└─────────────┘ └────────┬─────────┘
│
│ (200ms pause to avoid flash)
▼
┌──────────────────┐
│ Phase 2 │
│ fetchOurManna() │ ← network call
│ │
│ if success → │
│ replace verse │
│ setSource(online)│
│ if fail → │
│ keep KJV verse │
└──────────────────┘
// src/hooks/useVerse.ts
//
// VERSE HOOK
// ───────────
// Manages all Bible verse state for the Verse tab.
//
// DESIGN: offline-first, online-upgrade
// 1. On mount: call getDailyPlanVerse() (uses offline KJV from kjv.json).
// This is synchronous after the first load — appears instantly.
// 2. In parallel: call fetchOurManna() online.
// If it succeeds, silently upgrade the displayed verse.
// 3. The user sees the KJV verse first, then (maybe or likely) the OurManna verse
// appears a moment later. No spinner, no waiting.
//
// CACHE INVALIDATION (refresh)
// Calling refresh() clears the cached plan (so edits to daily_plan.txt
// are picked up) and forces a full reload of the verse.
//
// STATE SHAPE:
// verse — the current VerseResult to display (never null once loaded)
// loading — true only during the initial load (brief, usually not visible)
// error — non-null if getDailyPlanVerse completely failed (very rare)
// source — shorthand for verse.source, used by the UI to show the badge
// isOnline — true if OurManna responded (meaning we have a network connection)
// refresh — call to force a full reload
import { useState, useEffect, useCallback, useRef } from "react";
import {
getDailyPlanVerse,
fetchOurManna,
clearPlanCache,
} from "../services/bibleService";
import type { VerseResult } from "../db/types";
// ── Return type ───────────────────────────────────────────────────────────────
export interface UseVerseResult {
/** The current verse result (null only during initial load) */
verse: VerseResult | null;
/** true while the initial verse load is in progress */
loading: boolean;
/** Error string if verse loading failed completely (rare) */
error: string | null;
/**
* "online" if OurManna responded, "offline" if only KJV is available,
* "loading" during the initial fetch.
*/
source: "online" | "offline" | "loading";
/** Force a full reload (clears cache and re-fetches everything) */
refresh: () => void;
/**
* The date being displayed (defaults to today).
* Changing this with setDate() shows a different day's verse.
*/
date: Date;
setDate: (d: Date) => void;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Manages Bible verse state for the Verse tab.
*
* Usage:
* const { verse, loading, source, refresh } = useVerse();
* if (loading) return <Spinner />;
* return <VerseDisplay ref={verse.ref} text={verse.text} />;
*/
export function useVerse(): UseVerseResult {
// ── State ─────────────────────────────────────────────────────────────────
const [verse, setVerse] = useState<VerseResult | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [source, setSource] = useState<"online" | "offline" | "loading">("loading");
/* Changing date re‑runs the effect (see dependencies), loading a new offline verse and attempting a new online fetch.
We default to new Date() using a lazy initializer so it’s only computed on mount. */
const [date, setDate] = useState<Date>(() => new Date());
// refreshKey: incrementing this value re-triggers the useEffect.
// This is the canonical React pattern for "force re-run an effect".
const [refreshKey, setRefreshKey] = useState<number>(0);
// mountedRef: used to prevent setState after unmount.
// We use useRef (not useState) because changing it should NOT trigger
// a re-render — it is internal bookkeeping only.
//
// WHY useRef and not a local `let cancelled` variable?
// If we declared `let cancelled = false` inside useEffect, we would
// need a new variable every time the effect re-runs (on every refreshKey
// change). useRef persists across re-runs and can be checked in the
// async callbacks even after the variable that created them is gone.
// Changing useRef does not cause a re‑render – it’s pure internal bookkeeping.
const mountedRef = useRef<boolean>(true);
// ── Main load effect ───────────────────────────────────────────────────────
useEffect(() => {
// Mark the component as mounted.
// We reset the mount flag to true at the start of each effect run so that after
// a refresh (new refreshKey) the flag is correctly set.
mountedRef.current = true;
// ── Phase 1: load the offline KJV verse ──────────────────────────────────
// This is fast — synchronous after first load.
// We wrap in an async function because getDailyPlanVerse is async
// (it reads daily_plan.txt if not yet cached).
async function loadOffline() {
setLoading(true);
setError(null);
setSource("loading");
try {
// getDailyPlanVerse NEVER returns null (hardcoded fallback inside)
const offlineVerse = await getDailyPlanVerse(date);
// Guard: if the component unmounted while we were loading,
// do not call setState (avoids React warnings)
if (!mountedRef.current) return;
setVerse(offlineVerse);
setLoading(false);
setSource("offline");
} catch (e) {
/* Offline load failure (getDailyPlanVerse throws): very rare (hardcoded fallback inside that function).
If it does happen we set error and stop loading. */
if (!mountedRef.current) return;
setError(String(e));
setLoading(false);
}
}
// ── Phase 2: try OurManna online in the background ───────────────────────
// This runs AFTER Phase 1 completes and does not block the UI.
async function tryOnlineUpgrade() {
// Wait for Phase 1 to complete (verse is set) before upgrading.
// A small delay prevents a flash where KJV appears briefly then changes.
// If you prefer the online verse to appear as fast as possible,
// remove this delay.
await new Promise<void>((resolve) => setTimeout(resolve, 200));
if (!mountedRef.current) return;
try {
const onlineVerse = await fetchOurManna();
if (!mountedRef.current) return;
if (onlineVerse) {
// Upgrade the displayed verse while preserving context about the
// offline plan reference. This way the user still knows which day
// of the plan they are viewing.
setVerse((prev) => ({
ref: onlineVerse.ref,
text: onlineVerse.text,
source: prev
// When we upgrade from offline to online,
// we preserve the offline plan reference by concatenating it into source:
// This gives the UI context: it might read “OurManna (ESV) · also: Gen 1:1 (KJV)”.
? `${onlineVerse.source} · also: ${prev.ref}`
: onlineVerse.source,
}));
setSource("online");
console.log("[useVerse] Upgraded to OurManna:", onlineVerse.ref);
}
// If null (offline / API failed), keep the KJV verse — no action needed
} catch {
/* Online fetch (OurManna) failure: we silently ignore it and keep the offline verse.
The app remains usable without network. We log a message to the console for debugging. */
if (mountedRef.current) {
console.log("[useVerse] OurManna unavailable, keeping KJV.");
}
}
}
// Run both phases — Phase 2 starts after Phase 1 sets the offline verse
loadOffline().then(() => {
// Only try the online upgrade if the component is still mounted
if (mountedRef.current) {
tryOnlineUpgrade();
}
});
// ── Cleanup function ─────────────────────────────────────────────────────
// React calls this when:
// a) The component unmounts (user navigates away)
// b) The effect re-runs (refreshKey changes, date changes)
//
// Setting mountedRef.current = false prevents any pending setState()
// calls in loadOffline() and tryOnlineUpgrade() from running. They are silently ignored.
return () => {
mountedRef.current = false;
};
}, [date, refreshKey]);
// Dependencies:
// date — re-run when the user changes which date to show
// setDate allows the user to view a different day’s verse.
// Changing date re‑runs the effect, loading a new offline verse and attempting a new online fetch.
// refreshKey — re-run when refresh() is called
// ── refresh() action ──────────────────────────────────────────────────────
const refresh = useCallback(() => {
// **Cache invalidation**
// Clear the cached plan so it re-reads daily_plan.txt on next load.
// This is needed when the user updates daily_plan.txt from the Settings screen.
clearPlanCache();
// Increment the key to re-trigger the useEffect
// It triggers the whole effect again from scratch, including a new online fetch.
setRefreshKey((prev) => prev + 1);
}, []);
return {
verse,
loading,
error,
source,
date,
setDate,
refresh,
};
}
Replace the entire contents of app/(tabs)/verse.tsx with this test screen. It replaces the previous panels with a
single, clean display of the useVerse hook output.
// app/(tabs)/verse.tsx
//
// VERSE TAB
//
// This screen serves as a **diagnostic test harness** for the `useVerse`
// custom hook. It replaces the accumulated test panels with a single, focused display
// of the hook's output, making it easy to verify:
//
// 1. **Offline fallback** – If the device has no internet connection,
// `useVerse` should return a verse from the bundled KJV JSON and set
// `source` to `"offline"`.
//
// 2. **Online upgrade** – When connectivity is restored, the hook
// attempts to fetch a daily verse from the OurManna API. If
// successful, `source` becomes `"online"` and the verse updates.
//
// 3. **Date navigation** – The user can step forward/backward by day
// using the arrow buttons, or jump to today. Each date change
// triggers a new lookup.
//
// 4. **Loading & error handling** – The screen shows a spinner while
// `loading` is `true`, and an error message with a "Retry" button
// if `error` is not null.
// The screen displays:
// - Today's verse (offline KJV, then OurManna upgrade)
// - Source badge ("online" / "offline")
// - Refresh button
// - Date navigation (previous / today / next day)
// - Debug panel showing live hook state
// - Instructions for manual testing
//
import React, { useState } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import { useVerse } from "../../src/hooks/useVerse";
import { todayISO, formatDate, dayOfYear } from "../../src/utils/dateUtils";
/**
* VerseScreen – Test harness for the `useVerse` hook.
*
* This component consumes the hook and renders its state in a structured
* layout suitable for manual verification. It contains:
* - A date navigation bar
* - A source badge indicating the verse origin
* - A verse card with reference, text, and source attribution
* - A debug panel showing the raw hook state
* - A testing instructions card
* - A refresh button
*
* The component has no local state of its own; all state is provided
* by `useVerse()`.
*
* @returns {React.ReactElement} The rendered screen.
*/
export default function VerseScreen() {
// --------------------------------------------------------------------------
// Hook state
// --------------------------------------------------------------------------
const { verse, loading, error, source, date, setDate, refresh } = useVerse();
// ── Loading state ───────────────────────────────────────────────────────
// While the hook is fetching data (either from local KJV or remote API),
// we show a centered spinner with a brief message.
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator size="large" color="#c9a84c" />
<Text style={s.muted}>Loading verse…</Text>
</Viewgt;
);
}
// ── Error state ─────────────────────────────────────────────────────────
// If the hook encountered an unrecoverable error (e.g., no offline data
// and no network), the user can manually retry.
if (error) {
return (
<View style={s.center}>
<Text style={s.errorText}>❌ {error}</Text>
<TouchableOpacity style={s.btn} onPress={refresh}>
<Text style={s.btnText}>Retry</Text>
</TouchableOpacity>
</Viewgt;
);
}
// ── Main display ───────────────────────────────────────────────────────────
// Compute auxiliary values for the UI.
const isToday = todayISO() === date.toISOString().slice(0, 10);
const doy = dayOfYear(date);
return (
<ScrollView style={s.scroll} contentContainerStyle={s.content}>
{/* Date navigation */}
<View style={s.dateNav}>
{/* Previous day */}
<TouchableOpacity
style={s.arrowBtn}
onPress={() => {
const prev = new Date(date);
prev.setDate(prev.getDate() - 1);
setDate(prev);
}}
>
<Text style={s.arrowBtnText}>◀</Text>
</TouchableOpacity>
{/* Date indicator – tapping it resets to today */}
<TouchableOpacity
style={s.datePill}
onPress={() => setDate(new Date())}
>
<Text style={s.datePillText}>
{isToday ? "Today" : formatDate(date.toISOString().slice(0, 10), "short")}
</Textgt;
<Text style={s.dateSub}>Day {doy} of the year</Text>
</TouchableOpacity>
{/* Next day */}
<TouchableOpacity
style={s.arrowBtn}
onPress={() => {
const next = new Date(date);
next.setDate(next.getDate() + 1);
setDate(next);
}}
>
<Text style={s.arrowBtnText}>▶</Text>
</TouchableOpacity>
</View>
{/* Source badge */}
<View style={[s.sourceBadge, source === "online" ? s.badgeOnline : s.badgeOffline]}>
<Text style={s.sourceBadgeText}>
{source === "online" ? "🌐 Online" :
source === "offline" ? "📖 Offline KJV" :
"⏳ Loading…"}
</Text>
</View>
{/* Verse card */}
{verse ? (
<View style={s.verseCard}>
<Text style={s.verseRef}>{verse.ref}</Text>
<View style={s.divider} />
<Text style={s.verseText}>{verse.text}</Text>
<Text style={s.verseSource}>{verse.source}</Text>
</View>
) : null}
{/* Debug panel */}
<View style={s.debugCard}>
<Text style={s.debugTitle}>useVerse() state</Text>
<DebugRow label="verse.ref" value={verse?.ref ?? "null"} />
<DebugRow label="source" value={source} />
<DebugRow label="loading" value={String(loading)} />
<DebugRow label="error" value={error ?? "null"} />
<DebugRow label="date" value={date.toISOString().slice(0, 10)} />
</View>
{/* Testing Instructions */}
<View style={s.hintCard}>
<Text style={s.hintText}>
Offline test:{"\n"}
1. Enable airplane mode on the device/emulator{"\n"}
2. Tap Refresh below{"\n"}
3. Verse appears from KJV (offline) — source badge = "📖 Offline KJV"{"\n"}
{"\n"}
Date test:{"\n"}
Tap ◀ / ▶ arrows to change the date.{"\n"}
Each date should show a different verse reference.{"\n"}
{"\n"}
Online upgrade test:{"\n"}
1. Connect to internet{"\n"}
2. Tap Refresh{"\n"}
3. Watch the source badge change from "📖 Offline KJV" to "🌐 Online"
</Textgt;
</Viewgt;
{/* Refresh button */}
<TouchableOpacity style={s.refreshBtn} onPress={refresh}>
<Text style={s.refreshBtnText}>↺ Refresh</Text>
</TouchableOpacity>
<Text style={s.chunkLabel}>
UseVerse hook
</Text>
</ScrollView>
);
}
// ── Debug row helper ──────────────────────────────────────────────────────────
/**
* A small reusable component for the debug panel.
* Renders a key‑value pair where the label is left‑aligned and the value
* is right‑aligned, using monospace fonts for easy reading.
*
* @param label - The name of the state property (e.g., "verse.ref")
* @param value - The current value (string representation)
*/
function DebugRow({ label, value }: { label: string; value: string }) {
return (
<View style={s.debugRow}>
<Text style={s.debugLabel}>{label}</Text>
<Text style={s.debugValue} numberOfLines={1}>{value}</Text>
</View>
);
}
// ── Styles ────────────────────────────────────────────────────────────────────
/**
* The style sheet follows a dark theme ("#1a1f2e" background).
* Many elements use semitransparent borders and muted colours to maintain
* visual hierarchy without harsh contrasts.
*
* Naming convention: short, descriptive keys (e.g., `s.verseCard`, `s.arrowBtn`).
* Grouped roughly by the UI section they belong to.
*/
const s = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#1a1f2e" },
content: { padding: 20, paddingBottom: 48 },
center: { flex: 1, backgroundColor: "#1a1f2e",
alignItems: "center", justifyContent: "center", gap: 12 },
muted: { color: "#6b7280", fontSize: 14, textAlign: "center" },
errorText: { color: "#f87171", fontSize: 13, textAlign: "center" },
btn: { backgroundColor: "#5b8fd4", borderRadius: 8,
paddingVertical: 10, paddingHorizontal: 24 },
btnText: { color: "#fff", fontSize: 14, fontWeight: "600" },
// Date navigation
dateNav: { flexDirection: "row", alignItems: "center",
justifyContent: "space-between", marginBottom: 16, gap: 8 },
arrowBtn: { backgroundColor: "#222840", borderRadius: 8,
padding: 12, borderWidth: 1, borderColor: "#2d3550" },
arrowBtnText: { color: "#9ca3af", fontSize: 16 },
datePill: { flex: 1, backgroundColor: "#222840", borderRadius: 10,
padding: 12, alignItems: "center",
borderWidth: 1, borderColor: "#2d3550" },
datePillText: { fontSize: 15, fontWeight: "600", color: "#c9a84c" },
dateSub: { fontSize: 11, color: "#6b7280", marginTop: 2 },
// Source badge
sourceBadge: { borderRadius: 20, paddingVertical: 6,
paddingHorizontal: 14, alignSelf: "flex-start",
marginBottom: 14, borderWidth: 1 },
badgeOnline: { backgroundColor: "#0f2a1a", borderColor: "#166534" },
badgeOffline: { backgroundColor: "#1a2438", borderColor: "#1e3a5f" },
sourceBadgeText: { fontSize: 12, fontWeight: "600", color: "#dde4f0" },
// Verse card
verseCard: { backgroundColor: "#222840", borderRadius: 12,
padding: 20, marginBottom: 16,
borderWidth: 1, borderColor: "#2d3550" },
verseRef: { fontSize: 20, fontWeight: "bold", color: "#c9a84c",
marginBottom: 12 },
divider: { height: 1, backgroundColor: "#2d3550", marginBottom: 14 },
verseText: { fontSize: 16, color: "#dde4f0", lineHeight: 26,
marginBottom: 14 },
verseSource: { fontSize: 11, color: "#5b8fd4" },
// Debug panel
debugCard: { backgroundColor: "#1a1f2e", borderRadius: 8, padding: 14,
marginBottom: 16, borderWidth: 1, borderColor: "#2d3550" },
debugTitle: { fontSize: 11, color: "#6b7280", fontWeight: "600",
textTransform: "uppercase", letterSpacing: 0.5,
marginBottom: 10 },
debugRow: { flexDirection: "row", justifyContent: "space-between",
paddingVertical: 3, borderBottomWidth: 1,
borderBottomColor: "#222840" },
debugLabel: { fontSize: 11, color: "#4b5563", fontFamily: "monospace" },
debugValue: { fontSize: 11, color: "#9ca3af", fontFamily: "monospace",
flex: 1, textAlign: "right" },
// Hint card
hintCard: { backgroundColor: "#1a1f2e", borderRadius: 8, padding: 14,
marginBottom: 16, borderWidth: 1, borderColor: "#2d3550" },
hintText: { fontSize: 11, color: "#4b5563", lineHeight: 18,
fontFamily: "monospace" },
// Refresh
refreshBtn: { backgroundColor: "#222840", borderRadius: 8, padding: 14,
alignItems: "center", marginBottom: 16,
borderWidth: 1, borderColor: "#2d3550" },
refreshBtnText: { color: "#9ca3af", fontSize: 14 },
chunkLabel: { fontSize: 10, color: "#4b5563", textAlign: "center",
fontFamily: "monospace" },
});
npx expo start --clearsource = "online"