Build a Calorie Tracker Mobile App
Build a cross-platform calorie tracker app with React Native and Expo.
Introduction
30 Second Summary
Every health-conscious person has tried to track their meals at some point. The apps that make this effortless are all built with cross-platform mobile frameworks that let developers ship to Android and iOS from a single codebase.
In this project, you will build a calorie tracker mobile app called "Daily Bite" using React Native and Expo. You'll log meals, visualize daily calorie progress, and persist your data so it survives app restarts.
What You'll Build
A multi-screen mobile app that logs meals with calorie counts, displays daily progress toward a calorie goal, and saves data to local storage on your device.
By the end of this project, you'll have:
- A working Expo app with file-based routing across a home screen and an Add Meal screen.
- A progress bar and dynamic meal list on the home screen that updates in real time as you log food.
- Shared state using React Context and local data persistence using AsyncStorage so meals survive between app restarts.
- Secret Mission: Integrate the free OpenFoodFacts API to search real food products and auto-fill calorie data.
Are there any prerequisites?
This project assumes you have minor JavaScript and React experience. You don't need to be an expert, but you should be comfortable with concepts like components, props, and useState.
Before We Start
Before We Start...
Set Up Your Expo Project
Every mobile app starts as a project scaffold. Expo is a framework built on top of React Native that handles bundling, device communication, and configuration so you can focus on building your app.
In this step, you'll create your Expo project, install a storage library, and launch the app on the Android Emulator to confirm everything is working.
In this step, get ready to:
- Create a new Expo project called daily-bite.
- Install AsyncStorage for local data persistence.
- Launch the app on the Android Emulator and verify hot-reload works.
Create the project and install dependencies
You'll use the Expo CLI to scaffold a new project with all the configuration already wired up.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Expo project by running this command:
npx create-expo-app@latest
What will the CLI ask me?
The CLI will ask you two questions. Here's what to enter:
- Project name: type daily-bite and press Enter.
- SDK version: use the arrow keys to select SDK 54 and press Enter.
Wait for the installation to finish. You'll see a success message when it's done.
Seeing a network or permission error?
- Check your internet connection. The CLI downloads packages from npm.
- On macOS, if you see a permission error, try running sudo npx create-expo-app@latest instead.
Help me troubleshoot this Expo project creation error.
- Navigate into your new project by running:
cd daily-bite
- Install the AsyncStorage library by running:
npx expo install @react-native-async-storage/async-storage
What is AsyncStorage?
AsyncStorage is a key-value storage system for React Native. It works like localStorage in web browsers. You'll use it later to save meal data so it persists between app restarts.
- Run the reset-project script to remove boilerplate and start with a clean slate:
npm run reset-project
- Confirm the script worked by running:
ls src/app
- You should see index.tsx and _layout.tsx in the output. These are the only files you need to start building.
Don't see those files?
- Make sure you ran the command from inside the daily-bite folder. Run pwd to check your current directory.
- If you see more files (like explore.tsx), the reset script may not have run correctly. Try running npm run reset-project again.
Help me fix my reset-project script output.
Start the development server and launch the Android Emulator
With the project scaffolded, you can now open it in your code editor and run it on the emulator.
- Open the project in Cursor by running:
cursor .
Command not found?
If your terminal doesn't recognize cursor, you need to add it to your PATH. Open Cursor manually, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Install 'cursor' command in PATH, and select it. Then restart your terminal and try again.
Help me add the cursor command to my PATH.
- In Cursor, open the integrated terminal by pressing Ctrl+` (backtick key).
- Start the development server by running:
npx expo start
What does this command do?
This starts Metro, the JavaScript bundler for React Native. Metro watches your files and sends updates to the emulator in real time. You'll see a menu of options in the terminal once it's ready.
- Once you see the QR code and menu in the terminal, press a to open the app on the Android Emulator.
- Wait for the emulator to boot and the app to load. You should see the default Expo welcome screen with a green tint and a message about editing src/app/index.tsx.
Emulator not launching?
- Make sure Android Studio is installed and you have at least one virtual device configured. Open Android Studio, go to Virtual Device Manager, and verify a device exists.
- If you see a "No Android SDK found" error, open Android Studio and ensure the Android SDK is installed through Settings > Languages & Frameworks > Android SDK.
- The emulator can take 30-60 seconds to boot the first time. Be patient.
Help me get the Android Emulator working with Expo.
Understand the project structure
Your project uses file-based routing through Expo Router. Every file inside src/app/ becomes a route in your app.
- In Cursor's file explorer (left sidebar), expand the src/app/ folder to see your routes.
What are these files?
- _layout.tsx defines the navigation structure for your app. It wraps all screens and controls how they transition.
- index.tsx is the home screen. It maps to the / route, just like index.html on a website.
Any TypeScript file you add to src/app/ automatically becomes a new screen in your app.
- Test hot-reload by opening src/app/index.tsx in Cursor and changing any visible text (like the welcome message).
- Save the file. The emulator updates instantly without you needing to restart anything.
Why is hot-reload useful?
Hot-reload means every change you save appears on the emulator within a second. You get immediate feedback on your code without waiting for a full rebuild. This makes mobile development feel as fast as editing a web page.
Your development environment is ready. Next up, you'll build the home screen with a calorie progress bar and a meal list.
Build the Home Screen UI
Your Expo project is up and running on the Android Emulator. Now it's time to replace the default screen with something useful.
Every calorie tracker needs a central dashboard. In this step, you'll build the home screen that shows a daily progress bar, a list of logged meals, and a button to add new entries.
In this step, get ready to:
- Create a progress bar that tracks calories consumed vs. a daily goal.
- Build a scrollable meal list with placeholder data.
- Add a floating action button that navigates to an Add Meal screen.
Create the daily progress display
The progress display is the centrepiece of the home screen. It shows how many calories you've eaten and how close you are to your daily goal using a colored bar.
- Open src/app/index.tsx in Cursor.
- Select all the existing code and replace it with the following:
import { View, Text, StyleSheet } from 'react-native';
export default function HomeScreen() {
// Hardcoded placeholder data
const dailyCalories = 350;
const calorieGoal = 2000;
// Calculate progress percentage, capped at 100%
const progress = Math.min(dailyCalories / calorieGoal, 1);
const progressColor = progress >= 1 ? '#e74c3c' : '#27ae60';
return (
<View style={styles.container}>
<View style={styles.progressSection}>
<Text style={styles.calorieText}>{dailyCalories} / {calorieGoal} kcal</Text>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress * 100}%`, backgroundColor: progressColor }]} />
</View>
<Text style={styles.remainingText}>{Math.max(calorieGoal - dailyCalories, 0)} kcal remaining</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 20,
},
progressSection: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
marginBottom: 20,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
calorieText: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 12,
},
progressBar: {
height: 12,
backgroundColor: '#e0e0e0',
borderRadius: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 6,
},
remainingText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginTop: 8,
},
});
What does this code do?
This component uses hardcoded values for now. dailyCalories is set to 350 and calorieGoal is 2000. You'll replace these with real data in a later step.
The progress bar is built from two nested View components. The outer one is a grey track. The inner one is a colored fill whose width is set as a percentage of the goal. If you hit 100% or more, the color turns red to signal you've reached your limit.
- Save index.tsx.
- Check the Android Emulator. You should see a white card showing "350 / 2000 kcal" with a short green bar beneath it and "1650 kcal remaining" below that.
Don't see the progress bar?
- Make sure you saved the file. Hot-reload only triggers on save.
- Check that your dev server is still running in the terminal. If it stopped, restart it with npx expo start and press a.
- If you see a red error screen, look for a typo in your JSX. Mismatched curly braces or missing closing tags are common culprits.
Help me fix my progress bar in React Native
Build the meal list section
Below the progress bar, you need a scrollable list that shows every meal logged today. React Native's FlatList component handles this efficiently, only rendering items that are visible on screen.
- In src/app/index.tsx, find the import line at the top of the file:
import { View, Text, StyleSheet } from 'react-native';
- Replace it with this updated import that adds FlatList:
import { View, Text, FlatList, StyleSheet } from 'react-native';
- Inside the HomeScreen function, find the line that says const calorieGoal = 2000; and add this meals array directly below it:
// Placeholder meals for testing the list
const meals = [
{ id: '1', name: 'Oatmeal', calories: 150 },
{ id: '2', name: 'Chicken Salad', calories: 200 },
];
- Inside the return statement, find the closing </View> of the progressSection. Add this meal list section below it (but still inside the outer container View):
<Text style={styles.sectionTitle}>Today's Meals</Text>
{meals.length === 0 ? (
<Text style={styles.emptyText}>No meals logged yet. Tap + to add your first meal!</Text>
) : (
<FlatList data={meals} renderItem={({ item }) => (
<View style={styles.mealItem}>
<Text style={styles.mealName}>{item.name}</Text>
<Text style={styles.mealCalories}>{item.calories} kcal</Text>
</View>
)} keyExtractor={(item) => item.id} style={styles.mealList} />
)}
- Add these new styles inside your StyleSheet.create() object, after the remainingText style:
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
textAlign: 'center',
marginTop: 40,
},
mealList: {
flex: 1,
},
mealItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
mealName: {
fontSize: 16,
fontWeight: '500',
},
mealCalories: {
fontSize: 16,
color: '#27ae60',
fontWeight: '600',
},
What's happening here?
The conditional {meals.length === 0 ? ... : ...} checks whether the meals array is empty. If it is, the user sees a friendly "No meals logged yet" message. Otherwise, the FlatList renders each meal as a row with the food name on the left and calories on the right.
Every FlatList needs a keyExtractor so React Native can efficiently track which items changed. Here, each meal's id serves as that unique key.
- Save index.tsx and check the emulator.
- You should see "Today's Meals" as a heading, followed by two meal rows: "Oatmeal" (150 kcal) and "Chicken Salad" (200 kcal) in white cards.
Seeing a red error screen?
- Check that you added a comma after the last existing style (before your new sectionTitle style). Missing commas between style properties cause syntax errors.
- Confirm that the meal list JSX is placed between the progress section's closing </View> and the outer container's closing </View>. Nesting them inside the progress section breaks the layout.
Help me fix my FlatList code
Add a floating action button to navigate to Add Meal
A floating action button (FAB) is the standard mobile pattern for a primary action. Yours will sit in the bottom-right corner and link to an Add Meal screen that you'll build in the next step.
- At the top of src/app/index.tsx, find the import line:
import { View, Text, FlatList, StyleSheet } from 'react-native';
- Replace it with this line to add Pressable:
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
- Add this new import line directly below it to bring in Expo Router's Link component:
import { Link } from 'expo-router';
- Inside the return statement, add the FAB just before the final closing </View> of the container (after the meal list's closing )}):
<Link href="/add-meal" asChild>
<Pressable style={styles.fab}>
<Text style={styles.fabText}>+</Text>
</Pressable>
</Link>
- Add these FAB styles inside your StyleSheet.create() object, after the mealCalories style:
fab: {
position: 'absolute',
bottom: 30,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#27ae60',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
fabText: {
fontSize: 28,
color: '#fff',
fontWeight: 'bold',
},
What does asChild do?
The Link component from Expo Router normally wraps its children in a text-based element. Setting asChild passes the navigation behavior directly to the Pressable instead, so you get a fully styled, tappable button that navigates when pressed.
The href="/add-meal" tells the router to look for a file at src/app/add-meal.tsx. That file doesn't exist yet, so tapping the button will show a 404 screen. You'll create it in the next step.
- Save index.tsx and check the emulator.
- You should see a green circular "+" button in the bottom-right corner of the screen.
- Tap the "+" button. You should see a 404 or "unmatched route" screen. This confirms navigation is working correctly.
Don't see the green button?
- Check that the fab style has position: 'absolute'. Without it, the button won't float over the content.
- Make sure the Link and Pressable JSX is placed inside the container View (not outside of it). Absolute positioning only works relative to the nearest parent.
Help me debug my floating action button
✔️ Awesome, I've got everything!
Great. Double check that you've saved src/app/index.tsx and your emulator shows the progress bar, meal list, and green "+" button.
ⓧ I'd like to double check the full code
Here's the complete src/app/index.tsx file as it should look at the end of this step:
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
import { Link } from 'expo-router';
export default function HomeScreen() {
// Hardcoded placeholder data
const dailyCalories = 350;
const calorieGoal = 2000;
// Placeholder meals for testing the list
const meals = [
{ id: '1', name: 'Oatmeal', calories: 150 },
{ id: '2', name: 'Chicken Salad', calories: 200 },
];
// Calculate progress percentage, capped at 100%
const progress = Math.min(dailyCalories / calorieGoal, 1);
const progressColor = progress >= 1 ? '#e74c3c' : '#27ae60';
return (
<View style={styles.container}>
<View style={styles.progressSection}>
<Text style={styles.calorieText}>{dailyCalories} / {calorieGoal} kcal</Text>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress * 100}%`, backgroundColor: progressColor }]} />
</View>
<Text style={styles.remainingText}>{Math.max(calorieGoal - dailyCalories, 0)} kcal remaining</Text>
</View>
<Text style={styles.sectionTitle}>Today's Meals</Text>
{meals.length === 0 ? (
<Text style={styles.emptyText}>No meals logged yet. Tap + to add your first meal!</Text>
) : (
<FlatList data={meals} renderItem={({ item }) => (
<View style={styles.mealItem}>
<Text style={styles.mealName}>{item.name}</Text>
<Text style={styles.mealCalories}>{item.calories} kcal</Text>
</View>
)} keyExtractor={(item) => item.id} style={styles.mealList} />
)}
<Link href="/add-meal" asChild>
<Pressable style={styles.fab}>
<Text style={styles.fabText}>+</Text>
</Pressable>
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 20,
},
progressSection: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
marginBottom: 20,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
calorieText: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 12,
},
progressBar: {
height: 12,
backgroundColor: '#e0e0e0',
borderRadius: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 6,
},
remainingText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginTop: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
textAlign: 'center',
marginTop: 40,
},
mealList: {
flex: 1,
},
mealItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
mealName: {
fontSize: 16,
fontWeight: '500',
},
mealCalories: {
fontSize: 16,
color: '#27ae60',
fontWeight: '600',
},
fab: {
position: 'absolute',
bottom: 30,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#27ae60',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
fabText: {
fontSize: 28,
color: '#fff',
fontWeight: 'bold',
},
});
Your home screen is looking sharp. Next up, you'll create the Add Meal screen so that "+" button actually takes you somewhere useful.
Create the Add Meal Screen
Your home screen looks great, but tapping the "+" button leads nowhere. Every app with a list needs a way to add items to that list.
In this step, you'll build the Add Meal screen. This screen has a form with two inputs, validates user input in real time, and navigates back to the home screen on submission.
In this step, get ready to:
- Create a new route file for the Add Meal screen.
- Build a form with state management and real-time validation.
- Handle form submission and navigate back to the home screen.
Create the add-meal route file
In Expo Router, every file inside the src/app/ directory automatically becomes a route. Creating a file called add-meal.tsx means the app can navigate to /add-meal.
- In the Cursor sidebar, right-click the src/app/ folder.
- Select New File.
- Name it add-meal.tsx and press Enter.
- Paste the following code into the new file:
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { router } from 'expo-router';
export default function AddMealScreen() {
// Track what the user types into each field
const [name, setName] = useState('');
const [calories, setCalories] = useState('');
// Form is valid only when both fields have meaningful values
const isValid = name.trim().length > 0 && Number(calories) > 0;
const handleSubmit = () => {
if (!isValid) return;
// Build a meal object with a unique ID and timestamp
const meal = {
id: Date.now().toString(),
name: name.trim(),
calories: Number(calories),
timestamp: new Date().toISOString(),
};
console.log(meal);
// Return to the home screen
router.back();
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.label}>Food Name</Text>
<TextInput
style={styles.input}
placeholder="e.g. Chicken Salad"
value={name}
onChangeText={setName}
autoFocus
/>
<Text style={styles.label}>Calories</Text>
<TextInput
style={styles.input}
placeholder="e.g. 350"
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
/>
<Pressable
style={[styles.button, !isValid && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={!isValid}
>
<Text style={styles.buttonText}>Log Meal</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
form: {
padding: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
input: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#27ae60',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginTop: 10,
},
buttonDisabled: {
backgroundColor: '#b0b0b0',
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
});
What does this code do?
- KeyboardAvoidingView shifts the form upward when the keyboard opens so inputs don't get hidden behind it.
- Two TextInput components create the food name and calorie fields. Each is "controlled" by a useState hook that keeps the field's value in sync with React state.
- The isValid variable re-evaluates on every keystroke. When both fields pass validation, the button becomes tappable.
- handleSubmit builds a meal object, logs it to the console, then calls router.back() to return to the home screen.
- Save add-meal.tsx.
- In the emulator, tap the "+" button on the home screen. You should see the Add Meal form appear with a "Food Name" label, a "Calories" label, and a grayed-out "Log Meal" button.
Seeing a 404 or blank screen?
- Make sure the file is named exactly add-meal.tsx (not addMeal.tsx or add-meal.ts). The filename must match the route path in the Link component.
- Confirm the file is inside src/app/ and not in a subfolder or the project root.
- Try stopping the dev server with Ctrl+C and restarting with npx expo start. New route files sometimes need a server restart.
Why isn't my Add Meal screen showing up when I tap the + button?
Understand the form state and validation
The form you just created uses a pattern called "controlled components." Each input's displayed value is driven entirely by React state, and every keystroke updates that state.
How does the real-time validation work?
The isValid constant is recalculated every time the component re-renders (which happens on every keystroke). It checks two things: the food name has at least one non-whitespace character, and the calorie value converts to a positive number.
When isValid is false, two things happen. The Pressable button receives the buttonDisabled style (turning it gray), and the disabled prop prevents taps from firing handleSubmit.
Why is the calorie input stored as a string?
TextInput always returns a string, even when keyboardType="numeric" is set. The numeric keyboard only controls which keys appear. You convert to a number with Number(calories) only at validation and submission time.
Test form submission and navigation
The handleSubmit function builds a meal object and logs it to the terminal. In the next step, you'll replace this console.log with a shared state update so the home screen sees the new meal.
- In the emulator, tap the "+" button to navigate to the Add Meal screen.
- Type Banana in the Food Name field.
- Type 105 in the Calories field. The "Log Meal" button should turn green.
- Tap Log Meal.
You should be taken back to the home screen immediately. In your terminal (where the dev server is running), you should see the logged meal object with an id, name, calories, and timestamp.
Button stays gray even after filling both fields?
- Make sure the calorie field contains only digits. Letters or special characters will cause Number(calories) to return NaN, which fails the > 0 check.
- Check that the food name field isn't just spaces. The .trim().length > 0 check filters out whitespace-only input.
Why isn't my Log Meal button becoming active when I fill in both fields?
✔️ Awesome, I've got everything!
Great. Make sure you've saved add-meal.tsx and confirmed the form works in the emulator.
ⓧ I'd like to double check the full code
Here is the complete src/app/add-meal.tsx file:
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { router } from 'expo-router';
export default function AddMealScreen() {
const [name, setName] = useState('');
const [calories, setCalories] = useState('');
const isValid = name.trim().length > 0 && Number(calories) > 0;
const handleSubmit = () => {
if (!isValid) return;
const meal = {
id: Date.now().toString(),
name: name.trim(),
calories: Number(calories),
timestamp: new Date().toISOString(),
};
console.log(meal);
router.back();
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.label}>Food Name</Text>
<TextInput
style={styles.input}
placeholder="e.g. Chicken Salad"
value={name}
onChangeText={setName}
autoFocus
/>
<Text style={styles.label}>Calories</Text>
<TextInput
style={styles.input}
placeholder="e.g. 350"
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
/>
<Pressable
style={[styles.button, !isValid && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={!isValid}
>
<Text style={styles.buttonText}>Log Meal</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
form: {
padding: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
input: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#27ae60',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginTop: 10,
},
buttonDisabled: {
backgroundColor: '#b0b0b0',
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
});
Your Add Meal screen is fully functional. Right now, meals are logged to the console and then lost. In the next step, you'll create a shared state layer with React Context so that meals flow from this screen back to the home screen in real time.
Connect State with React Context
Right now your two screens are completely disconnected. You can fill out the meal form and tap Log Meal, but the home screen has no way of knowing a meal was added. The data just gets logged to the console and vanishes.
To share data between screens, you need a central store that both screens can read from and write to. React Context is React's built-in solution for this. You will create a Context that holds your meals array, then wrap your entire app in it so every screen has access.
In this step, get ready to:
- Create a MealContext with a typed Meal interface and provider component.
- Wrap your app's navigation in the MealProvider.
- Connect both screens to the shared context so adding a meal updates the home screen.
Create the MealContext
The context file defines the shape of your shared state and provides a component that makes that state available to every screen. You will create a new directory and file for this.
- In the Cursor file explorer, create a new folder called context inside the src/ directory.
- Inside that folder, create a new file called MealContext.tsx.
- Add the imports, type definitions, and constants by pasting this code into src/context/MealContext.tsx:
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Define the shape of a single meal
export interface Meal {
id: string;
name: string;
calories: number;
timestamp: string;
}
// Define what the context provides to consumers
interface MealContextType {
meals: Meal[];
addMeal: (name: string, calories: number) => void;
dailyCalories: number;
calorieGoal: number;
isLoading: boolean;
}
// Create the context with undefined as default
const MealContext = createContext<MealContextType | undefined>(undefined);
// Storage key for AsyncStorage (used in the next step)
const STORAGE_KEY = 'daily-bite-meals';
const CALORIE_GOAL = 2000;
What does this code do?
The Meal interface defines the exact shape of every meal object in your app. TypeScript uses this to catch errors before they reach the emulator.
The MealContextType interface describes what every screen receives when it connects to the context: the list of meals, a function to add one, the daily calorie total, and the goal.
The STORAGE_KEY and AsyncStorage import are prepared for the next step where you will add data persistence.
- Below the constants, add the MealProvider component that manages the meals state:
export function MealProvider({ children }: { children: React.ReactNode }) {
const [meals, setMeals] = useState<Meal[]>([]);
const [isLoading, setIsLoading] = useState(true);
const addMeal = (name: string, calories: number) => {
const newMeal: Meal = {
id: Date.now().toString(),
name,
calories,
timestamp: new Date().toISOString(),
};
setMeals((prev) => [newMeal, ...prev]);
};
// Filter to only show today's meals
const todaysMeals = meals.filter((meal) => {
const mealDate = new Date(meal.timestamp).toDateString();
const today = new Date().toDateString();
return mealDate === today;
});
// Sum up today's calories
const dailyCalories = todaysMeals.reduce(
(total, meal) => total + meal.calories,
0
);
return (
<MealContext.Provider
value={{
meals: todaysMeals,
addMeal,
dailyCalories,
calorieGoal: CALORIE_GOAL,
isLoading,
}}
>
{children}
</MealContext.Provider>
);
}
What does this code do?
The MealProvider wraps your app and provides shared meal data to all child components. It holds the meals array in state and exposes an addMeal function that any screen can call.
The todaysMeals filter ensures only meals from today appear on the home screen. The dailyCalories value sums up those meals so the progress bar can calculate the percentage.
The isLoading state starts as true because in the next step you will load saved meals from AsyncStorage on startup. Once loading completes, it flips to false.
- Finally, below the MealProvider function, add the useMeals hook that screens will use to access the context:
export function useMeals() {
const context = useContext(MealContext);
if (!context) {
throw new Error('useMeals must be used within a MealProvider');
}
return context;
}
What does this code do?
The useMeals hook is a convenience wrapper. Instead of importing the raw context and calling useContext(MealContext) on every screen, you call useMeals(). The error guard catches cases where a screen accidentally renders outside the provider.
- Save src/context/MealContext.tsx.
Seeing red squiggles in the editor?
If TypeScript shows errors on the AsyncStorage import, make sure you installed the package back in Step 1 with npx expo install @react-native-async-storage/async-storage. Try restarting your terminal and running the install command again if needed.
help me fix TypeScript errors in MealContext.tsx
Wrap the app in the MealProvider
For the context to be available on every screen, you need to wrap your app's navigation stack inside the MealProvider. The layout file is where this happens.
- Open src/app/_layout.tsx and replace its entire contents with the following:
import { Stack } from 'expo-router';
import { MealProvider } from '../context/MealContext';
export default function RootLayout() {
return (
// Wrap the navigator so all screens can access meal state
<MealProvider>
<Stack>
<Stack.Screen name="index" options={{ title: 'Daily Bite' }} />
<Stack.Screen name="add-meal" options={{ title: 'Add Meal' }} />
</Stack>
</MealProvider>
);
}
What does this code do?
By wrapping the Stack navigator inside MealProvider, every screen rendered by the navigator inherits access to the meal context. Both the home screen and the add-meal screen can now call useMeals() to read or write meal data.
- Save _layout.tsx.
- Check the terminal where your dev server is running. If you see no errors, the provider is connected correctly.
App crashing after saving?
Make sure the import path '../context/MealContext' matches your actual folder structure. The context folder should be inside src/ (at the same level as app/), not inside src/app/.
help me fix a crash in my Expo layout file after adding MealProvider
Connect both screens to the context
Now that the provider wraps your app, each screen can pull data from it. You will update the home screen to read live meal data and update the add-meal screen to push new meals into the shared state.
- Open src/app/index.tsx and replace its entire contents with this updated version:
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useMeals, Meal } from '../context/MealContext';
export default function HomeScreen() {
// Pull live data from the shared context
const { meals, dailyCalories, calorieGoal, isLoading } = useMeals();
// Show loading screen while data loads (wired up in next step)
if (isLoading) {
return (
<View style={styles.container}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
// Calculate progress percentage (capped at 100%)
const progress = Math.min(dailyCalories / calorieGoal, 1);
const progressColor = progress >= 1 ? '#e74c3c' : '#27ae60';
const renderMeal = ({ item }: { item: Meal }) => (
<View style={styles.mealItem}>
<Text style={styles.mealName}>{item.name}</Text>
<Text style={styles.mealCalories}>{item.calories} kcal</Text>
</View>
);
return (
<View style={styles.container}>
<View style={styles.progressSection}>
<Text style={styles.calorieText}>
{dailyCalories} / {calorieGoal} kcal
</Text>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${progress * 100}%`, backgroundColor: progressColor },
]}
/>
</View>
<Text style={styles.remainingText}>
{Math.max(calorieGoal - dailyCalories, 0)} kcal remaining
</Text>
</View>
<Text style={styles.sectionTitle}>Today's Meals</Text>
{meals.length === 0 ? (
<Text style={styles.emptyText}>
No meals logged yet. Tap + to add your first meal!
</Text>
) : (
<FlatList
data={meals}
renderItem={renderMeal}
keyExtractor={(item) => item.id}
style={styles.mealList}
/>
)}
<Link href="/add-meal" asChild>
<Pressable style={styles.fab}>
<Text style={styles.fabText}>+</Text>
</Pressable>
</Link>
</View>
);
}
What changed from the previous version?
The hardcoded meal array is gone. Instead, the home screen calls useMeals() to get the live meals array, dailyCalories total, and calorieGoal directly from the context.
The isLoading guard shows a loading screen while data is being retrieved. This becomes important once you wire up AsyncStorage in the next step.
- Save index.tsx.
- Now open src/app/add-meal.tsx and find the import section at the top of the file:
import { useState } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { router } from 'expo-router';
- Add the useMeals import on a new line below the existing imports, so the imports section looks like this:
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { router } from 'expo-router';
import { useMeals } from '../context/MealContext';
- Inside the AddMealScreen function, find the first line after the opening brace. Add the context hook before the existing useState lines:
const { addMeal } = useMeals();
- Now find the handleSubmit function:
const handleSubmit = () => {
if (!isValid) return;
const meal = { id: Date.now().toString(), name: name.trim(), calories: Number(calories), timestamp: new Date().toISOString() };
console.log(meal);
router.back();
};
- Replace the entire handleSubmit function with this version that calls the context's addMeal instead of logging to the console:
const handleSubmit = () => {
if (!isValid) return;
// Push the new meal into the shared context
addMeal(name.trim(), Number(calories));
router.back();
};
What changed?
Instead of manually constructing a meal object and logging it, the form now calls the addMeal function from the context. The context handles creating the meal object with a unique ID and timestamp, then adds it to the shared array that the home screen reads from.
- Save add-meal.tsx.
- Check the emulator. You should see a "Loading..." message on the home screen. This is expected because the loading state will be wired up in the next step when you add AsyncStorage.
- Tap the + button. The Add Meal screen should open without any errors, confirming the context is connected.
Seeing a blank screen or crash?
Check that your import path in add-meal.tsx is '../context/MealContext' (two dots, going up one level from app/ to src/, then into context/).
If you see "useMeals must be used within a MealProvider", double check that _layout.tsx wraps the Stack inside <MealProvider>.
help me debug a crash after connecting MealContext to my screens
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you changed in this step.
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface Meal {
id: string;
name: string;
calories: number;
timestamp: string;
}
interface MealContextType {
meals: Meal[];
addMeal: (name: string, calories: number) => void;
dailyCalories: number;
calorieGoal: number;
isLoading: boolean;
}
const MealContext = createContext<MealContextType | undefined>(undefined);
const STORAGE_KEY = 'daily-bite-meals';
const CALORIE_GOAL = 2000;
export function MealProvider({ children }: { children: React.ReactNode }) {
const [meals, setMeals] = useState<Meal[]>([]);
const [isLoading, setIsLoading] = useState(true);
const addMeal = (name: string, calories: number) => {
const newMeal: Meal = {
id: Date.now().toString(),
name,
calories,
timestamp: new Date().toISOString(),
};
setMeals((prev) => [newMeal, ...prev]);
};
const todaysMeals = meals.filter((meal) => {
const mealDate = new Date(meal.timestamp).toDateString();
const today = new Date().toDateString();
return mealDate === today;
});
const dailyCalories = todaysMeals.reduce(
(total, meal) => total + meal.calories,
0
);
return (
<MealContext.Provider
value={{
meals: todaysMeals,
addMeal,
dailyCalories,
calorieGoal: CALORIE_GOAL,
isLoading,
}}
>
{children}
</MealContext.Provider>
);
}
export function useMeals() {
const context = useContext(MealContext);
if (!context) {
throw new Error('useMeals must be used within a MealProvider');
}
return context;
}
import { Stack } from 'expo-router';
import { MealProvider } from '../context/MealContext';
export default function RootLayout() {
return (
<MealProvider>
<Stack>
<Stack.Screen name="index" options={{ title: 'Daily Bite' }} />
<Stack.Screen name="add-meal" options={{ title: 'Add Meal' }} />
</Stack>
</MealProvider>
);
}
import { View, Text, FlatList, StyleSheet, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useMeals, Meal } from '../context/MealContext';
export default function HomeScreen() {
const { meals, dailyCalories, calorieGoal, isLoading } = useMeals();
if (isLoading) {
return (
<View style={styles.container}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
const progress = Math.min(dailyCalories / calorieGoal, 1);
const progressColor = progress >= 1 ? '#e74c3c' : '#27ae60';
const renderMeal = ({ item }: { item: Meal }) => (
<View style={styles.mealItem}>
<Text style={styles.mealName}>{item.name}</Text>
<Text style={styles.mealCalories}>{item.calories} kcal</Text>
</View>
);
return (
<View style={styles.container}>
<View style={styles.progressSection}>
<Text style={styles.calorieText}>
{dailyCalories} / {calorieGoal} kcal
</Text>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${progress * 100}%`, backgroundColor: progressColor },
]}
/>
</View>
<Text style={styles.remainingText}>
{Math.max(calorieGoal - dailyCalories, 0)} kcal remaining
</Text>
</View>
<Text style={styles.sectionTitle}>Today's Meals</Text>
{meals.length === 0 ? (
<Text style={styles.emptyText}>
No meals logged yet. Tap + to add your first meal!
</Text>
) : (
<FlatList
data={meals}
renderItem={renderMeal}
keyExtractor={(item) => item.id}
style={styles.mealList}
/>
)}
<Link href="/add-meal" asChild>
<Pressable style={styles.fab}>
<Text style={styles.fabText}>+</Text>
</Pressable>
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 20,
},
loadingText: {
fontSize: 18,
textAlign: 'center',
marginTop: 50,
},
progressSection: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
marginBottom: 20,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
calorieText: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 12,
},
progressBar: {
height: 12,
backgroundColor: '#e0e0e0',
borderRadius: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 6,
},
remainingText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginTop: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
textAlign: 'center',
marginTop: 40,
},
mealList: {
flex: 1,
},
mealItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
},
mealName: {
fontSize: 16,
fontWeight: '500',
},
mealCalories: {
fontSize: 16,
color: '#27ae60',
fontWeight: '600',
},
fab: {
position: 'absolute',
bottom: 30,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#27ae60',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
fabText: {
fontSize: 28,
color: '#fff',
fontWeight: 'bold',
},
});
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { router } from 'expo-router';
import { useMeals } from '../context/MealContext';
export default function AddMealScreen() {
const { addMeal } = useMeals();
const [name, setName] = useState('');
const [calories, setCalories] = useState('');
const isValid = name.trim().length > 0 && Number(calories) > 0;
const handleSubmit = () => {
if (!isValid) return;
addMeal(name.trim(), Number(calories));
router.back();
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.label}>Food Name</Text>
<TextInput
style={styles.input}
placeholder="e.g. Chicken Salad"
value={name}
onChangeText={setName}
autoFocus
/>
<Text style={styles.label}>Calories</Text>
<TextInput
style={styles.input}
placeholder="e.g. 350"
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
/>
<Pressable
style={[styles.button, !isValid && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={!isValid}
>
<Text style={styles.buttonText}>Log Meal</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
form: {
padding: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
input: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#27ae60',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginTop: 10,
},
buttonDisabled: {
backgroundColor: '#b0b0b0',
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
});
Your screens are now talking to each other through a shared context. In the next step, you will wire up AsyncStorage so meals persist between app restarts. Right now data disappears when you reload the app. That changes next.
Persist Data with AsyncStorage
Your app can now log meals and track calories across screens. There is one major problem though: if you close the app and reopen it, all your data vanishes.
Mobile apps need a way to store data locally so it survives restarts. In this step, you will wire up AsyncStorage to save and load meals automatically.
In this step, get ready to:
- Save meals to AsyncStorage whenever the list changes.
- Load saved meals when the app starts up.
- Verify that meals persist between full app restarts.
Save meals to AsyncStorage whenever they change
AsyncStorage works like a simple key-value store on the device. You give it a key (a string name) and a value (a string of data), and it saves it to disk.
- In Cursor, open src/context/MealContext.tsx.
- Find the const [isLoading, setIsLoading] = useState(true); line inside the MealProvider function.
- Add the following code directly below that line to save meals whenever the array changes:
useEffect(() => {
if (!isLoading) {
saveMeals();
}
}, [meals]);
const saveMeals = async () => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(meals));
} catch (error) {
console.error('Failed to save meals:', error);
}
};
What does this code do?
The useEffect watches the meals array. Every time a meal is added or removed, this effect fires and calls saveMeals.
The if (!isLoading) guard prevents saving an empty array before the real data has loaded. Without it, the app would overwrite your saved meals with nothing on startup.
The saveMeals function serializes the meals array to JSON and stores it under the STORAGE_KEY key. The try/catch handles any disk write errors gracefully.
- Save the file. You won't see a visible change yet because the load logic comes next.
Load saved meals when the app starts
Saving data is only half the story. The app also needs to read that saved data back into memory when it launches.
- Still in src/context/MealContext.tsx, add the following code between the setIsLoading line and the save useEffect you just added:
useEffect(() => {
loadMeals();
}, []);
const loadMeals = async () => {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
setMeals(JSON.parse(stored));
}
} catch (error) {
console.error('Failed to load meals:', error);
} finally {
setIsLoading(false);
}
};
What does this code do?
The useEffect(() => { ... }, []) with an empty dependency array runs exactly once when the component mounts. This is the standard React pattern for "do something on startup."
The loadMeals function reads the stored JSON string, parses it back into an array, and puts it into state. The finally block sets isLoading to false regardless of success or failure, so the app always renders.
- Save MealContext.tsx.
- Switch to the Android Emulator. The app should still display your home screen normally.
Seeing a blank screen or error?
- Make sure both useEffect hooks are inside the MealProvider function body, not outside it.
- Check that the loadMeals and saveMeals functions are also inside MealProvider. They need access to the state variables.
- Confirm that the save useEffect has the guard check. Without it, you may be saving an empty array on mount.
Help me debug my AsyncStorage setup in MealContext
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface Meal {
id: string;
name: string;
calories: number;
timestamp: string;
}
interface MealContextType {
meals: Meal[];
addMeal: (name: string, calories: number) => void;
dailyCalories: number;
calorieGoal: number;
isLoading: boolean;
}
const MealContext = createContext<MealContextType | undefined>(undefined);
const STORAGE_KEY = 'daily-bite-meals';
const CALORIE_GOAL = 2000;
export function MealProvider({ children }: { children: React.ReactNode }) {
const [meals, setMeals] = useState<Meal[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadMeals();
}, []);
useEffect(() => {
if (!isLoading) {
saveMeals();
}
}, [meals]);
const loadMeals = async () => {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
setMeals(JSON.parse(stored));
}
} catch (error) {
console.error('Failed to load meals:', error);
} finally {
setIsLoading(false);
}
};
const saveMeals = async () => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(meals));
} catch (error) {
console.error('Failed to save meals:', error);
}
};
const addMeal = (name: string, calories: number) => {
const newMeal: Meal = {
id: Date.now().toString(),
name,
calories,
timestamp: new Date().toISOString(),
};
setMeals((prev) => [newMeal, ...prev]);
};
const todaysMeals = meals.filter((meal) => {
const mealDate = new Date(meal.timestamp).toDateString();
const today = new Date().toDateString();
return mealDate === today;
});
const dailyCalories = todaysMeals.reduce(
(total, meal) => total + meal.calories,
0
);
return (
<MealContext.Provider
value={{
meals: todaysMeals,
addMeal,
dailyCalories,
calorieGoal: CALORIE_GOAL,
isLoading,
}}
>
{children}
</MealContext.Provider>
);
}
export function useMeals() {
const context = useContext(MealContext);
if (!context) {
throw new Error('useMeals must be used within a MealProvider');
}
return context;
}
Verify persistence by restarting the app
The true test of local storage is whether data survives a full restart. Let's kill the dev server and relaunch to confirm.
- In the emulator, tap the + button and log a test meal (any food name and calorie count).
- Confirm the meal appears on the home screen with the progress bar updated.
- Return to your terminal and stop the development server by pressing Ctrl+C.
- Restart the dev server and launch the emulator again by running:
npx expo start
- Press a in the terminal to relaunch on the Android Emulator.
- Confirm that the meal you logged earlier still appears in the list with the correct calorie count.
Why does this prove persistence?
Stopping and restarting the dev server destroys all in-memory state. The only way your meal can reappear is if loadMeals successfully read it back from AsyncStorage on mount.
Meal disappeared after restart?
- Open the console output in your terminal. Look for "Failed to load meals" or "Failed to save meals" error messages.
- Make sure your load useEffect has an empty dependency array []. Without it, the effect may not run on mount.
- Check that both functions use the same STORAGE_KEY constant. If the save writes to one key and load reads from another, the data will appear lost.
Help me figure out why my meals don't persist after restart
Your calorie tracker now remembers everything. Meals persist across restarts, so users can track their daily intake without losing data. Next up, the Secret Mission challenges you to connect to a real food database and auto-fill nutrition data.
Secret mission
Search Real Foods with OpenFoodFacts API
Your calorie tracker works great for manual entry, but what if you could search a database of over 2 million real food products and auto-fill the calorie data? In this secret mission, you'll integrate the free OpenFoodFacts API to add smart food search to your Add Meal screen.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and the Android Emulator. There are no cloud services, paid APIs, or ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The daily-bite project folder on your Desktop.
- The Expo dev server (running process in your terminal).
- App data stored in AsyncStorage on the Android Emulator.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your Daily Bite app.
- Your Expo dev server stays running in the terminal.
- Your meal data persists in the emulator between sessions.
- You can continue editing files in Cursor and see hot-reload updates instantly.
Pause - I'll come back to this later
Stop the running dev server to free up your terminal, but keep your project files for later.
- In the terminal where the Expo dev server is running, press Ctrl+C to stop the server.
- Your daily-bite folder and all your code will still be on your Desktop when you come back.
- To resume later, open the project folder in Cursor and run npx expo start again.
Delete - I don't want to use this again
Remove all project files and app data to start completely fresh.
- In the terminal where the Expo dev server is running, press Ctrl+C to stop the server.
- Delete the project folder by running:
macOS
cd ~/Desktop
rm -rf daily-bite
Windows
cd ~\Desktop
rmdir /s /q daily-bite
- To remove the app and its AsyncStorage data from the Android Emulator, open the emulator, long-press the Daily Bite app icon, and drag it to Uninstall.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete calorie tracker mobile app from scratch using React Native and Expo.
You've learned how to:
- Built a multi-screen mobile app with file-based routing using Expo Router, including a home screen with a progress bar and a form-based Add Meal screen.
- Managed shared state across screens using React Context, so adding a meal on one screen instantly updates the calorie progress on another.
- Persisted data locally with AsyncStorage, ensuring logged meals survive app restarts without any server or database.
- Secret Mission: Integrated the OpenFoodFacts API to search real food products and auto-fill nutrition data with a debounced search.
Ready to quiz yourself?