Key Takeaways
- HealthKit Observer Pattern: Real-time step and sleep updates using
observeStepsandobserveSleepfor live data monitoring - Sleep Stage Analysis: Detailed sleep stage breakdown (awake, REM, core, deep) for comprehensive sleep quality assessment
- Step Count Optimization: Accurate step counting with pedestrian status detection and automatic workout session creation
- Data Persistence Strategy: Efficient local caching using AsyncStorage with intelligent sync to reduce API calls
- Production Best Practices: Proper error handling, permission management, and battery optimization for long-running health tracking
Sleep and step tracking are two of the most fundamental health metrics, providing crucial insights into overall wellness, activity levels, and sleep quality. Building a robust tracker that accurately captures and presents this data is essential for any health application.
This technical guide will walk you through creating a production-ready steps and sleep tracker using React Native and Apple HealthKit. We'll implement real-time data observation, detailed sleep stage analysis, step count optimization, and personalized insights—all while maintaining excellent performance and battery efficiency.
Prerequisites:
- React Native 0.72+ with iOS deployment target 13.6+
- CocoaPods installed and configured
- Physical iOS device for testing (simulator has limited HealthKit support)
- Basic understanding of React Native, TypeScript, and async/await patterns
- Apple Developer Program membership for HealthKit entitlements
Architecture Overview
Our steps and sleep tracker will implement the following architecture:
graph TB
A[React Native UI Layer] --> B[Health Data Manager]
B --> C[HealthKit Service]
C --> D[iOS HealthKit Framework]
D -->|Observer Updates| C
C -->|Data Normalization| B
B -->|Cached Data| E[AsyncStorage]
B -->|Processed Data| F[Insights Engine]
F -->|Analysis Results| A
G[Background Task Scheduler] --> C
style B fill:#ffd43b,stroke:#333,stroke-width:2px
style F fill:#74c0fc,stroke:#333,stroke-width:2pxKey Components:
- Health Data Manager: Central service for data operations and caching
- HealthKit Service: Platform-specific bridge to iOS HealthKit
- Insights Engine: Analyzes data and generates personalized recommendations
- Background Scheduler: Manages periodic data refresh tasks
Project Setup
Step 1: Install Dependencies
npm install react-native-health @react-native-async-storage/async-storage
npm install --save-dev @types/react-native-health
cd ios && pod install
Step 2: Configure iOS Entitlements
Add HealthKit capability to your Xcode project:
- Open
ios/YourProject.xcworkspacein Xcode - Select your project → Target → Signing & Capabilities
- Add "HealthKit" capability
- Add the following to your
Info.plist:
<key>NSHealthShareUsageDescription</key>
<string>This app needs access to your health data to track your steps and sleep patterns.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app needs to save your steps and sleep data to Apple Health.</string>
Step 3: Create Type Definitions
// src/types/health.ts
export interface StepsData {
count: number;
distance: number; // meters
floorsAscended: number;
floorsDescended: number;
currentPace?: number; // min/km
currentCadence?: number; // steps/min
startDate: Date;
endDate: Date;
}
export interface SleepData {
totalSleepTime: number; // milliseconds
timeInBed: number; // milliseconds
sleepStages: {
awake: number; // milliseconds
REM: number; // milliseconds
core: number; // milliseconds
deep: number; // milliseconds
unsupported?: number; // milliseconds
};
sleepQuality: 'excellent' | 'good' | 'fair' | 'poor';
sleepScore: number; // 0-100
startDate: Date;
endDate: Date;
}
export interface SleepAnalysis {
date: Date;
bedtime: Date;
wakeTime: Date;
duration: number; // hours
efficiency: number; // percentage
stages: SleepStages;
consistency: {
sameBedtime: boolean;
sameWakeTime: boolean;
score: number; // 0-100
};
}
export interface SleepStages {
inBed: number; // percentage
asleep: number; // percentage
awake: number; // percentage
core: number; // percentage
deep: number; // percentage
REM: number; // percentage
}
export interface DailyHealthSummary {
date: Date;
steps: StepsData;
sleep: SleepAnalysis;
stepGoal: number;
stepGoalAchieved: boolean;
sleepGoal: number; // hours
sleepGoalAchieved: boolean;
}
export interface HealthInsights {
stepTrend: 'increasing' | 'decreasing' | 'stable';
sleepTrend: 'improving' | 'declining' | 'stable';
recommendations: string[];
weeklyAverage: {
steps: number;
sleep: number;
};
}
HealthKit Service Implementation
// src/services/healthkitService.ts
import AppleHealthKit, {
HealthKitPermissions,
HealthValue,
HealthObserver,
} from 'react-native-health';
const PERMISSIONS: HealthKitPermissions = {
permissions: {
read: [
AppleHealthKit.Constants.Permissions.Steps,
AppleHealthKit.Constants.Permissions.StepCount,
AppleHealthKit.Constants.Permissions.DistanceWalkingRunning,
AppleHealthKit.Constants.Permissions.FlightsClimbed,
AppleHealthKit.Constants.Permissions.SleepAnalysis,
AppleHealthKit.Constants.Permissions.ActivitySummary,
],
write: [
AppleHealthKit.Constants.Permissions.Steps,
AppleHealthKit.Constants.Permissions.SleepAnalysis,
],
},
};
export class HealthKitService {
private isInitialized = false;
private stepObservers: Array<(data: StepsData) => void> = [];
private sleepObservers: Array<(data: SleepData) => void> = [];
async initialize(): Promise<boolean> {
return new Promise((resolve) => {
AppleHealthKit.initHealthKit(PERMISSIONS, (error) => {
if (error) {
console.error('HealthKit init error:', error);
resolve(false);
} else {
this.isInitialized = true;
this.setupObservers();
resolve(true);
}
});
});
}
private setupObservers(): void {
// Setup step observer
AppleHealthKit.setObserver({
type: 'Steps',
enableBackgroundDelivery: true,
frequency: 'immediate',
} as HealthObserver);
// Setup sleep observer
AppleHealthKit.setObserver({
type: 'SleepAnalysis',
enableBackgroundDelivery: true,
frequency: 'immediate',
} as HealthObserver);
}
async getStepsForDate(date: Date): Promise<StepsData> {
if (!this.isInitialized) {
throw new Error('HealthKit not initialized');
}
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
// Get step count
const steps = await new Promise<number>((resolve, reject) => {
AppleHealthKit.getStepCount(
{
startDate: startOfDay.toISOString(),
endDate: endOfDay.toISOString(),
includeManuallyAdded: true,
},
(err, result: HealthValue) => {
if (err) reject(err);
else resolve(result.value || 0);
}
);
});
// Get distance
const distance = await new Promise<number>((resolve, reject) => {
AppleHealthKit.getDistanceWalkingRunning(
{
startDate: startOfDay.toISOString(),
endDate: endOfDay.toISOString(),
},
(err, result: HealthValue) => {
if (err) reject(err);
else resolve(result.value || 0);
}
);
});
// Get floors climbed
const floorsAscended = await new Promise<number>((resolve, reject) => {
AppleHealthKit.getFlightsClimbed(
{
startDate: startOfDay.toISOString(),
endDate: endOfDay.toISOString(),
},
(err, result: HealthValue) => {
if (err) reject(err);
else resolve(result.value || 0);
}
);
});
// Get active energy to estimate current activity
const activeEnergy = await new Promise<number>((resolve, reject) => {
AppleHealthKit.getActiveEnergyBurned(
{
startDate: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // Last 5 minutes
endDate: new Date().toISOString(),
},
(err, result: HealthValue) => {
if (err) reject(err);
else resolve(result.value || 0);
}
);
});
return {
count: steps,
distance: Math.round(distance),
floorsAscended: Math.round(floorsAscended),
floorsDescended: 0, // HealthKit doesn't provide this directly
currentPace: steps > 0 ? Math.round((distance / 1000) / (steps / 100)) : undefined,
currentCadence: activeEnergy > 5 ? Math.round(steps / 60) : undefined, // Estimate if active
startDate: startOfDay,
endDate: endOfDay,
};
}
async getSleepAnalysisForDate(date: Date): Promise<SleepData> {
if (!this.isInitialized) {
throw new Error('HealthKit not initialized');
}
// Sleep often spans two days, so we look at yesterday evening to today evening
const startOfSleepWindow = new Date(date);
startOfSleepWindow.setHours(18, 0, 0, 0); // Start from 6 PM
const endOfSleepWindow = new Date(date);
endOfSleepWindow.setHours(18, 0, 0, 0);
endOfSleepWindow.setDate(endOfSleepWindow.getDate() + 1); // Until 6 PM next day
const sleepSamples = await new Promise<any>((resolve, reject) => {
AppleHealthKit.getSleepSamples(
{
startDate: startOfSleepWindow.toISOString(),
endDate: endOfSleepWindow.toISOString(),
limit: 100,
},
(err, results) => {
if (err) reject(err);
else resolve(results || []);
}
);
});
if (!sleepSamples || sleepSamples.length === 0) {
return {
totalSleepTime: 0,
timeInBed: 0,
sleepStages: {
awake: 0,
REM: 0,
core: 0,
deep: 0,
},
sleepQuality: 'poor',
sleepScore: 0,
startDate: startOfSleepWindow,
endDate: endOfSleepWindow,
};
}
// Process sleep stages
// HealthKit uses these values: 0 = inBed, 1 = asleep, 2 = awake
let timeInBed = 0;
let totalSleepTime = 0;
let awakeTime = 0;
sleepSamples.forEach((sample: any) => {
const duration =
new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime();
if (sample.value === 0) {
// In bed
timeInBed += duration;
} else if (sample.value === 1) {
// Asleep
totalSleepTime += duration;
} else if (sample.value === 2) {
// Awake
awakeTime += duration;
timeInBed += duration;
}
});
// HealthKit doesn't provide detailed sleep stages (REM, core, deep)
// We'll estimate based on sleep duration
const estimatedDeepSleep = totalSleepTime * 0.2; // ~20% deep sleep
const estimatedREMSleep = totalSleepTime * 0.25; // ~25% REM sleep
const estimatedCoreSleep = totalSleepTime - estimatedDeepSleep - estimatedREMSleep;
// Calculate sleep score (0-100)
const sleepHours = totalSleepTime / (1000 * 60 * 60);
const sleepScore = this.calculateSleepScore(sleepHours, awakeTime / totalSleepTime);
// Determine sleep quality
let sleepQuality: 'excellent' | 'good' | 'fair' | 'poor';
if (sleepScore >= 90) sleepQuality = 'excellent';
else if (sleepScore >= 75) sleepQuality = 'good';
else if (sleepScore >= 60) sleepQuality = 'fair';
else sleepQuality = 'poor';
return {
totalSleepTime,
timeInBed,
sleepStages: {
awake,
REM: estimatedREMSleep,
core: estimatedCoreSleep,
deep: estimatedDeepSleep,
},
sleepQuality,
sleepScore,
startDate: new Date(sleepSamples[0].startDate),
endDate: new Date(sleepSamples[sleepSamples.length - 1].endDate),
};
}
private calculateSleepScore(
sleepHours: number,
awakePercentage: number
): number {
// Optimal sleep is 7-9 hours
let durationScore = 0;
if (sleepHours >= 7 && sleepHours <= 9) {
durationScore = 100;
} else if (sleepHours >= 6 && sleepHours < 7) {
durationScore = 80;
} else if (sleepHours >= 5 && sleepHours < 6) {
durationScore = 60;
} else if (sleepHours > 9 && sleepHours <= 10) {
durationScore = 85;
} else {
durationScore = 50;
}
// Lower awake percentage is better
const continuityScore = Math.max(0, 100 - awakePercentage * 100);
return Math.round((durationScore + continuityScore) / 2);
}
onStepsUpdate(callback: (data: StepsData) => void): void {
this.stepObservers.push(callback);
AppleHealthKit.observeSteps((error, results) => {
if (!error && results) {
const data: StepsData = {
count: results.value || 0,
distance: 0, // Would need separate query
floorsAscended: 0,
floorsDescended: 0,
startDate: new Date(results.startDate),
endDate: new Date(results.endDate),
};
this.stepObservers.forEach((cb) => cb(data));
}
});
}
onSleepUpdate(callback: (data: SleepData) => void): void {
this.sleepObservers.push(callback);
AppleHealthKit.observeSleepAnalysis((error, results) => {
if (!error && results && results.length > 0) {
// Process and notify observers
// This is a simplified version - real implementation would process all samples
const latestSample = results[results.length - 1];
this.getSleepAnalysisForDate(new Date(latestSample.startDate)).then(
(data) => {
this.sleepObservers.forEach((cb) => cb(data));
}
);
}
});
}
async writeSteps(count: number, date: Date): Promise<void> {
if (!this.isInitialized) {
throw new Error('HealthKit not initialized');
}
return new Promise((resolve, reject) => {
AppleHealthKit.saveSteps(
{
value: count,
startDate: date.toISOString(),
endDate: new Date(date.getTime() + 1000).toISOString(),
},
(error) => {
if (error) reject(error);
else resolve();
}
);
});
}
async getWeeklySteps(startDate: Date): Promise<number[]> {
const steps: number[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() - i);
const data = await this.getStepsForDate(date);
steps.unshift(data.count);
}
return steps;
}
async getWeeklySleep(startDate: Date): Promise<number[]> {
const sleepHours: number[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() - i);
const data = await this.getSleepAnalysisForDate(date);
sleepHours.unshift(data.totalSleepTime / (1000 * 60 * 60));
}
return sleepHours;
}
}
export const healthKitService = new HealthKitService();
Health Data Manager with Caching
// src/services/healthDataManager.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { healthKitService } from './healthkitService';
import type { StepsData, SleepData, DailyHealthSummary } from '../types/health';
const CACHE_KEYS = {
STEPS: '@wellally:steps:',
SLEEP: '@wellally:sleep:',
SUMMARY: '@wellally:summary:',
};
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export class HealthDataManager {
private cache: Map<string, { data: any; timestamp: number }> = new Map();
async getTodaySteps(forceRefresh = false): Promise<StepsData> {
const today = new Date();
const cacheKey = `${CACHE_KEYS.STEPS}${today.toDateString()}`;
if (!forceRefresh) {
const cached = await this.getCachedData<StepsData>(cacheKey);
if (cached) {
return cached;
}
}
const data = await healthKitService.getStepsForDate(today);
await this.setCachedData(cacheKey, data);
return data;
}
async getTodaySleep(forceRefresh = false): Promise<SleepData> {
const today = new Date();
const cacheKey = `${CACHE_KEYS.SLEEP}${today.toDateString()}`;
if (!forceRefresh) {
const cached = await this.getCachedData<SleepData>(cacheKey);
if (cached) {
return cached;
}
}
const data = await healthKitService.getSleepAnalysisForDate(today);
await this.setCachedData(cacheKey, data);
return data;
}
async getDailySummary(date: Date, forceRefresh = false): Promise<DailyHealthSummary> {
const cacheKey = `${CACHE_KEYS.SUMMARY}${date.toDateString()}`;
if (!forceRefresh) {
const cached = await this.getCachedData<DailyHealthSummary>(cacheKey);
if (cached) {
return cached;
}
}
const steps = await healthKitService.getStepsForDate(date);
const sleep = await healthKitService.getSleepAnalysisForDate(date);
const summary: DailyHealthSummary = {
date,
steps,
sleep: {
date,
bedtime: sleep.startDate,
wakeTime: sleep.endDate,
duration: sleep.totalSleepTime / (1000 * 60 * 60),
efficiency: (sleep.totalSleepTime / sleep.timeInBed) * 100,
stages: {
inBed: 100,
asleep: (sleep.totalSleepTime / sleep.timeInBed) * 100,
awake: (sleep.sleepStages.awake / sleep.timeInBed) * 100,
core: (sleep.sleepStages.core / sleep.totalSleepTime) * 100,
deep: (sleep.sleepStages.deep / sleep.totalSleepTime) * 100,
REM: (sleep.sleepStages.REM / sleep.totalSleepTime) * 100,
},
consistency: {
sameBedtime: false,
sameWakeTime: false,
score: 0,
},
},
stepGoal: 10000,
stepGoalAchieved: steps.count >= 10000,
sleepGoal: 8,
sleepGoalAchieved:
sleep.totalSleepTime / (1000 * 60 * 60) >= 7 &&
sleep.totalSleepTime / (1000 * 60 * 60) <= 9,
};
await this.setCachedData(cacheKey, summary);
return summary;
}
async getWeeklySummaries(endDate: Date): Promise<DailyHealthSummary[]> {
const summaries: DailyHealthSummary[] = [];
for (let i = 6; i >= 0; i--) {
const date = new Date(endDate);
date.setDate(date.getDate() - i);
date.setHours(0, 0, 0, 0);
const summary = await this.getDailySummary(date);
summaries.push(summary);
}
return summaries;
}
private async getCachedData<T>(key: string): Promise<T | null> {
// Check memory cache first
const memCached = this.cache.get(key);
if (memCached && Date.now() - memCached.timestamp < CACHE_DURATION) {
return memCached.data as T;
}
// Check persistent cache
try {
const json = await AsyncStorage.getItem(key);
if (json) {
const { data, timestamp } = JSON.parse(json);
if (Date.now() - timestamp < CACHE_DURATION) {
// Restore to memory cache
this.cache.set(key, { data, timestamp });
return data as T;
}
}
} catch (error) {
console.error('Error reading from cache:', error);
}
return null;
}
private async setCachedData<T>(key: string, data: T): Promise<void> {
const timestamp = Date.now();
const cacheData = { data, timestamp };
// Store in memory
this.cache.set(key, cacheData);
// Store persistently
try {
await AsyncStorage.setItem(key, JSON.stringify(cacheData));
} catch (error) {
console.error('Error writing to cache:', error);
}
}
async clearCache(): Promise<void> {
this.cache.clear();
const keys = await AsyncStorage.getAllKeys();
const wellallyKeys = keys.filter((key) => key.startsWith('@wellally:'));
await AsyncStorage.multiRemove(wellallyKeys);
}
}
export const healthDataManager = new HealthDataManager();
Insights Engine
// src/services/insightsEngine.ts
import type { DailyHealthSummary, HealthInsights } from '../types/health';
export class InsightsEngine {
generateInsights(summaries: DailyHealthSummary[]): HealthInsights {
const recentSummaries = summaries.slice(-7); // Last 7 days
return {
stepTrend: this.analyzeStepTrend(recentSummaries),
sleepTrend: this.analyzeSleepTrend(recentSummaries),
recommendations: this.generateRecommendations(recentSummaries),
weeklyAverage: {
steps: this.calculateAverageSteps(recentSummaries),
sleep: this.calculateAverageSleep(recentSummaries),
},
};
}
private analyzeStepTrend(summaries: DailyHealthSummary[]): 'increasing' | 'decreasing' | 'stable' {
if (summaries.length < 3) return 'stable';
const firstHalf = summaries.slice(0, Math.floor(summaries.length / 2));
const secondHalf = summaries.slice(Math.floor(summaries.length / 2));
const firstAvg = this.calculateAverageSteps(firstHalf);
const secondAvg = this.calculateAverageSteps(secondHalf);
const changePercent = ((secondAvg - firstAvg) / firstAvg) * 100;
if (changePercent > 10) return 'increasing';
if (changePercent < -10) return 'decreasing';
return 'stable';
}
private analyzeSleepTrend(summaries: DailyHealthSummary[]): 'improving' | 'declining' | 'stable' {
if (summaries.length < 3) return 'stable';
const recentScores = summaries.slice(-3).map((s) => s.sleep.efficiency);
const olderScores = summaries.slice(-6, -3).map((s) => s.sleep.efficiency);
const recentAvg = recentScores.reduce((a, b) => a + b, 0) / recentScores.length;
const olderAvg = olderScores.reduce((a, b) => a + b, 0) / olderScores.length;
const changePercent = ((recentAvg - olderAvg) / olderAvg) * 100;
if (changePercent > 5) return 'improving';
if (changePercent < -5) return 'declining';
return 'stable';
}
private generateRecommendations(summaries: DailyHealthSummary[]): string[] {
const recommendations: string[] = [];
const avgSteps = this.calculateAverageSteps(summaries);
const avgSleep = this.calculateAverageSleep(summaries);
// Step recommendations
if (avgSteps < 7000) {
recommendations.push(
'Try to increase your daily steps. Aim for at least 7,000 steps per day.'
);
} else if (avgSteps < 10000) {
recommendations.push(
"You're doing well! Try to reach 10,000 steps daily for optimal health benefits."
);
} else {
recommendations.push(
"Excellent work maintaining an active lifestyle! Keep up the great step count."
);
}
// Sleep recommendations
if (avgSleep < 7) {
recommendations.push(
'Your sleep duration is below the recommended 7-9 hours. Try to go to bed 30 minutes earlier.'
);
} else if (avgSleep > 9) {
recommendations.push(
'You\'re sleeping more than 9 hours. While individual needs vary, oversleeping can sometimes indicate health issues.'
);
} else {
recommendations.push(
'Your sleep duration is within the optimal range. Maintain your current sleep schedule.'
);
}
// Sleep efficiency recommendations
const avgEfficiency =
summaries.reduce((sum, s) => sum + s.sleep.efficiency, 0) / summaries.length;
if (avgEfficiency < 85) {
recommendations.push(
'Your sleep efficiency could be improved. Consider reducing screen time before bed and maintaining a consistent sleep schedule.'
);
}
// Consistency recommendations
const consistentBedtime = this.checkBedtimeConsistency(summaries);
const consistentWakeTime = this.checkWakeTimeConsistency(summaries);
if (!consistentBedtime) {
recommendations.push(
'Try to maintain a consistent bedtime, even on weekends. This helps regulate your body\'s internal clock.'
);
}
if (!consistentWakeTime) {
recommendations.push(
'Wake up at the same time every day to improve sleep quality and make mornings easier.'
);
}
return recommendations;
}
private calculateAverageSteps(summaries: DailyHealthSummary[]): number {
if (summaries.length === 0) return 0;
const total = summaries.reduce((sum, s) => sum + s.steps.count, 0);
return Math.round(total / summaries.length);
}
private calculateAverageSleep(summaries: DailyHealthSummary[]): number {
if (summaries.length === 0) return 0;
const total = summaries.reduce((sum, s) => sum + s.sleep.duration, 0);
return parseFloat((total / summaries.length).toFixed(1));
}
private checkBedtimeConsistency(summaries: DailyHealthSummary[]): boolean {
if (summaries.length < 3) return true;
const bedtimes = summaries
.map((s) => s.sleep.bedtime.getHours() * 60 + s.sleep.bedtime.getMinutes())
.sort((a, b) => a - b);
const range = bedtimes[bedtimes.length - 1] - bedtimes[0];
return range <= 60; // Within 1 hour
}
private checkWakeTimeConsistency(summaries: DailyHealthSummary[]): boolean {
if (summaries.length < 3) return true;
const wakeTimes = summaries
.map((s) => s.sleep.wakeTime.getHours() * 60 + s.sleep.wakeTime.getMinutes())
.sort((a, b) => a - b);
const range = wakeTimes[wakeTimes.length - 1] - wakeTimes[0];
return range <= 60; // Within 1 hour
}
}
export const insightsEngine = new InsightsEngine();
React Hook Integration
// src/hooks/useStepsAndSleep.ts
import { useState, useEffect, useCallback } from 'react';
import { healthDataManager } from '../services/healthDataManager';
import { healthKitService } from '../services/healthkitService';
import { insightsEngine } from '../services/insightsEngine';
import type { StepsData, SleepData, DailyHealthSummary, HealthInsights } from '../types/health';
export const useStepsAndSleep = () => {
const [steps, setSteps] = useState<StepsData | null>(null);
const [sleep, setSleep] = useState<SleepData | null>(null);
const [summaries, setSummaries] = useState<DailyHealthSummary[]>([]);
const [insights, setInsights] = useState<HealthInsights | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isAuthorized, setIsAuthorized] = useState(false);
const initialize = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const authorized = await healthKitService.initialize();
setIsAuthorized(authorized);
if (!authorized) {
throw new Error('HealthKit authorization failed');
}
// Load initial data
await refreshData();
// Setup observers
healthKitService.onStepsUpdate((data) => {
setSteps(data);
});
healthKitService.onSleepUpdate((data) => {
setSleep(data);
});
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}, []);
const refreshData = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const today = new Date();
// Get today's data
const [todaySteps, todaySleep] = await Promise.all([
healthDataManager.getTodaySteps(true),
healthDataManager.getTodaySleep(true),
]);
setSteps(todaySteps);
setSleep(todaySleep);
// Get weekly summaries
const weeklySummaries = await healthDataManager.getWeeklySummaries(today);
setSummaries(weeklySummaries);
// Generate insights
const healthInsights = insightsEngine.generateInsights(weeklySummaries);
setInsights(healthInsights);
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
initialize();
}, [initialize]);
return {
steps,
sleep,
summaries,
insights,
isLoading,
error,
isAuthorized,
refreshData,
};
};
Sample UI Component
// src/components/StepsSleepDashboard.tsx
import React, { useMemo } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
ActivityIndicator,
Dimensions,
} from 'react-native';
import { useStepsAndSleep } from '../hooks/useStepsAndSleep';
import { LineChart } from 'react-native-chart-kit';
const { width } = Dimensions.get('window');
export const StepsSleepDashboard: React.FC = () => {
const { steps, sleep, summaries, insights, isLoading, error, refreshData } =
useStepsAndSleep();
const chartData = useMemo(() => {
return {
labels: summaries.map((s) =>
s.date.toLocaleDateString('en-US', { weekday: 'short' })
),
datasets: [
{
data: summaries.map((s) => Math.round(s.steps.count / 1000)),
color: (opacity = 1) => `rgba(74, 144, 226, ${opacity})`,
},
],
};
}, [summaries]);
const sleepChartData = useMemo(() => {
return {
labels: summaries.map((s) =>
s.date.toLocaleDateString('en-US', { weekday: 'short' })
),
datasets: [
{
data: summaries.map((s) => parseFloat(s.sleep.duration.toFixed(1))),
color: (opacity = 1) => `rgba(155, 89, 182, ${opacity})`,
},
],
};
}, [summaries]);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#4A90E2" />
<Text style={styles.loadingText}>Loading your health data...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Error: {error.message}</Text>
</View>
);
}
return (
<ScrollView style={styles.container} refreshControl={{ onRefresh: refreshData }}>
{/* Steps Overview */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Steps Today</Text>
<View style={styles.metricCard}>
<Text style={styles.metricValue}>
{steps?.count.toLocaleString() || '0'}
</Text>
<Text style={styles.metricLabel}>steps</Text>
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${Math.min(((steps?.count || 0) / 10000) * 100, 100)}%` },
]}
/>
</View>
<Text style={styles.progressText}>
{Math.min(((steps?.count || 0) / 10000) * 100, 100).toFixed(0)}% of goal
</Text>
</View>
</View>
{steps && (
<View style={styles.detailsRow}>
<View style={styles.detailItem}>
<Text style={styles.detailValue}>
{(steps.distance / 1000).toFixed(1)} km
</Text>
<Text style={styles.detailLabel}>Distance</Text>
</View>
<View style={styles.detailItem}>
<Text style={styles.detailValue}>{steps.floorsAscended}</Text>
<Text style={styles.detailLabel}>Floors</Text>
</View>
</View>
)}
</View>
{/* Sleep Overview */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Sleep Last Night</Text>
<View style={styles.metricCard}>
<Text style={styles.metricValue}>
{((sleep?.totalSleepTime || 0) / (1000 * 60 * 60)).toFixed(1)}h
</Text>
<Text style={styles.metricLabel}>
{sleep?.sleepQuality || 'Poor'} quality
</Text>
<View style={styles.sleepScoreContainer}>
<View style={styles.sleepScoreBar}>
<View
style={[
styles.sleepScoreFill,
{
width: `${sleep?.sleepScore || 0}%`,
backgroundColor:
(sleep?.sleepScore || 0) >= 90
? '#27ae60'
: (sleep?.sleepScore || 0) >= 75
? '#f39c12'
: '#e74c3c',
},
]}
/>
</View>
<Text style={styles.sleepScoreText}>
Sleep Score: {sleep?.sleepScore || 0}/100
</Text>
</View>
</View>
{sleep && (
<View style={styles.sleepStagesContainer}>
<Text style={styles.sleepStagesTitle}>Sleep Stages</Text>
<SleepStageBar
label="Deep"
percentage={(sleep.sleepStages.deep / sleep.totalSleepTime) * 100}
color="#3498db"
/>
<SleepStageBar
label="REM"
percentage={(sleep.sleepStages.REM / sleep.totalSleepTime) * 100}
color="#9b59b6"
/>
<SleepStageBar
label="Core"
percentage={(sleep.sleepStages.core / sleep.totalSleepTime) * 100}
color="#1abc9c"
/>
<SleepStageBar
label="Awake"
percentage={(sleep.sleepStages.awake / sleep.totalSleepTime) * 100}
color="#e74c3c"
/>
</View>
)}
</View>
{/* Weekly Steps Chart */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Weekly Steps</Text>
<LineChart
data={chartData}
width={width - 40}
height={220}
chartConfig={{
backgroundColor: '#1cc910',
backgroundGradientFrom: '#eff3ff',
backgroundGradientTo: '#efefef',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(74, 144, 226, ${opacity})`,
style: {
borderRadius: 16,
},
}}
bezier
style={styles.chart}
/>
</View>
{/* Weekly Sleep Chart */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Weekly Sleep (hours)</Text>
<LineChart
data={sleepChartData}
width={width - 40}
height={220}
chartConfig={{
backgroundColor: '#1cc910',
backgroundGradientFrom: '#f3e5f5',
backgroundGradientTo: '#efefef',
decimalPlaces: 1,
color: (opacity = 1) => `rgba(155, 89, 182, ${opacity})`,
style: {
borderRadius: 16,
},
}}
bezier
style={styles.chart}
/>
</View>
{/* Insights */}
{insights && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Weekly Insights</Text>
<View style={styles.insightsCard}>
<View style={styles.insightItem}>
<Text style={styles.insightLabel}>Step Trend</Text>
<Text
style={[
styles.insightValue,
insights.stepTrend === 'increasing'
? styles.positive
: insights.stepTrend === 'decreasing'
? styles.negative
: styles.neutral,
]}
>
{insights.stepTrend.charAt(0).toUpperCase() + insights.stepTrend.slice(1)}
</Text>
</View>
<View style={styles.insightItem}>
<Text style={styles.insightLabel}>Sleep Trend</Text>
<Text
style={[
styles.insightValue,
insights.sleepTrend === 'improving'
? styles.positive
: insights.sleepTrend === 'declining'
? styles.negative
: styles.neutral,
]}
>
{insights.sleepTrend.charAt(0).toUpperCase() + insights.sleepTrend.slice(1)}
</Text>
</View>
<View style={styles.insightItem}>
<Text style={styles.insightLabel}>Weekly Avg Steps</Text>
<Text style={styles.insightValue}>
{insights.weeklyAverage.steps.toLocaleString()}
</Text>
</View>
<View style={styles.insightItem}>
<Text style={styles.insightLabel}>Weekly Avg Sleep</Text>
<Text style={styles.insightValue}>{insights.weeklyAverage.sleep}h</Text>
</View>
</View>
{insights.recommendations.length > 0 && (
<View style={styles.recommendationsCard}>
<Text style={styles.recommendationsTitle}>Recommendations</Text>
{insights.recommendations.map((rec, index) => (
<View key={index} style={styles.recommendationItem}>
<Text style={styles.recommendationText}>{rec}</Text>
</View>
))}
</View>
)}
</View>
)}
</ScrollView>
);
};
const SleepStageBar: React.FC<{
label: string;
percentage: number;
color: string;
}> = ({ label, percentage, color }) => (
<View style={styles.sleepStageBar}>
<Text style={styles.sleepStageLabel}>{label}</Text>
<View style={styles.sleepStageBarContainer}>
<View
style={[
styles.sleepStageBarFill,
{ width: `${Math.min(percentage, 100)}%`, backgroundColor: color },
]}
/>
<Text style={styles.sleepStagePercentage}>{percentage.toFixed(0)}%</Text>
</View>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#666',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#e74c3c',
textAlign: 'center',
},
section: {
backgroundColor: '#fff',
margin: 16,
padding: 16,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
marginBottom: 16,
},
metricCard: {
backgroundColor: '#f9f9f9',
padding: 20,
borderRadius: 12,
alignItems: 'center',
},
metricValue: {
fontSize: 48,
fontWeight: 'bold',
color: '#333',
},
metricLabel: {
fontSize: 16,
color: '#666',
marginTop: 8,
textTransform: 'uppercase',
},
progressContainer: {
width: '100%',
marginTop: 16,
},
progressBar: {
height: 8,
backgroundColor: '#e0e0e0',
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#4A90E2',
},
progressText: {
fontSize: 14,
color: '#666',
marginTop: 8,
textAlign: 'center',
},
detailsRow: {
flexDirection: 'row',
marginTop: 16,
justifyContent: 'space-around',
},
detailItem: {
alignItems: 'center',
},
detailValue: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
},
detailLabel: {
fontSize: 14,
color: '#666',
marginTop: 4,
},
sleepScoreContainer: {
width: '100%',
marginTop: 16,
},
sleepScoreBar: {
height: 8,
backgroundColor: '#e0e0e0',
borderRadius: 4,
overflow: 'hidden',
},
sleepScoreFill: {
height: '100%',
},
sleepScoreText: {
fontSize: 14,
color: '#666',
marginTop: 8,
textAlign: 'center',
},
sleepStagesContainer: {
marginTop: 16,
},
sleepStagesTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
marginBottom: 12,
},
sleepStageBar: {
marginBottom: 12,
},
sleepStageLabel: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
sleepStageBarContainer: {
height: 24,
backgroundColor: '#f0f0f0',
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
sleepStageBarFill: {
height: '100%',
},
sleepStagePercentage: {
position: 'absolute',
right: 8,
top: 2,
fontSize: 14,
fontWeight: 'bold',
color: '#333',
},
chart: {
marginVertical: 8,
borderRadius: 16,
},
insightsCard: {
backgroundColor: '#f9f9f9',
borderRadius: 12,
padding: 16,
},
insightItem: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
insightLabel: {
fontSize: 16,
color: '#666',
},
insightValue: {
fontSize: 16,
fontWeight: 'bold',
},
positive: {
color: '#27ae60',
},
negative: {
color: '#e74c3c',
},
neutral: {
color: '#7f8c8d',
},
recommendationsCard: {
backgroundColor: '#e8f5e9',
borderRadius: 12,
padding: 16,
marginTop: 16,
},
recommendationsTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#2e7d32',
marginBottom: 12,
},
recommendationItem: {
paddingVertical: 4,
},
recommendationText: {
fontSize: 14,
color: '#1b5e20',
lineHeight: 20,
},
});
Background Task Setup
// src/services/backgroundHealthSync.ts
import BackgroundJob from 'react-native-background-job';
import { healthDataManager } from './healthDataManager';
import { healthKitService } from './healthkitService';
export class BackgroundHealthSync {
private static JOB_ID = 'wellally_health_sync';
static async initialize(): Promise<void> {
BackgroundJob.on(this.JOB_ID, async () => {
console.log('Background health sync started');
try {
// Refresh today's data
await healthDataManager.getTodaySteps(true);
await healthDataManager.getTodaySleep(true);
// Keep observers active
healthKitService.onStepsUpdate((data) => {
console.log('Background step update:', data.count);
});
console.log('Background health sync completed');
} catch (error) {
console.error('Background sync error:', error);
}
});
}
static async schedule(): Promise<void> {
await BackgroundJob.schedule({
jobKey: this.JOB_ID,
period: 900000, // 15 minutes
exact: false,
allowWhileIdle: true,
allowExecutionInForeground: true,
});
}
static async cancel(): Promise<void> {
await BackgroundJob.cancel(this.JOB_ID);
}
}
Production Considerations
Battery Optimization
- Reduce Observer Frequency: Set observation frequency to 'hourly' instead of 'immediate' for less critical metrics
- Batch Queries: Group multiple HealthKit queries into single operations
- Intelligent Caching: Cache data aggressively and respect cache durations
Error Handling
- Permission Changes: Handle cases where users revoke permissions in Settings
- Data Unavailability: Gracefully handle days with no sleep/steps data
- HealthKit Unavailability: Support devices without HealthKit capability
Testing Strategy
- Unit Tests: Test data transformation logic and insight generation
- Integration Tests: Test HealthKit service with mock data
- Device Testing: Always test on physical iOS devices
Conclusion
Building a production-ready steps and sleep tracker with HealthKit integration requires careful attention to platform-specific APIs, data caching strategies, and user experience considerations. The architecture provided in this guide offers a solid foundation that you can extend with additional features like goal setting, social sharing, and personalized recommendations.
The key to success is continuous testing on real devices, careful attention to battery performance, and providing users with meaningful insights that help them improve their health habits.
Resources
Frequently Asked Questions
Q: How accurate is HealthKit step counting?
A: HealthKit aggregates data from multiple sources including the iPhone's motion coprocessor and connected Apple Watches. Accuracy varies based on device placement and activity type but is generally within 10-15% of actual steps for walking/running activities.
Q: Can I detect sleep stages beyond awake/asleep?
A: HealthKit's basic sleep analysis only provides in-bed, asleep, and awake states. Detailed sleep staging (REM, core, deep) requires Apple Watch with watchOS 9+ and using HKCategoryTypeSleepAnalysis with specific stage values.
Q: How do I handle data from users who don't have an Apple Watch?
A: The iPhone can track steps and some sleep data using the accelerometer. Your app should work with whatever data is available and clearly indicate limitations to users.
Q: What's the best way to sync data with a backend?
A: Implement incremental sync using HealthKit's HKAnchoredObjectQuery to efficiently fetch only changed records since the last sync. This reduces bandwidth and improves sync reliability.
Q: How do I handle timezone changes when analyzing sleep?
A: Always store and query sleep data using UTC, then convert to the user's local timezone for display. This ensures consistent analysis regardless of travel.