React Native Mobile Foundation
Objective
Build React Native mobile application using reason-react-native with Apollo Client, shared types, and Relude functional patterns.
Background
Mobile application shares the same GraphQL API and types as the web app, demonstrating true full-stack type safety with ReasonML.
Tasks
-
Create packages/mobile structure -
Set up Expo with ReasonML compilation -
Configure reason-react-native bindings -
Integrate Apollo Client bindings -
Implement navigation with React Navigation -
Create mobile UI components -
Add mobile-specific features (biometric auth, push notifications) -
Configure Metro bundler for Melange output -
Set up development workflow
Package Structure
packages/mobile/
├── src/
│ ├── components/
│ │ ├── App.re
│ │ ├── Navigation.re
│ │ ├── TaskList.re
│ │ └── TaskItem.re
│ ├── screens/
│ │ ├── LoginScreen.re
│ │ ├── DashboardScreen.re
│ │ └── TaskScreen.re
│ ├── queries/
│ │ └── (shared with web via melange_shared)
│ ├── utils/
│ │ ├── Auth.re
│ │ └── Navigation.re
│ └── Index.re
├── lib/
│ └── dune
├── metro.config.js
├── app.json
├── package.json
└── dune-project
Component Example (Mobile + Relude)
(* src/screens/TaskScreen.re *)
open ReactNative;
open MelangeApollo;
module GetTasks = [%graphql {|
query GetTasks($projectId: ID\!) {
tasks(projectId: $projectId) {
id
title
status
priority
}
}
|}];
module Styles = Style.(StyleSheet.create({
"container": style(~flex=1., ~padding=16.->dp, ()),
"loading": style(~flex=1., ~justifyContent=`center, ~alignItems=`center, ()),
"error": style(~backgroundColor="#ffebee", ~padding=16.->dp, ~borderRadius=8., ()),
"taskItem": style(~backgroundColor="white", ~padding=12.->dp, ~marginVertical=4.->dp, ~borderRadius=8., ()),
}));
[@react.component]
let make = (~projectId) => {
let (result, refetch) = useQuery(GetTasks.make(~projectId, ()));
<View style={Styles.container}>
{result
|> RemoteData.fold(
() =>
<View style={Styles.loading}>
<Text> {React.string("Loading tasks...")} </Text>
</View>,
error =>
<View style={Styles.error}>
<Text>
{error##message
|> Option.getOrElse("An error occurred")
|> React.string}
</Text>
</View>,
data =>
<ScrollView>
{data##tasks
|> Array.map(task =>
<TouchableOpacity
key={task##id}
style={Styles.taskItem}
onPress={() => Navigation.navigate("TaskDetail", ~params={"id": task##id})}>
<Text> {React.string(task##title)} </Text>
<Text> {task##status |> TaskStatus.toString |> React.string} </Text>
</TouchableOpacity>
)
|> React.array}
</ScrollView>
)}
</View>
};
Apollo Setup (Mobile)
(* src/utils/Apollo.re *)
open MelangeApollo;
[@mel.raw "__DEV__"] external isDev: bool = "";
let getApiUrl = () =>
isDev
|> Bool.fold(
() => "https://api.yourdomain.com/graphql",
() => "http://localhost:5000/graphql"
);
let client = createClient({
uri: getApiUrl(),
headers: Auth.getAuthHeaders(),
});
[@react.component]
let make = (~children) => {
<ApolloProvider client>
children
</ApolloProvider>
};
Mobile-Specific Features
(* src/utils/BiometricAuth.re *)
open ReactNative;
[@mel.module "@react-native-async-storage/async-storage"]
external setItem: (string, string) => Js.Promise.t(unit) = "";
[@mel.module "expo-local-authentication"]
external authenticateAsync: unit => Js.Promise.t(Js.t({. "success": bool})) = "";
let authenticateUser = () =>
authenticateAsync()
|> Js.Promise.then_(result =>
result##success
|> Bool.fold(
() => Js.Promise.reject(Js.Exn.raiseError("Authentication failed")),
() => Js.Promise.resolve(())
)
);
Dune Configuration (Relude.Globals opened automatically)
(melange.emit
(target mobile)
(module_systems es6)
(libraries
melange_shared
melange_apollo
reason-react-native
relude)
(preprocess
(pps melange.ppx reason-react-ppx))
(flags (:standard -open Relude.Globals)))
Metro Configuration
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
module.exports = (() => {
const config = getDefaultConfig(__dirname);
// Watch _build directory for Melange output
config.watchFolders = [
path.resolve(__dirname, '_build/default/packages/mobile/src')
];
// Ensure proper module resolution
config.resolver.alias = {
...config.resolver.alias,
'@melange': path.resolve(__dirname, '_build/default/packages')
};
return config;
})();
Development Scripts
{
"scripts": {
"start": "dune build @melange --watch & expo start",
"ios": "expo start --ios",
"android": "expo start --android",
"build": "dune build @melange",
"test": "dune runtest"
}
}
Expo Configuration
{
"expo": {
"name": "Melange MVP",
"slug": "melange-mvp",
"platforms": ["ios", "android"],
"version": "1.0.0",
"main": "_build/default/packages/mobile/src/Index.js",
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.melange.mvp"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FFFFFF"
},
"package": "com.melange.mvp"
}
}
}
Acceptance Criteria
-
App builds and runs on iOS simulator -
App builds and runs on Android emulator -
Apollo integration works -
Navigation between screens works -
Shared types work correctly -
All pattern matching uses Relude functional patterns -
Relude.Globals opened via dune flags (not in source files) -
All external bindings use [@mel.xxx] syntax -
Metro bundler correctly resolves Melange .js output -
Mobile-specific features work -
Hot reload works in development
🟡 Medium
Priority: Important for full-stack demonstration.
Estimated Effort: 4-5 days
Dependencies
CI Validation
-
Build succeeds for both platforms -
Tests pass -
Type checking passes -
Can create development builds -
Melange compilation produces correct .js files