Why a workspace instead of one app
Most portfolio pieces are a single app. This one is deliberately not — it's a multi-project Expo workspace where each folder is a focused, runnable app that isolates one slice of real mobile engineering: adaptive UI, navigation, state management, forms, HTTP, authentication, and native device APIs. I built it this way because production React Native work rarely fails on any one of these in isolation — it fails at the seams between them. A navigator that doesn't compose with a Drawer, a Context provider re-rendering the whole tree, an auth token that never gets attached to a request. Isolating each concern into its own runnable module let me get each pattern right on its own before combining them, and it gives anyone reviewing the code a clean map of what I actually know how to build, rather than one app that vaguely touches everything.
The workspace has nine modules, each a standalone Expo app with its own package.json, sharing a consistent internal structure: App.js as the entry point, components/ for reusable UI, screens/ for containers, store/ for state, util/ for helpers, and constants/ for shared styling tokens.
Navigation: nested navigators without header/route collisions
The Meals app (module 06) is the navigation-heavy piece — it nests a Drawer.Navigator inside a Stack.Navigator, which is a common real-world requirement (side-menu app shell, stack-based drill-down inside a tab/drawer) and a common source of bugs when header options or route params leak between levels:
<Stack.Navigator screenOptions={{ headerStyle: { backgroundColor: "#351401" } }}>
<Stack.Screen name="Drawer" component={DrawerNavigator} options={{ headerShown: false }} />
<Stack.Screen name="MealsOverview" component={MealsOverviewScreen} />
<Stack.Screen name="MealDetail" component={MealDetailScreen} options={{ title: "About the Meal" }} />
</Stack.Navigator>
The trick that matters here is headerShown: false on the Drawer stack screen — without it you get two headers stacked on top of each other the moment the Drawer navigator renders its own. It's a one-line fix, but it's the kind of detail that separates a navigation structure that looks right in a screenshot from one that behaves right when a user actually taps through it.
State: Context + useReducer before reaching for Redux
Modules 07 and 08 build the same expense-tracking domain twice — once with Redux, once with Context API + useReducer — specifically to compare the two at equal scope. The Context version won out for this domain size, and the reducer pattern is what makes it viable at all: a single expensesReducer handling ADD, UPDATE, and DELETE as explicit action types, with the Context only exposing intention-revealing functions (addExpense, updateExpense, deleteExpense) rather than raw dispatch to consumers:
function expensesReducer(state, action) {
switch (action.type) {
case 'ADD':
return [{ ...action.payload, id: id }, ...state];
case 'UPDATE': {
const index = state.findIndex(e => e.id === action.payload.id);
const updated = [...state];
updated[index] = { ...state[index], ...action.payload.data };
return updated;
}
case 'DELETE':
return state.filter(e => e.id !== action.payload);
}
}
This is a deliberate architectural stance, not a shortcut: Redux earns its complexity budget when state is shared across many unrelated feature areas or needs middleware (persistence, logging, sagas). For a bounded domain like "the list of expenses this screen manages," a reducer plus Context gets the same predictability — actions in, new state out — without the extra dependency and boilerplate.
HTTP: from local reducer to a real backend, with proper failure states
Module 10 takes the exact same expense-tracker UI from module 08 and swaps the local DUMMY_EXPENSES array for a real Firebase Realtime Database backend over Axios — which is the more instructive exercise, because it forces the state layer to handle latency and failure, not just data shape:
export async function storeExpense(expenseData) {
const response = await axios.post(BACKEND_URL + '/expenses.json', expenseData);
return response.data.name; // Firebase returns the generated key as `name`
}
The screen-level change is the important part. ManageExpense now tracks isSubmitting and error as explicit UI states, and on edit it updates local Context state optimistically before awaiting the network call, so the UI feels instant while the request is in flight:
if (isEditing) {
expensesCtx.updateExpense(editedExpenseId, expenseData); // optimistic
await updateExpense(editedExpenseId, expenseData); // then persist
}
On failure, the catch block flips back to an ErrorOverlay with a retry-worthy message instead of a silent failure or a raw error object. This is a small pattern but it's the difference between an app that feels responsive and one that feels like it's fighting the network on every screen.
Auth: token-based sessions with persistence across app restarts
Module 11 implements email/password auth against Firebase's Identity Toolkit REST API directly over Axios (no Firebase SDK), which means handling the token lifecycle by hand rather than getting it for free:
async function authenticate(mode, email, password) {
const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`;
const response = await axios.post(url, { email, password, returnSecureToken: true });
return response.data.idToken;
}
export const createUser = (email, password) => authenticate('signUp', email, password);
export const login = (email, password) => authenticate('signInWithPassword', email, password);
The AuthContext is intentionally thin — it exposes token, a derived isAuthenticated boolean, and authenticate/logout functions, and it's the single source of truth the root navigator reads to decide between the auth stack and the authenticated app stack. The token is persisted to AsyncStorage on login and cleared on logout, so the natural next step (which the module scaffolds for) is a splash-screen check on app boot that reads the stored token and silently re-authenticates the session instead of forcing a login screen on every cold start.
Native device features: camera, maps, and on-device SQL
Module 12 is the "Places" app — users capture a photo and a map location for each place, persisted locally. Three native integrations had to work together correctly:
| Concern | API | Key detail |
|---|---|---|
| Camera capture | expo-image-picker | Explicit permission-state machine (UNDETERMINED / DENIED / granted) rather than a single boolean, so denied-vs-not-yet-asked get different UX |
| Location picking | react-native-maps | MapView with a controlled onPress handler that's disabled once a location is passed in via route params, so the "view" mode and "pick" mode share one component without extra branching |
| Local persistence | expo-sqlite | Real SQL (CREATE TABLE IF NOT EXISTS, parameterized INSERT/SELECT) instead of flattening everything into AsyncStorage JSON blobs |
The SQLite choice is worth calling out: AsyncStorage is fine for small key-value data, but once you have structured records with a stable schema — and want indexed lookups by id — a real embedded database is the correct tool, and expo-sqlite's async API (runAsync, getAllAsync, getFirstAsync) makes it a drop-in fit for React's async data-fetching patterns.
Why this structure, for anyone evaluating the work
Each module here is small enough to read end-to-end in a few minutes, but the patterns inside them — reducer-driven state, optimistic updates with explicit error states, permission-state machines instead of booleans, token persistence across restarts — are exactly what shows up in larger production apps, just without the noise of a bigger codebase around them. That's the point: this workspace isn't a demo of one finished product, it's evidence of the underlying engineering habits — separating concerns cleanly, choosing the right-weight tool for each state problem, and handling the failure paths (denied permissions, failed requests, cold starts) as first-class cases rather than afterthoughts.