The engineer's companion to the product writeup
This is the engineer's companion to the product retrospective of the same project — a cross-platform customer self-service app for a prominent nationwide ISP in Bangladesh. Where that post talks about scope and people, this one is about the things that actually kept us up at night: the service layer, the payment WebView, the auth refresh loop, bilingual UI, offline UX, and the bits of store-compliance plumbing that nobody mentions in the tutorials.
Most of what's below is transferable to any ISP customer self-service app, and most of it is advice I wish someone had given us before we started.
Stack choices, and why
- React Native via Expo (managed + prebuild). Single codebase for Android and iOS, first-class native modules through Expo's config plugins, and EAS for builds. We needed cross-platform parity, not per-platform differentiation.
- Expo Router. File-based routing collapses the navigation config into the filesystem.
app/(app)/is the authenticated surface;app/login.tsx,app/forgot-password.tsx,app/privacy-policy.tsxare public routes. The(app)route group exists purely to gate anything inside it behind auth. - NativeWind (Tailwind in RN). Utility classes stop the team from bikeshedding stylesheet APIs. It also makes the design system legible to anyone who has ever touched Tailwind on the web.
- React Context for state, not Redux. The app has three slices of global state — auth, language, popup — and each is a small context. Anything larger would be over-engineered for a self-service app with roughly a dozen screens.
- React Hook Form for forms, toast for feedback, Reanimated for motion, NetInfo for connectivity, Firebase Analytics for product telemetry. No state management library, no GraphQL client, no over-abstracted network layer. Everything is boring on purpose.
The service layer: mock-first, real-when-ready
The single best architectural decision on this project was building a service layer with a mock-data toggle. The backend team and the mobile team ran on separate cadences; neither could afford to wait on the other.
Every request goes through the same shape. The mock-vs-real branch is invisible to callers — both paths return the same ApiResponse<T>.
Shape of the config:
const API_CONFIG = {
BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL || 'https://…/api/v1',
TIMEOUT: 30000,
USE_MOCK_DATA: process.env.EXPO_PUBLIC_USE_MOCK_DATA === 'true',
PAYMENT_REDIRECT_HOSTS: process.env.EXPO_PUBLIC_PAYMENT_REDIRECT_HOSTS || '…',
};Every service module checks USE_MOCK_DATA at the top of each function and either resolves against a mockData.ts fixture (with a simulated 300–500ms delay) or calls the real API. The contract — the ApiResponse<T> shape — is identical in both paths:
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}Components never know which mode they're in. They just await getPayBillPackages() and check response.success. Result: we could demo the entire app to stakeholders in week three, with zero backend, and flip to real endpoints feature-by-feature as they came online.
Auth, session, and the token refresh trap
Auth for an ISP self-service app is less scary than it looks, but there is one common pitfall: what do you do when a token expires while the user is on, say, the Pay Bill screen?
Our solution has three parts:
- Token persistence in AsyncStorage. The token is prefixed with
Beareronce, at write time, so every caller can reuse it without restringing. - A global logout callback registered from the AuthContext. The API client holds a function pointer; when a request fails with a real 401 (not a transient error), it calls the callback, which wipes state and bounces the user to the login screen.
- A silent refresh path. Before giving up on a request, the client tries
/refresh-tokenonce with the current token. If that succeeds, the original request is retried transparently. If it fails, we fall through to the logout callback.
Silent token refresh on a 401. The whole sequence is invisible to the screen — it just gets an ApiResponse back, eventually.
Sketch of the glue:
let globalLogoutCallback: (() => void) | null = null;
export function setLogoutCallback(cb: () => void) {
globalLogoutCallback = cb;
}
useEffect(() => {
setLogoutCallback(() => { logout(); });
return () => clearLogoutCallback();
}, []);We also layered an app-level session expiry (24h) on top of the token expiry. When the app cold-starts, it reads a timestamp from storage and nukes the session if it's past due — so a phone left on a shelf for a week doesn't silently reuse a stale identity.
Payments: the WebView host-interception pattern
The hardest feature in an ISP customer self-service app, by a wide margin, is the payment flow. Not because the payment UI is hard — the aggregator provides a checkout URL, you load it in a WebView, done — but because you have to detect completion without being told.
The aggregator does not post a message back into the WebView when payment succeeds. It redirects the browser to a success or failure URL on one of your configured hosts. So the mobile app has to watch the WebView's navigation state and decide, on each URL change, "is this a redirect that means we're done?"
We made the redirect host list a comma-separated environment variable (EXPO_PUBLIC_PAYMENT_REDIRECT_HOSTS) so we could add new hosts without shipping a build.
Payment flow from tap to receipt. The sharp edges cluster at step 4 (redirect detection) and step 5 (server-side truth).
The handler is small but load-bearing:
const handleShouldStartLoadWithRequest = (request: any) => {
const { url } = request;
if (url === paymentUrl) return true;
const urlHost = getHostFromUrl(url);
const isRedirectHost = PAYMENT_REDIRECT_HOSTS.some(h => urlHost === h);
if (isRedirectHost && transactionId) {
setTimeout(() => handlePaymentStatusCheck(), 100);
return false;
}
return true;
};The sharp edges we hit:
- User closes the WebView. Treat that as cancel, not failure — show a dedicated "cancelled" modal so the user knows their card wasn't charged.
- Host matching, not URL matching. Aggregators change path shapes; they don't change domains. Match on host.
- Always store
transaction_idbefore opening the WebView. If the app is killed mid-flow, you can still resume a status check. - Don't trust the WebView's completion — trust the server. The status check is the source of truth. The WebView is only the trigger.
- Refresh dashboard and user data after a successful payment, so the balance and expiry date reflect the new state without a full reload.
Bilingual without a framework
i18next is a perfectly fine choice. We didn't use it. For an app with roughly 300 strings, a typed translation map inside a Context is simpler and ships smaller:
const translations: Record<Language, Record<string, string>> = {
en: { 'dashboard.greeting': 'Hello', /* … */ },
bn: { 'dashboard.greeting': 'হ্যালো', /* … */ },
};
export const useLanguage = () => useContext(LanguageContext);Rules we enforced:
- No literal strings in JSX. Ever. Every
<Text>callst('key'). Reviewers grep for bare string literals in commits. - Persist the user's choice in AsyncStorage and restore it synchronously on cold start, before any screen mounts, so the app never flashes English to a Bangla user.
- Design in both languages. Bangla renders wider than English in most components. Layouts that look fine in English will line-wrap in Bangla. Fix in design, not in CSS later.
- Bangla font loading.
@expo-google-fonts/noto-sans-bengalialongside Inter. Bangla text with the wrong font falls back to a system font that ships differently on Android vs iOS and looks inconsistent.
Offline: the banner that earns its keep
The app lives on phones with variable connectivity. A useful offline strategy has three parts:
- Detect disconnection with NetInfo.
- Tell the user with a persistent banner, not a silent failure.
- Make every screen's loading state recoverable. No dead-end spinners.
The banner is ~30 lines of React:
export function OfflineBanner() {
const { isConnected } = useNetworkStatus();
if (isConnected) return null;
return <Animated.View /* slides down, red background, wifi-off icon */ />;
}It mounts once in the root layout and handles its own visibility. The honest version of offline UX is: don't try to be clever, just be loud when the network is gone.
Analytics that gracefully degrade
Firebase Analytics is the easy choice, but it has a gotcha: in Expo Go, the native module isn't present, and a naive import crashes the dev client. We guard it:
let analytics: typeof import('@react-native-firebase/analytics').default | null = null;
try {
analytics = require('@react-native-firebase/analytics').default;
} catch {
console.warn('[Analytics] Firebase Analytics not available');
}
async function logEvent(name: string, params?: EventParams): Promise<void> {
try {
if (analytics) await analytics().logEvent(name, params);
} catch (error) {
if (__DEV__) console.warn(`[Analytics] Failed to log "${name}":`, error);
}
}Every event site calls through a typed Analytics facade (Analytics.paymentInitiated, Analytics.loginFailed, etc.) so we get autocompletion and a stable event taxonomy, and we never have to touch the Firebase SDK from a component.
Store compliance, in code
Google Play's account deletion requirement is not optional. The mobile implementation is roughly:
- A dedicated
delete-account.tsxscreen with three stages:info→confirm(password + optional reason) →success(shows the server-issuedrequest_idand ETA). - The service call posts to the deletion endpoint with
ba_no, username, mobile, reason, password confirmation, source (mobile_app), and app version. - On success, the analytics ID is reset and the user is logged out after a 2.5s delay so the confirmation UI has time to render.
if (response.success && response.data) {
Analytics.accountDeletionRequested(response.data.request_id);
await Analytics.resetAnalyticsId();
setTimeout(async () => { await logout(); }, 2500);
}Pair that with a static privacy-policy.tsx screen reachable from the login surface, link it in the Play Console listing, and you're in compliance. Do this up front, not in the week before submission.
Build, signing, and environments
Boring and important.
- Three environments: development (localhost, mock data), preview (staging API), production (live API). EAS profiles in
eas.jsonmap to each, withEXPO_PUBLIC_*env vars baked in per profile. - Android signing keystore lives outside the repo.
gradle.propertieswith the passwords is gitignored; aBUILD_GUIDE.mddocuments the regeneration steps so the knowledge doesn't live in one person's head. - Proguard + resource shrinking on in release. Dropped the APK significantly once we enabled both. Test in release mode before shipping — a reflection-heavy dependency will happily get stripped and fail only in production.
- Permissions trimmed. Start from the default Expo list and explicitly remove anything you don't need.
READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE, andSYSTEM_ALERT_WINDOWgo intoblockedPermissionsinapp.json— an ISP self-service app has no business requesting any of them, and reviewers at both stores will flag you if you do.
Things I'd do differently next time
The biggest one is a proper state machine for the payment screen. The live implementation has roughly six booleans — showWebView, processingPayment, showStatusModal, showCancelledModal, webViewLoading, calculating. Most of the bugs we hit there were invalid combinations of those booleans.
Six independent booleans can encode 64 states. The gap between "reachable" and "valid" is where bugs live. A real finite state machine gives you 7 by construction.
Here's what the refactor target looks like as a state machine:
The pay-bill screen as a finite state machine. Solid arrows are forward transitions; dashed arrows are dismiss/back paths that return the user to idle.
Other things I'd change:
- Skeleton loaders from commit one. We bolted them on late. Every screen should have a skeleton state before it has a real data state — the visual difference between "loading" and "broken" is the whole of perceived quality.
- A typed API client generator. We hand-wrote types against the Postman collection. Codegen from the OpenAPI spec would have eliminated a class of drift bugs.
- Deeper offline caching. Right now, offline means "show a banner." A better version caches the dashboard so a user can open the app in a dead zone and still see their balance and expiry.
The short version
Boring stack. Mock-first service layer. Global logout callback around a silent refresh. Payment WebView with host-based redirect detection. Bilingual by convention, not by framework. Graceful analytics. Compliance treated as a feature. Release builds tested in release mode.
None of this is clever. All of it is the difference between a customer self-service app that works and one that you rewrite twelve months later.
The state machine you write on a whiteboard in week one costs an hour. The state machine you reconstruct from six booleans in month twelve costs a sprint.