A cross-platform Personal Finance application (Frontend Mentor challenge) built on a shared design system targeting React Native (Expo) and React web (React Router), managed with pnpm workspaces.
React Native · Expo SDK 54 · React · TypeScript · pnpm · Turborepo · twrnc · Tailwind CSS · Style Dictionary · CVA · Supabase · Jotai · TanStack Query · Expo Router · react-hook-form · zod
- About this project
- Project Structure
- Packages
- Prerequisites
- Environment Setup (macOS)
- Main Commands
- How the Monorepo Works
- React Native Version Alignment (0.81.5)
- Troubleshooting
This project started from the perspective of a React developer asking: "How do I build a consistent design across web and mobile?"
The answer is a shared design system (@financial-app/ui) where styles are shared between web and native component implementations via a file extension split (.web.tsx / .native.tsx). Styling uses Tailwind CSS on web and twrnc on native, with shared variant logic through CVA (class-variance-authority). This keeps both platforms visually aligned while respecting each renderer's idioms. A migration to NativeWind (Tailwind v4 + unified className) is planned once it reaches stable release.
Three mobile apps coexist for comparison: mobile (bare React Native CLI), mobile-expo (Expo managed — the canonical app), and mobile-expo-ejected (ejected Expo). A web app uses React Router v7 in framework mode. An API server built on Express delivers the backend.
Business logic shared across all apps (auth, state, types, utils) lives in the shared package. Screen-specific compositions of design system primitives — configured DataTables, modal content, overview sections — live in the features package, keeping both the design system and the apps focused on their own concerns.
react-and-react-native-financial-app/
├── package.json # Monorepo root config
├── pnpm-workspace.yaml # pnpm workspaces + catalog
├── turbo.json # Turborepo task pipeline
├── apps/
│ ├── mobile-expo/ # Expo SDK 54 (canonical mobile app)
│ ├── web/ # React Router v7 + Vite (SSR)
│ ├── storybook/ # Storybook — component browser (web + native)
│ ├── mobile/ # Bare React Native CLI (learning reference)
│ └── mobile-expo-ejected/ # Ejected Expo (learning reference)
├── packages/
│ ├── tokens/ # @financial-app/tokens — Style Dictionary (DTCG)
│ ├── tailwind-config/ # @financial-app/tailwind-config — shared Tailwind config
│ ├── icons/ # @financial-app/icons — cross-platform SVG icon library
│ ├── ui/ # @financial-app/ui — cross-platform design system
│ ├── features/ # @financial-app/features — screen-specific composed blocks
│ └── shared/ # @financial-app/shared — auth, types, utils, atoms
└── scripts/ # Utility scripts (reset, rebuild, changelogs)
| App | Path | Status | Description |
|---|---|---|---|
mobile-expo |
apps/mobile-expo/ |
Active | Expo managed (SDK 54) — canonical mobile app, primary focus |
web |
apps/web/ |
Active | React Router v7 + Vite with SSR |
mobile |
apps/mobile/ |
Active | Bare React Native CLI — learning reference |
mobile-expo-ejected |
apps/mobile-expo-ejected/ |
Active | Expo bare/ejected — learning reference |
storybook |
apps/storybook/ |
Active | Component browser (web + native stories via react-native-web) |
api |
apps/api/ |
Planned | Express REST API (OpenAPI + zod-to-openapi) |
| Package | Path | Status | Description |
|---|---|---|---|
@financial-app/tokens |
packages/tokens/ |
Active | Style Dictionary (DTCG) — colors, spacing, typography, radii |
@financial-app/tailwind-config |
packages/tailwind-config/ |
Active | Shared Tailwind config consuming token outputs |
@financial-app/icons |
packages/icons/ |
Active | Cross-platform SVG icon library — type-safe <Icon name="..." /> component |
@financial-app/ui |
packages/ui/ |
Active | Cross-platform design system (file extension split: .native.tsx / .web.tsx) |
@financial-app/features |
packages/features/ |
Active | Screen-specific composed blocks (overview, transactions, budgets, pots) |
@financial-app/shared |
packages/shared/ |
Active | Auth (Supabase), Jotai atoms, domain types, utils |
@financial-app/http-client |
packages/http-client/ |
Planned | HeyAPI client consuming the Express REST API |
@financial-app/tokens -> depends on nothing
@financial-app/icons -> depends on nothing (react-native-svg is a peer dep)
@financial-app/tailwind-config -> @financial-app/tokens
@financial-app/ui -> @financial-app/tokens, @financial-app/tailwind-config, @financial-app/icons
@financial-app/features -> @financial-app/ui, @financial-app/tailwind-config
@financial-app/shared -> depends on nothing (pure TS, no renderer)
apps/* (mobile, web) -> @financial-app/features, @financial-app/ui, @financial-app/shared, @financial-app/icons
- Node.js (v18+ recommended)
- pnpm:
npm install -g pnpm - Ruby (v3.1.x recommended): for CocoaPods (iOS)
- Xcode (macOS): for iOS
- Android Studio: for Android
This section details the steps to configure the React Native development environment on macOS.
npm install -g pnpmThe system Ruby version (2.6) is too old for CocoaPods. Ruby 3.4+ can also cause compatibility issues. Ruby 3.1.x is recommended.
# Install rbenv
brew install rbenv
# Install Ruby 3.1.4
rbenv install 3.1.4
# Set as global version
rbenv global 3.1.4
# Add rbenv to shell (once only)
echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc && source ~/.zshrc
# Verify version
ruby -v # Should display ruby 3.1.4# Select Xcode as the active build tool
sudo xcode-select -s /Applications/Xcode.app/Contents/DeveloperOpen Xcode at least once to accept the license and install additional components (iOS simulators).
- Install Android Studio
- Open Android Studio -> More Actions -> Virtual Device Manager
- Create an emulator (e.g. Pixel 7, API 34)
Configure environment variables:
# Java 17 (required by Gradle 9)
brew install openjdk@17
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
echo 'export JAVA_HOME="/opt/homebrew/opt/openjdk@17"' >> ~/.zshrc
# Android SDK
echo 'export ANDROID_HOME=$HOME/Library/Android/sdk' >> ~/.zshrc
echo 'export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools' >> ~/.zshrc
source ~/.zshrcVerify:
java -version # Should display openjdk 17.x.x
adb --version # Should display Android Debug Bridge# From the monorepo root
pnpm install# In apps/mobile
cd apps/mobile
bundle install
bundle exec pod install --project-directory=ios# Start Metro (in a dedicated terminal)
pnpm --filter mobile-financial-app start
# In another terminal:
pnpm --filter mobile-financial-app ios # iOS (simulator)
pnpm --filter mobile-financial-app android # Android (emulator must be running)All orchestrated commands use Turborepo for caching and correct dependency ordering.
pnpm install # Install all dependencies
pnpm build # Build all packages (tokens → tailwind-config → ui → apps)
pnpm tokens # Rebuild token outputs only
pnpm icons # Regenerate icon data from SVGspnpm type-check # TypeScript --noEmit across all packages
pnpm lint # ESLint across all packages
pnpm test # Jest tests across all packagesFor day-to-day JS/TS work (editing components, screens, styles, logic), Metro hot-reloads changes instantly — no rebuild needed. Just keep Metro running in one terminal.
# Web
pnpm web:dev # React Router dev server
# Expo (canonical mobile)
pnpm expo:start # Metro bundler (press i/a for iOS/Android)
pnpm expo:ios # Build + launch on default iPhone simulator
pnpm expo:ios:iphone # iPhone 16 Pro simulator
pnpm expo:ios:ipad # iPad Pro 11-inch (M4) simulator
pnpm expo:android # Build + launch on default Android emulator
pnpm expo:android:phone # Small_Phone AVD
pnpm expo:android:tablet # Medium_Tablet AVD
pnpm expo:ios:device # Pick from connected iOS devices
pnpm expo:android:device # Pick from connected Android devices
# Bare RN CLI
pnpm mobile:start # Metro bundler
pnpm mobile:ios # Build + launch on iOS simulator
pnpm mobile:android # Build + launch on Android emulator
# Storybook
pnpm storybook # Component browser (web + native stories)These commands assume a native binary is already installed on the simulator/emulator. If it's your first run or you changed native dependencies, use a rebuild command instead.
Use rebuild when the native layer changed — adding/removing a native library,
changing build.gradle, Podfile, app.json native config, or upgrading RN/Expo SDK.
Also use it when a build fails for no obvious reason (cache corruption).
For pure JS/TS changes, you do not need this — Metro hot-reloads automatically.
rebuild:android (scripts/rebuild-android.sh) is a single command that handles everything:
- Stops Gradle daemon (cached JVM that survives
rm -rfon disk) - Kills stale Metro on port 8081 (if any)
- Cleans Gradle build dirs (
.gradle/,build/,.cxx/) - Cleans Metro/Haste/RN temp caches + Watchman
- Runs
expo prebuild --clean(Expo only, skipped for bare RN) - Starts Metro in background, waits for it to be ready
- Builds and launches on emulator
# Bare RN CLI
pnpm mobile:rebuild:android # default emulator
pnpm mobile:rebuild:android:phone # Small_Phone AVD
pnpm mobile:rebuild:android:tablet # Medium_Tablet AVD
# Expo managed
pnpm expo:rebuild:android # default emulator
pnpm expo:rebuild:android:phone # Small_Phone AVD
pnpm expo:rebuild:android:tablet # Medium_Tablet AVDFirst build after a clean takes ~5 min (Gradle re-downloads dependencies). Subsequent runs reuse the global Gradle cache and finish faster.
After the script finishes, Metro keeps running in the background. The output shows the PID and the command to stop it.
pnpm mobile:rebuild:ios # Bare RN CLI — clean + pod install + build + launch
pnpm expo:rebuild:ios # Expo managed — clean + prebuild + build + launchUse pnpm reset when everything is broken and you want to simulate a fresh clone.
It removes all node_modules, caches, build outputs, reinstalls everything, and rebuilds tokens.
See docs/modus-operandi/reset.md for the full checklist.
pnpm reset # Full clean + reinstall + pod install + token rebuild
pnpm clean # Remove all node_modules only
pnpm clean:build # Remove all build/dist outputs onlyAfter a reset, you still need to rebuild native binaries — use the rebuild commands above.
The monorepo uses pnpm workspaces for dependency management and Turborepo for task orchestration.
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"Turborepo ensures tasks run in the correct dependency order with caching:
pnpm build → tokens → tailwind-config → ui → shared → web (parallel where possible)
Cached tasks replay instantly — a full pnpm type-check with warm cache runs in ~30ms.
# Add a dependency to a specific package
pnpm --filter mobile-expo-financial-app add <package-name>
# Add a dev dependency
pnpm --filter mobile-expo-financial-app add -D <package-name>pnpm add -w -D <package-name>UI components use a file extension split pattern — shared types + CVA variants with platform-specific implementations:
packages/ui/src/components/Button/
Button.tsx # Types + props interface only (no JSX)
Button.native.tsx # React Native implementation (twrnc)
Button.web.tsx # DOM/HTML implementation (Tailwind CSS + cn())
index.ts # Native barrel (Metro picks this)
index.web.ts # Web barrel (Vite picks this)
Styling: twrnc on native, Tailwind CSS on web. Shared variant logic via CVA (class-variance-authority).
This monorepo contains multiple apps:
mobile: React Native CLI (without Expo)mobile-expo: Expo managed (SDK 54)mobile-expo-ejected: Expo bare/ejectedui: Shared components withtwrnc(Tailwind for RN)
Expo SDK 54 requires React Native 0.81.x. To avoid multiple version conflicts in the monorepo (which cause "Invalid hook call" errors and crashes), all apps must use the same React Native version.
Initially, mobile used RN 0.82.1 (latest version) while Expo SDK 54 used RN 0.81.5. This caused:
- 3 versions of react-native in
node_modules/.pnpm - "Invalid hook call" errors due to multiple React instances
- Android crashes with
library "libreact_featureflagsjni.so" not found
In the root package.json:
{
"pnpm": {
"overrides": {
"react-native": "0.81.5",
"@react-native/babel-plugin-codegen": "0.81.5",
"@react-native/babel-preset": "0.81.5",
"@react-native/codegen": "0.81.5",
"@react-native/js-polyfills": "0.81.5",
"@react-native/metro-babel-transformer": "0.81.5",
"@react-native/metro-config": "0.81.5"
}
}
}In apps/mobile/package.json, apps/mobile-expo/package.json, etc.:
{
"dependencies": {
"react-native": "0.81.5"
},
"devDependencies": {
"@react-native-community/cli": "20.0.0",
"@react-native/babel-preset": "0.81.5",
"@react-native/metro-config": "0.81.5",
"@react-native/typescript-config": "0.81.5"
}
}If the android/ folder was created with RN 0.82, it contains incompatible files. Solution:
cd apps/mobile
mv android android_backup
npx @react-native-community/cli init mobile --version 0.81.5 --skip-install --directory temp_mobile
mv temp_mobile/android .
rm -rf temp_mobile# See how many react-native versions are installed
ls node_modules/.pnpm | grep "^react-native@0"
# Check version in a package
cat apps/mobile/node_modules/react-native/package.json | grep versionTo view Android crash logs:
# Clear old logs
adb logcat -c
# Record logs to file
adb logcat > /tmp/crash.log
# (Launch the crashing app, then Ctrl+C)
# View errors
grep -A 10 "FATAL EXCEPTION" /tmp/crash.logIn a pnpm monorepo, dependencies are hoisted to the root. Metro must be configured to find them.
The apps/mobile/metro.config.js file must include:
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const monorepoRoot = path.resolve(__dirname, '../..');
const config = {
watchFolders: [monorepoRoot],
resolver: {
nodeModulesPaths: [
path.resolve(__dirname, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
],
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);After modification, restart Metro (Ctrl + C then pnpm --filter mobile-financial-app start).
Metro is not running or the emulator can't reach it.
If you used a rebuild command, Metro is started automatically — check the output for
Metro is ready (PID ...). If you used a regular run command, start Metro manually:
# Terminal 1: start Metro
pnpm mobile:start # or pnpm expo:start
# Terminal 2: build + launch
pnpm mobile:android # or pnpm expo:androidIf Metro is running but the emulator still can't connect, forward the port:
adb reverse tcp:8081 tcp:8081Then reload in the emulator: press R twice.
sudo xcode-select -s /Applications/Xcode.app/Contents/DeveloperUse Bundler (installed locally in the project):
cd apps/mobile
bundle install
bundle exec pod install --project-directory=iosInstall Ruby 3.1.x via rbenv (see section 2 above).
Metro is not started. Run in a separate terminal:
pnpm mobile:startThen reload the app in the simulator (Cmd + R).
Gradle 9 requires Java 17 minimum. Install and configure Java 17:
brew install openjdk@17
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
echo 'export JAVA_HOME="/opt/homebrew/opt/openjdk@17"' >> ~/.zshrc
source ~/.zshrcVerify: java -version should display openjdk 17.x.x.
The Android SDK is not in the PATH:
echo 'export ANDROID_HOME=$HOME/Library/Android/sdk' >> ~/.zshrc
echo 'export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools' >> ~/.zshrc
source ~/.zshrcAsyncStorage v3 ships its Android artifact via a local Maven repo, not Maven Central.
The project's android/build.gradle must include:
allprojects {
repositories {
maven {
url = uri(project(":react-native-async-storage_async-storage").file("local_repo"))
}
}
}This is already configured in apps/mobile/android/build.gradle. If you see this error after
a fresh clone or reset, verify the block is present.
In a pnpm monorepo, some React Native dependencies are not installed by default. Add the missing dependencies:
pnpm --filter mobile-financial-app add -D @react-native/gradle-plugin@0.82.1 @react-native/codegen@0.82.1When in doubt, use the rebuild command — it handles all cache layers:
pnpm mobile:rebuild:android # bare RN CLI
pnpm expo:rebuild:android # Expo managed