What You'll Build
You will build a cross-platform health data integration layer that reads step counts, heart rate readings, sleep analysis, and workout data from Apple HealthKit on iOS and Google Fit on Android. The module provides a unified TypeScript API so your app code does not need platform-specific branching. You will also implement background sync, permission management, and data caching.
This is the same integration pattern used in WellAlly to pull patient activity and vitals data into the unified health dashboard.
Prerequisites
- React Native project using Expo SDK 52+ with development builds
- Apple Developer account (for HealthKit capability)
- Google Cloud Console project with Fitness API enabled
- Physical iOS device (HealthKit does not work in Simulator)
- Physical Android device or emulator with Google Play Services
- Expo Application Services (EAS) CLI installed
Tech Stack
| Technology | Version | Purpose |
|---|---|---|
| React Native | 0.76.x (Expo) | Mobile framework |
| expo-health-kit | 1.x | iOS HealthKit access |
| @ovalmoney/react-native-health-kit | 3.x | Alternative HealthKit bridge |
| @kingstinct/react-native-healthkit | 7.x | Full HealthKit types and queries |
| expo-dev-client | 5.x | Development builds for native modules |
| TypeScript | 5.x | Type safety |
| react-native-mmkv | 3.x | Fast local cache |
Architecture Overview
The integration uses a platform abstraction layer. A unified HealthDataService interface is implemented separately for iOS and Android. The factory pattern selects the correct implementation at runtime.
HealthDataProvider (React Context)
├── useHealthData() -- unified hook
├── HealthDataService (interface)
│ ├── iOSHealthKitService -- wraps HealthKit
│ └── AndroidGoogleFitService -- wraps Google Fit
├── PermissionManager
│ ├── requestPermissions()
│ └── checkPermissionStatus()
├── DataSyncEngine
│ ├── fetchDailySummary()
│ ├── fetchTimeSeries()
│ └── writeWorkout()
└── LocalCache (MMKV)
├── getCachedData()
└── setCachedData()
Step-by-Step Implementation
Step 1: Expo Development Build Setup
HealthKit and Google Fit require native modules. You need a development build rather than Expo Go.
# Install EAS CLI if not already installed
npm install -g eas-cli
# Configure the project
eas build:configure
# Install required packages
npx expo install expo-dev-client expo-build-properties
npm install @kingstinct/react-native-healthkit
npm install react-native-google-fit
npm install react-native-mmkv
Add HealthKit capability to your iOS configuration.
// app.json
{
"expo": {
"ios": {
"infoPlist": {
"NSHealthShareUsageDescription": "WellAlly reads your health data to provide personalized health insights and track your wellness journey.",
"NSHealthUpdateUsageDescription": "WellAlly writes workout and health data to help you maintain a complete health record."
}
},
"plugins": [
[
"expo-build-properties",
{
"ios": {
"entitlements": {
"health-kit": true
}
}
}
]
]
}
}
Step 2: Define the Unified Health Data Types
Create shared types that abstract away platform-specific data formats.
// src/types/health-data.ts
export interface HealthPermission {
read: HealthDataType[];
write: HealthDataType[];
}
export type HealthDataType =
| 'steps'
| 'heartRate'
| 'sleepAnalysis'
| 'activeEnergyBurned'
| 'distanceWalkingRunning'
| 'workout'
| 'bloodGlucose'
| 'bloodPressureSystolic'
| 'bloodPressureDiastolic'
| 'bodyMass'
| 'oxygenSaturation';
export interface HealthDataPoint {
value: number;
unit: string;
startDate: string;
endDate: string;
source: 'apple_health' | 'google_fit' | 'manual';
metadata?: Record<string, unknown>;
}
export interface DailyHealthSummary {
date: string;
steps: number;
heartRate: { avg: number; min: number; max: number };
activeCalories: number;
sleepHours: number;
distanceKm: number;
}
export interface WorkoutEntry {
type: WorkoutType;
startDate: string;
endDate: string;
caloriesBurned: number;
distanceKm?: number;
avgHeartRate?: number;
source: string;
}
export type WorkoutType =
| 'running'
| 'walking'
| 'cycling'
| 'swimming'
| 'strength'
| 'yoga'
| 'hiit'
| 'other';
export interface HealthDataQuery {
dataType: HealthDataType;
startDate: Date;
endDate: Date;
limit?: number;
ascending?: boolean;
}
export interface PermissionStatus {
dataType: HealthDataType;
read: 'granted' | 'denied' | 'notDetermined';
write: 'granted' | 'denied' | 'notDetermined';
}
Step 3: Define the Service Interface
// src/services/HealthDataService.ts
import type {
HealthDataPoint,
DailyHealthSummary,
WorkoutEntry,
HealthDataQuery,
PermissionStatus,
HealthDataType,
} from '../types/health-data';
export interface HealthDataService {
isAvailable(): Promise<boolean>;
requestPermissions(readTypes: HealthDataType[], writeTypes: HealthDataType[]): Promise<boolean>;
getPermissionStatus(types: HealthDataType[]): Promise<PermissionStatus[]>;
queryData(query: HealthDataQuery): Promise<HealthDataPoint[]>;
getDailySummary(date: Date): Promise<DailyHealthSummary>;
getDailySummaries(startDate: Date, endDate: Date): Promise<DailyHealthSummary[]>;
writeWorkout(workout: WorkoutEntry): Promise<boolean>;
writeDataPoint(dataType: HealthDataType, value: number, startDate: Date, endDate: Date): Promise<boolean>;
}
Step 4: iOS HealthKit Implementation
// src/services/IOSHealthKitService.ts
import HealthKit, {
HKQuantityTypeIdentifier,
HKCategoryTypeIdentifier,
HKWorkoutType,
HKUnit,
HKSampleSortIdentifier,
} from '@kingstinct/react-native-healthkit';
import type { HealthDataService } from './HealthDataService';
import type {
HealthDataPoint,
DailyHealthSummary,
WorkoutEntry,
HealthDataQuery,
PermissionStatus,
HealthDataType,
} from '../types/health-data';
const dataTypeToHKIdentifier: Record<HealthDataType, string> = {
steps: HKQuantityTypeIdentifier.stepCount,
heartRate: HKQuantityTypeIdentifier.heartRate,
activeEnergyBurned: HKQuantityTypeIdentifier.activeEnergyBurned,
distanceWalkingRunning: HKQuantityTypeIdentifier.distanceWalkingRunning,
bloodGlucose: HKQuantityTypeIdentifier.bloodGlucose,
bloodPressureSystolic: HKQuantityTypeIdentifier.bloodPressureSystolic,
bloodPressureDiastolic: HKQuantityTypeIdentifier.bloodPressureDiastolic,
bodyMass: HKQuantityTypeIdentifier.bodyMass,
oxygenSaturation: HKQuantityTypeIdentifier.oxygenSaturation,
sleepAnalysis: HKCategoryTypeIdentifier.sleepAnalysis,
workout: HKWorkoutType,
};
const dataTypeToHKUnit: Record<HealthDataType, string> = {
steps: HKUnit.count,
heartRate: 'count/min',
activeEnergyBurned: HKUnit.kilocalorie,
distanceWalkingRunning: HKUnit.meter,
bloodGlucose: 'mmol/L',
bloodPressureSystolic: 'mmHg',
bloodPressureDiastolic: 'mmHg',
bodyMass: HKUnit.gram,
oxygenSaturation: 'percent',
sleepAnalysis: HKUnit.minute,
workout: HKUnit.count,
};
export class IOSHealthKitService implements HealthDataService {
async isAvailable(): Promise<boolean> {
return HealthKit.isAvailable();
}
async requestPermissions(readTypes: HealthDataType[], writeTypes: HealthDataType[]): Promise<boolean> {
try {
const readIdentifiers = readTypes
.map((t) => dataTypeToHKIdentifier[t])
.filter(Boolean);
const writeIdentifiers = writeTypes
.map((t) => dataTypeToHKIdentifier[t])
.filter(Boolean);
await HealthKit.requestPermissions(readIdentifiers, writeIdentifiers);
return true;
} catch {
return false;
}
}
async getPermissionStatus(types: HealthDataType[]): Promise<PermissionStatus[]> {
// HealthKit does not expose per-type permission status directly.
// Attempt a zero-range query to detect authorization.
const statuses: PermissionStatus[] = [];
for (const dataType of types) {
const hkIdentifier = dataTypeToHKIdentifier[dataType];
const status: PermissionStatus = {
dataType,
read: 'notDetermined',
write: 'notDetermined',
};
try {
const now = new Date();
const results = await HealthKit.queryQuantitySamples(hkIdentifier, {
from: now,
to: now,
limit: 1,
});
status.read = results.length >= 0 ? 'granted' : 'denied';
} catch {
status.read = 'denied';
}
statuses.push(status);
}
return statuses;
}
async queryData(query: HealthDataQuery): Promise<HealthDataPoint[]> {
const hkIdentifier = dataTypeToHKIdentifier[query.dataType];
if (!hkIdentifier) return [];
try {
const samples = await HealthKit.queryQuantitySamples(hkIdentifier, {
from: query.startDate,
to: query.endDate,
limit: query.limit ?? 1000,
sort: query.ascending !== false
? HKSampleSortIdentifier.startDate
: undefined,
});
return samples.map((sample) => ({
value: sample.quantity,
unit: dataTypeToHKUnit[query.dataType],
startDate: sample.startDate,
endDate: sample.endDate,
source: 'apple_health' as const,
metadata: sample.metadata as Record<string, unknown> | undefined,
}));
} catch {
return [];
}
}
async getDailySummary(date: Date): Promise<DailyHealthSummary> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const [stepsData, heartRateData, caloriesData, distanceData, sleepData] =
await Promise.all([
this.queryData({ dataType: 'steps', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'heartRate', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'activeEnergyBurned', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'distanceWalkingRunning', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'sleepAnalysis', startDate: startOfDay, endDate: endOfDay }),
]);
const totalSteps = stepsData.reduce((sum, d) => sum + d.value, 0);
const hrValues = heartRateData.map((d) => d.value);
const totalCalories = caloriesData.reduce((sum, d) => sum + d.value, 0);
const totalDistance = distanceData.reduce((sum, d) => sum + d.value, 0) / 1000;
const totalSleepMinutes = sleepData.reduce((sum, d) => sum + d.value, 0);
return {
date: startOfDay.toISOString().split('T')[0],
steps: Math.round(totalSteps),
heartRate: {
avg: hrValues.length ? Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length) : 0,
min: hrValues.length ? Math.round(Math.min(...hrValues)) : 0,
max: hrValues.length ? Math.round(Math.max(...hrValues)) : 0,
},
activeCalories: Math.round(totalCalories),
sleepHours: Math.round((totalSleepMinutes / 60) * 10) / 10,
distanceKm: Math.round(totalDistance * 100) / 100,
};
}
async getDailySummaries(startDate: Date, endDate: Date): Promise<DailyHealthSummary[]> {
const summaries: DailyHealthSummary[] = [];
const current = new Date(startDate);
while (current <= endDate) {
const summary = await this.getDailySummary(new Date(current));
summaries.push(summary);
current.setDate(current.getDate() + 1);
}
return summaries;
}
async writeWorkout(workout: WorkoutEntry): Promise<boolean> {
try {
await HealthKit.saveWorkout({
type: this.mapWorkoutType(workout.type),
startDate: workout.startDate,
endDate: workout.endDate,
totalEnergyBurned: workout.caloriesBurned,
totalEnergyBurnedUnit: HKUnit.kilocalorie,
totalDistance: workout.distanceKm,
totalDistanceUnit: HKUnit.kilometer,
});
return true;
} catch {
return false;
}
}
async writeDataPoint(
dataType: HealthDataType,
value: number,
startDate: Date,
endDate: Date
): Promise<boolean> {
const hkIdentifier = dataTypeToHKIdentifier[dataType];
if (!hkIdentifier) return false;
try {
await HealthKit.saveQuantitySample(hkIdentifier, HKUnit.count, value, {
start: startDate,
end: endDate,
});
return true;
} catch {
return false;
}
}
private mapWorkoutType(type: string): string {
const mapping: Record<string, string> = {
running: 'running',
walking: 'walking',
cycling: 'cycling',
swimming: 'swimming',
strength: 'traditionalStrengthTraining',
yoga: 'yoga',
hiit: 'highIntensityIntervalTraining',
other: 'other',
};
return mapping[type] ?? 'other';
}
}
Step 5: Android Google Fit Implementation
// src/services/AndroidGoogleFitService.ts
import GoogleFit from 'react-native-google-fit';
import type { HealthDataService } from './HealthDataService';
import type {
HealthDataPoint,
DailyHealthSummary,
WorkoutEntry,
HealthDataQuery,
PermissionStatus,
HealthDataType,
} from '../types/health-data';
export class AndroidGoogleFitService implements HealthDataService {
async isAvailable(): Promise<boolean> {
try {
return await GoogleFit.isAvailable();
} catch {
return false;
}
}
async requestPermissions(readTypes: HealthDataType[], writeTypes: HealthDataType[]): Promise<boolean> {
try {
const scopes = this.mapTypesToScopes(readTypes);
const result = await GoogleFit.authorize({ scopes });
return result.success;
} catch {
return false;
}
}
async getPermissionStatus(types: HealthDataType[]): Promise<PermissionStatus[]> {
// Google Fit does not expose per-scope permission status.
// Return notDetermined and rely on the query fallback.
return types.map((dataType) => ({
dataType,
read: 'notDetermined' as const,
write: 'notDetermined' as const,
}));
}
async queryData(query: HealthDataQuery): Promise<HealthDataPoint[]> {
const options = {
startDate: query.startDate.toISOString(),
endDate: query.endDate.toISOString(),
};
try {
switch (query.dataType) {
case 'steps': {
const result = await GoogleFit.getDailyStepCountSamples(options);
return result.flatMap((day: any) =>
(day.steps ?? []).map((s: any) => ({
value: s.value ?? s.steps ?? 0,
unit: 'count',
startDate: s.startDate ?? day.startDate,
endDate: s.endDate ?? day.endDate,
source: 'google_fit' as const,
}))
);
}
case 'heartRate': {
const result = await GoogleFit.getHeartRateSamples(options);
return result.map((s: any) => ({
value: s.value,
unit: 'bpm',
startDate: s.startDate,
endDate: s.endDate,
source: 'google_fit' as const,
}));
}
case 'distanceWalkingRunning': {
const result = await GoogleFit.getDailyDistanceSamples(options);
return result.map((s: any) => ({
value: s.distance,
unit: 'meter',
startDate: s.startDate,
endDate: s.endDate,
source: 'google_fit' as const,
}));
}
case 'activeEnergyBurned': {
const result = await GoogleFit.getDailyCalorieSamples(options);
return result.map((s: any) => ({
value: s.calorie,
unit: 'kcal',
startDate: s.startDate,
endDate: s.endDate,
source: 'google_fit' as const,
}));
}
default:
return [];
}
} catch {
return [];
}
}
async getDailySummary(date: Date): Promise<DailyHealthSummary> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const [stepsData, heartRateData, caloriesData, distanceData] = await Promise.all([
this.queryData({ dataType: 'steps', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'heartRate', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'activeEnergyBurned', startDate: startOfDay, endDate: endOfDay }),
this.queryData({ dataType: 'distanceWalkingRunning', startDate: startOfDay, endDate: endOfDay }),
]);
const totalSteps = stepsData.reduce((sum, d) => sum + d.value, 0);
const hrValues = heartRateData.map((d) => d.value);
const totalCalories = caloriesData.reduce((sum, d) => sum + d.value, 0);
const totalDistance = distanceData.reduce((sum, d) => sum + d.value, 0) / 1000;
return {
date: startOfDay.toISOString().split('T')[0],
steps: Math.round(totalSteps),
heartRate: {
avg: hrValues.length ? Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length) : 0,
min: hrValues.length ? Math.round(Math.min(...hrValues)) : 0,
max: hrValues.length ? Math.round(Math.max(...hrValues)) : 0,
},
activeCalories: Math.round(totalCalories),
sleepHours: 0, // Google Fit sleep requires separate Fitness History API
distanceKm: Math.round(totalDistance * 100) / 100,
};
}
async getDailySummaries(startDate: Date, endDate: Date): Promise<DailyHealthSummary[]> {
const summaries: DailyHealthSummary[] = [];
const current = new Date(startDate);
while (current <= endDate) {
summaries.push(await this.getDailySummary(new Date(current)));
current.setDate(current.getDate() + 1);
}
return summaries;
}
async writeWorkout(workout: WorkoutEntry): Promise<boolean> {
try {
await GoogleFit.saveWorkout({
startDate: new Date(workout.startDate).toISOString(),
endDate: new Date(workout.endDate).toISOString(),
sessionName: `WellAlly ${workout.type}`,
description: workout.type,
activityType: this.mapWorkoutType(workout.type),
calories: workout.caloriesBurned,
});
return true;
} catch {
return false;
}
}
async writeDataPoint(): Promise<boolean> {
// Google Fit requires specific insert APIs per data type.
// Implement as needed for your write use cases.
return false;
}
private mapTypesToScopes(types: HealthDataType[]): any[] {
const scopeMap: Partial<Record<HealthDataType, any>> = {
steps: { access: 'read', type: 'step' },
heartRate: { access: 'read', type: 'heartRate' },
activeEnergyBurned: { access: 'read', type: 'calories' },
distanceWalkingRunning: { access: 'read', type: 'distance' },
};
return types.map((t) => scopeMap[t]).filter(Boolean);
}
private mapWorkoutType(type: string): any {
const mapping: Record<string, number> = {
running: 8, // ActivityType.RUNNING
walking: 7,
cycling: 1,
swimming: 82,
strength: 36,
other: 0,
};
return mapping[type] ?? 0;
}
}
Step 6: Platform Factory and React Context
// src/services/HealthServiceFactory.ts
import { Platform } from 'react-native';
import type { HealthDataService } from './HealthDataService';
import { IOSHealthKitService } from './IOSHealthKitService';
import { AndroidGoogleFitService } from './AndroidGoogleFitService';
export function createHealthService(): HealthDataService {
if (Platform.OS === 'ios') {
return new IOSHealthKitService();
}
return new AndroidGoogleFitService();
}
// src/contexts/HealthDataContext.tsx
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import { createHealthService } from '../services/HealthServiceFactory';
import type { HealthDataService, DailyHealthSummary, PermissionStatus, HealthDataType } from '../types/health-data';
import { MMKV } from 'react-native-mmkv';
const cache = new MMKV({ id: 'health-data-cache' });
interface HealthDataContextValue {
service: HealthDataService;
isAvailable: boolean;
permissions: PermissionStatus[];
requestPermissions: (read: HealthDataType[], write: HealthDataType[]) => Promise<boolean>;
getDailySummary: (date: Date) => Promise<DailyHealthSummary>;
loading: boolean;
}
const HealthDataContext = createContext<HealthDataContextValue | null>(null);
export function HealthDataProvider({ children }: { children: React.ReactNode }) {
const serviceRef = useRef<HealthDataService>(createHealthService());
const [isAvailable, setIsAvailable] = useState(false);
const [permissions, setPermissions] = useState<PermissionStatus[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function checkAvailability() {
const available = await serviceRef.current.isAvailable();
setIsAvailable(available);
setLoading(false);
}
checkAvailability();
}, []);
const requestPermissions = async (read: HealthDataType[], write: HealthDataType[]) => {
const granted = await serviceRef.current.requestPermissions(read, write);
if (granted) {
const status = await serviceRef.current.getPermissionStatus(read);
setPermissions(status);
}
return granted;
};
const getDailySummary = async (date: Date): Promise<DailyHealthSummary> => {
const cacheKey = `summary-${date.toISOString().split('T')[0]}`;
const cached = cache.getString(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const summary = await serviceRef.current.getDailySummary(date);
cache.set(cacheKey, JSON.stringify(summary));
// Cache expires after 15 minutes
setTimeout(() => cache.delete(cacheKey), 15 * 60 * 1000);
return summary;
};
return (
<HealthDataContext.Provider
value={{
service: serviceRef.current,
isAvailable,
permissions,
requestPermissions,
getDailySummary,
loading,
}}
>
{children}
</HealthDataContext.Provider>
);
}
export function useHealthData() {
const context = useContext(HealthDataContext);
if (!context) {
throw new Error('useHealthData must be used within a HealthDataProvider');
}
return context;
}
Step 7: Using the Hook in a Screen
// src/screens/HealthDashboardScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { useHealthData } from '../contexts/HealthDataContext';
import type { DailyHealthSummary } from '../types/health-data';
const REQUIRED_READ_TYPES = ['steps', 'heartRate', 'activeEnergyBurned', 'distanceWalkingRunning'] as const;
const REQUIRED_WRITE_TYPES = ['workout'] as const;
export function HealthDashboardScreen() {
const { isAvailable, requestPermissions, getDailySummary, loading } = useHealthData();
const [summary, setSummary] = useState<DailyHealthSummary | null>(null);
const [hasPermissions, setHasPermissions] = useState(false);
useEffect(() => {
if (hasPermissions) {
getDailySummary(new Date()).then(setSummary);
}
}, [hasPermissions]);
const handleRequestPermissions = async () => {
const granted = await requestPermissions(
REQUIRED_READ_TYPES as unknown as any[],
REQUIRED_WRITE_TYPES as unknown as any[]
);
setHasPermissions(granted);
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
}
if (!isAvailable) {
return (
<View style={styles.center}>
<Text style={styles.unavailableText}>
Health data is not available on this device.
</Text>
</View>
);
}
if (!hasPermissions) {
return (
<View style={styles.center}>
<Text style={styles.permissionTitle}>Connect Your Health Data</Text>
<Text style={styles.permissionSubtext}>
WellAlly needs access to read your health data to provide personalized insights.
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={handleRequestPermissions}>
<Text style={styles.permissionButtonText}>Grant Permissions</Text>
</TouchableOpacity>
</View>
);
}
return (
<ScrollView style={styles.container}>
<Text style={styles.header}>Today's Health Summary</Text>
{summary && (
<View style={styles.cardGrid}>
<MetricCard label="Steps" value={summary.steps.toLocaleString()} unit="steps" />
<MetricCard label="Avg Heart Rate" value={`${summary.heartRate.avg}`} unit="bpm" />
<MetricCard label="Calories" value={`${summary.activeCalories}`} unit="kcal" />
<MetricCard label="Distance" value={`${summary.distanceKm}`} unit="km" />
<MetricCard label="Sleep" value={`${summary.sleepHours}`} unit="hours" />
<MetricCard
label="Heart Rate Range"
value={`${summary.heartRate.min}-${summary.heartRate.max}`}
unit="bpm"
/>
</View>
)}
</ScrollView>
);
}
function MetricCard({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>{label}</Text>
<Text style={styles.metricValue}>
{value} <Text style={styles.metricUnit}>{unit}</Text>
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f8fafc', padding: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
header: { fontSize: 22, fontWeight: '700', color: '#0f172a', marginBottom: 16 },
cardGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
metricCard: {
flex: 1,
minWidth: '45%',
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
metricLabel: { fontSize: 13, color: '#64748b', marginBottom: 4 },
metricValue: { fontSize: 24, fontWeight: '700', color: '#0f172a' },
metricUnit: { fontSize: 13, fontWeight: '400', color: '#94a3b8' },
unavailableText: { fontSize: 16, color: '#64748b', textAlign: 'center' },
permissionTitle: { fontSize: 20, fontWeight: '700', color: '#0f172a', marginBottom: 8 },
permissionSubtext: { fontSize: 14, color: '#64748b', textAlign: 'center', marginBottom: 24 },
permissionButton: { backgroundColor: '#3b82f6', paddingHorizontal: 24, paddingVertical: 14, borderRadius: 10 },
permissionButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Best Practices
Permission Handling
- Request permissions in context: Only ask for HealthKit or Google Fit access when the user navigates to a feature that needs it. Do not request all permissions on first launch.
- Explain why you need the data: Show a pre-permission screen explaining what data you read and why before triggering the system permission dialog.
- Handle denial gracefully: If the user denies permission, show them what they are missing and provide a path to Settings to re-enable.
Data Sync
- Use incremental sync: Track the last sync timestamp and only query data from that point forward instead of pulling the entire history every time.
- Cache aggressively with MMKV: Health data does not change for past dates. Cache historical summaries permanently and only refresh today's data.
- Batch API calls: Use
Promise.allfor independent queries to minimize total sync time.
Cross-Platform Consistency
- Normalize units: HealthKit returns distance in meters while Google Fit may vary. Always normalize to a consistent unit in your service layer.
- Handle missing data uniformly: Both platforms may return empty arrays. Your summary logic should default to zero rather than crashing on undefined values.
Common Pitfalls
-
Using Expo Go for testing: HealthKit and Google Fit native modules do not work in Expo Go. You must create a development build using
eas build --profile development. -
Not accounting for timezone differences: HealthKit and Google Fit store dates in UTC. When querying for "today," always calculate the local start and end of day and convert to UTC.
-
Assuming synchronous permission checks: Both platforms require async permission checks. Never block the UI thread waiting for permission status.
-
Forgetting to handle Google Play Services unavailability: Some Android devices, particularly Huawei devices, do not have Google Play Services. Always call
isAvailable()before using Google Fit. -
Not clearing the cache after permission changes: If the user revokes and re-grants permissions, your cached data may be stale. Clear the cache when permissions change.
Deploying to Production
- Submit HealthKit entitlement justification to Apple App Review. Apple requires you to explain why your app needs each HealthKit data type.
- Complete Google Fit OAuth consent screen verification in Google Cloud Console for production access.
- Implement rate limiting on your sync engine to avoid hitting Google Fit API quotas.
- Add E2E encryption for any health data that leaves the device and is sent to your backend.
- Test permission flows on both platforms with fresh installs to ensure the first-run experience works correctly.
Complete Code
Key files in this implementation:
- Shared types:
src/types/health-data.ts - Service interface:
src/services/HealthDataService.ts - iOS implementation:
src/services/IOSHealthKitService.ts - Android implementation:
src/services/AndroidGoogleFitService.ts - Platform factory:
src/services/HealthServiceFactory.ts - React context:
src/contexts/HealthDataContext.tsx - Example screen:
src/screens/HealthDashboardScreen.tsx
Resources
- Apple HealthKit Documentation - Official HealthKit API reference
- Google Fit Android API - Google Fit REST and Android APIs
- @kingstinct/react-native-healthkit - Comprehensive HealthKit bridge
- react-native-google-fit - Google Fit bridge for React Native
- Expo Development Builds - Native module support in Expo