Build a Location-Aware Photo Journal

Build a React Native photo journal app with GPS tagging and a calendar view.

Introduction

30 Second Summary

Every photo on your phone already knows where it was taken. But what if you could build your own app that organizes photos by location and date automatically?

In this project, you will build a photo journal app using React Native and Expo. You'll capture photos with your phone's camera, auto-tag them with GPS coordinates and human-readable place names, and browse them through a timeline feed and a custom calendar view.

What You'll Build

A multi-screen React Native app with camera capture, GPS location tagging, persistent local storage, and a custom calendar interface that runs directly on your Android phone.

By the end of this project, you'll have:

  • A working React Native app running on your Android phone with tab navigation between Feed, Camera, and Calendar screens.
  • A camera capture flow that auto-tags each photo with GPS coordinates and reverse-geocoded place names.
  • A persistent local store using Zustand and AsyncStorage so journal entries survive app restarts.
  • Secret Mission: A map view with pins showing where each photo was taken.

Are there any prerequisites?

You should be familiar with React basics like components, props, and JSX. This project serves as a refresher for intermediate React patterns applied in a mobile context.

Before We Start

Before We Start...

Scaffold the Expo Project

Every mobile app starts as an empty folder on your computer. Before you can capture photos or tag locations, you need the scaffolding that turns a blank directory into a runnable app on your phone.

In this step, you will create a new Expo project, install all the packages the photo journal needs, and set up tab-based navigation so you can switch between three screens: Feed, Camera, and Calendar.

In this step, get ready to:
  • Create the Expo project and install all dependencies.
  • Configure tab-based navigation with three placeholder screens.
  • Verify the app runs on your Android phone with a working tab bar.
Create the project with Expo SDK 55 and install all dependencies

You will use the create-expo-app CLI tool to generate a fully configured project. This saves you from manually wiring up bundlers, compilers, and navigation libraries.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Expo project by running this command:
npx create-expo-app@latest photo-journal --template default@sdk-55

What does this command do?

npx create-expo-app@latest downloads and runs the latest version of the Expo project generator.

photo-journal is the name of your project folder. The --template default@sdk-55 flag pins the project to Expo SDK 55, which includes React Native 0.83 and file-based routing via Expo Router.

The command takes a minute or two to download dependencies and generate the project files.

  • Once it finishes, move into the project folder by running:
cd photo-journal
  • Install the Expo-managed packages your photo journal needs by running:
npx expo install expo-camera expo-location expo-file-system @react-native-async-storage/async-storage

Why use npx expo install?

npx expo install resolves package versions that are compatible with your SDK version. Using plain npm install for Expo-managed packages could pull incompatible versions.

  • Install the community packages (Zustand for state management and react-native-calendars for the calendar view) by running:
npm install zustand react-native-calendars

Seeing network errors during install?

Make sure you are connected to the internet. If you are behind a corporate proxy, check that your terminal has the proxy environment variables set.

help me fix npm install errors in my Expo project

Now you need to install the Expo Go app on your Android phone so it can run your project.

  • On your Android phone, open the Google Play Store.
  • Search for Expo Go and install it.
  • Make sure your phone and computer are on the same Wi-Fi network.
  • Back in VS Code's terminal, start the development server by running:
npx expo start

A QR code appears in your terminal. This is how your phone connects to the project running on your computer.

  • Open the camera app on your Android phone and point it at the QR code in the terminal.
  • Tap the notification that appears to open the project in Expo Go.

You should see the default Expo template app load on your phone. It takes 10-20 seconds the first time.

App not loading on your phone?

Ensure both your computer and phone are on the same Wi-Fi network. If the QR code does not connect, stop the server with Ctrl+C and restart it with tunnel mode:

npx expo start --tunnel

Tunnel mode is slower but works across different networks.

help me connect my Android phone to my Expo development server

Configure the project structure and file-based routing

The default template comes with demo screens you do not need. You will replace them with three tabs: Feed, Camera, and Calendar. In Expo Router, the file structure inside src/app defines your app's navigation. A folder named (tabs) creates a tab navigator automatically.

  • In VS Code, look at the Explorer sidebar on the left. Expand the src folder, then the app folder inside it.
  • Select all files and folders inside src/app (click the first item, then Shift+click the last item).
  • Right-click the selection and choose Delete to remove all default template content.
  • Right-click the now-empty app folder and select New Folder.
  • Type (tabs) as the folder name and press Enter.

What is a route group?

Parentheses around a folder name like (tabs) tell Expo Router this is a route group. It organizes screens without adding to the URL path. The _layout.tsx file inside it defines how those screens are arranged.

  • Right-click the (tabs) folder and select New File.
  • Name it _layout.tsx and press Enter.
  • Paste the following code into _layout.tsx:
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Feed' }} />
      <Tabs.Screen name="camera" options={{ title: 'Camera' }} />
      <Tabs.Screen name="calendar" options={{ title: 'Calendar' }} />
    </Tabs>
  );
}

What does this code do?

This file defines a tab navigator with three screens. Each Tabs.Screen entry maps a filename (like index) to a tab with a display title (like Feed).

Expo Router reads this layout and renders a bottom tab bar. When the learner taps a tab, it shows the corresponding screen file.

  • Save _layout.tsx.
  • Right-click the (tabs) folder again and create a new file named index.tsx.
  • Paste the following code into index.tsx:
import { View, Text } from 'react-native';

export default function FeedScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Feed</Text>
    </View>
  );
}

What does this code do?

This is a placeholder screen that centers the word "Feed" on the page. The flex: 1 style makes the View fill the entire screen. alignItems and justifyContent center the text both horizontally and vertically.

  • Save index.tsx.
  • Create another new file in (tabs) named camera.tsx.
  • Paste the following code into camera.tsx:
import { View, Text } from 'react-native';

export default function CameraScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Camera</Text>
    </View>
  );
}
  • Save camera.tsx.
  • Create one more file in (tabs) named calendar.tsx.
  • Paste the following code into calendar.tsx:
import { View, Text } from 'react-native';

export default function CalendarScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Calendar</Text>
    </View>
  );
}
  • Save calendar.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files inside src/app/(tabs)/.

ⓧ I'd like to double check the full code

Here is each file you should have inside src/app/(tabs)/:

import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Feed' }} />
      <Tabs.Screen name="camera" options={{ title: 'Camera' }} />
      <Tabs.Screen name="calendar" options={{ title: 'Calendar' }} />
    </Tabs>
  );
}
import { View, Text } from 'react-native';

export default function FeedScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Feed</Text>
    </View>
  );
}
import { View, Text } from 'react-native';

export default function CameraScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Camera</Text>
    </View>
  );
}
import { View, Text } from 'react-native';

export default function CalendarScreen() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Calendar</Text>
    </View>
  );
}
Verify the app loads with three navigable tabs on your phone

Because the dev server is still running, Expo Go automatically picks up your file changes and reloads the app.

  • Look at your phone. The app should reload and display a bottom tab bar with three tabs: Feed, Camera, and Calendar.
  • Tap each tab to confirm you can switch between them. Each screen shows its name centered on the page.

Not seeing the tab bar?

Make sure all four files are saved inside src/app/(tabs)/ and not in a different folder. The folder must be named exactly (tabs) with parentheses.

If the app shows an error screen, read the error message on your phone. A common cause is a typo in a filename or a missing import statement.

help me fix my Expo Router tab navigation that is not rendering

Your project is scaffolded and running on your phone. Next up, you will build the camera screen so you can capture photos tagged with your GPS location.

Build the Camera Screen with Location Tagging

In the last step, you set up the Expo project, installed all your packages, and got the app running on your phone with three tabs.

Now it's time to build the core feature: a camera screen that snaps a photo and automatically tags it with your GPS location. This is where your app starts feeling like a real native mobile experience.

In this step, get ready to:
  • Build a camera screen with permission handling and a live preview.
  • Capture a photo and grab GPS coordinates simultaneously.
  • Save the photo to persistent app storage and log a journal entry to the console.
Build the camera screen with permission handling

Mobile apps need explicit user permission before accessing sensitive hardware like the camera or GPS. Your camera screen handles both of these permission flows gracefully.

  • In VS Code, open src/app/(tabs)/camera.tsx.
  • Select all the existing placeholder code and replace it with the following:
import { useRef, useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as FileSystem from 'expo-file-system';

export default function CameraScreen() {
  // Permission state for camera access
  const [permission, requestPermission] = useCameraPermissions();
  // Track whether location permission has been granted
  const [locationPermission, setLocationPermission] = useState(false);
  // Ref to access the camera's imperative methods (like takePictureAsync)
  const cameraRef = useRef(null);

  // Request location permission when the screen loads
  useEffect(() => {
    (async () => {
      const { status } = await Location.requestForegroundPermissionsAsync();
      setLocationPermission(status === 'granted');
    })();
  }, []);

  // While permission state is loading, render nothing
  if (!permission) return <View />;

  // If camera permission not granted, show a request button
  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text>Camera permission is required.</Text>
        <TouchableOpacity onPress={requestPermission}>
          <Text>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  // Capture a photo, get GPS coordinates, reverse geocode, and save
  const handleCapture = async () => {
    if (!cameraRef.current) return;
    try {
      // Take the photo
      const photo = await cameraRef.current.takePictureAsync();
      // Get current GPS coordinates
      const location = await Location.getCurrentPositionAsync({});
      // Convert coordinates to a human-readable place name
      const geocode = await Location.reverseGeocodeAsync({
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
      });
      const place = geocode[0];
      const placeName = [place.city, place.street].filter(Boolean).join(', ');
      // Copy photo from temporary cache to permanent app storage
      const fileName = `photo_${Date.now()}.jpg`;
      const destUri = FileSystem.documentDirectory + fileName;
      await FileSystem.copyAsync({ from: photo.uri, to: destUri });
      // Build the journal entry object
      const entry = {
        id: Date.now().toString(),
        uri: destUri,
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
        placeName,
        timestamp: new Date().toISOString(),
      };
      console.log('Journal entry:', entry);
    } catch (e) {
      Alert.alert('Error', 'Failed to capture photo or location.');
    }
  };

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef}>
        <View style={styles.buttonContainer}>
          <TouchableOpacity style={styles.captureButton} onPress={handleCapture} />
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  buttonContainer: { flex: 1, justifyContent: 'flex-end', alignItems: 'center', marginBottom: 32 },
  captureButton: { width: 70, height: 70, borderRadius: 35, backgroundColor: 'white', borderWidth: 4, borderColor: '#ccc' },
});

What does this code do?

This file handles the entire camera-to-journal pipeline. Here's how the key pieces work:

  • useCameraPermissions() is a hook from expo-camera that checks whether the app has camera access and provides a function to request it.
  • useRef(null) creates a reference to the camera component so you can call imperative methods like takePictureAsync() on it.
  • The useEffect runs once on mount to request location permission upfront, so there's no delay when the learner taps the capture button.
  • The conditional returns handle three states: loading (empty view), permission denied (request button), and permission granted (camera preview).
  • Save camera.tsx.
  • Switch to the Camera tab on your phone. Your phone will prompt you to allow camera access and location access.
  • Tap Grant Permission if you see the permission request screen, then allow both prompts.
  • You should see a live camera preview filling the screen with a white circular button at the bottom.

Camera preview not showing?

  • Make sure you tapped Allow on both the camera and location permission dialogs. If you accidentally denied one, uninstall the app from your phone and re-scan the QR code to reset permissions.
  • Confirm your phone is still connected to the same Wi-Fi network as your computer. The app must reach the Metro bundler to load the updated code.
  • Check the terminal running npx expo start for any red error messages.

Help me debug why my camera preview isn't rendering in Expo Go.

Capture a photo and grab GPS coordinates

The handleCapture function orchestrates three async operations in sequence: taking the photo, fetching your location, and converting coordinates into a readable place name.

How does the capture flow work?

  • cameraRef.current.takePictureAsync() snaps a photo and saves it to a temporary cache directory. It returns an object with a uri pointing to that cached file.
  • Location.getCurrentPositionAsync({}) reads the device's GPS sensor and returns a LocationObject with coords.latitude and coords.longitude.
  • Location.reverseGeocodeAsync() takes those coordinates and returns a postal address. The code extracts city and street to build a human-readable placeName.

These three calls run in sequence because each depends on the previous result. The try/catch block handles failures gracefully with an alert.

What is reverse geocoding?

Geocoding converts a street address into coordinates. Reverse geocoding does the opposite: it takes latitude and longitude numbers and translates them into a human-readable location like "Melbourne, Collins St".

Save the photo to persistent app storage

Photos taken with takePictureAsync land in a temporary cache that can be cleared at any time. To keep them permanently, the code copies each photo to the app's document directory.

Why copy the photo to a different directory?

The cache directory is designed for disposable files. The OS can delete cached files when storage runs low. FileSystem.documentDirectory is the app's permanent storage area that persists across restarts and won't be cleared automatically.

After copying, the code constructs a journal entry object with six fields: id, uri, latitude, longitude, placeName, and timestamp. This object is what gets stored in the journal later.

  • On your phone, tap the white capture button at the bottom of the camera screen.
  • Switch to the terminal where npx expo start is running. You should see a log line starting with Journal entry: followed by an object containing a valid uri, numeric latitude and longitude values, and a placeName string.

Where do I see console.log output?

When you run npx expo start and connect a device, console logs show up in the terminal process. The logs are sent from the runtime to Expo CLI over WebSockets.

Not seeing the journal entry log?

  • Make sure you allowed location access when prompted. Without location permission, getCurrentPositionAsync throws an error and the alert appears instead of the log.
  • Check that your phone has GPS enabled (location services turned on in your device settings).
  • If you see the Error alert on your phone, check the terminal for a more detailed error message above the alert log.

Help me debug why my journal entry isn't logging to the console.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the log output looks correct.

ⓧ I'd like to double check the full code

import { useRef, useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as FileSystem from 'expo-file-system';

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const [locationPermission, setLocationPermission] = useState(false);
  const cameraRef = useRef(null);

  useEffect(() => {
    (async () => {
      const { status } = await Location.requestForegroundPermissionsAsync();
      setLocationPermission(status === 'granted');
    })();
  }, []);

  if (!permission) return <View />;

  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text>Camera permission is required.</Text>
        <TouchableOpacity onPress={requestPermission}>
          <Text>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  const handleCapture = async () => {
    if (!cameraRef.current) return;
    try {
      const photo = await cameraRef.current.takePictureAsync();
      const location = await Location.getCurrentPositionAsync({});
      const geocode = await Location.reverseGeocodeAsync({
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
      });
      const place = geocode[0];
      const placeName = [place.city, place.street].filter(Boolean).join(', ');
      const fileName = `photo_${Date.now()}.jpg`;
      const destUri = FileSystem.documentDirectory + fileName;
      await FileSystem.copyAsync({ from: photo.uri, to: destUri });
      const entry = {
        id: Date.now().toString(),
        uri: destUri,
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
        placeName,
        timestamp: new Date().toISOString(),
      };
      console.log('Journal entry:', entry);
    } catch (e) {
      Alert.alert('Error', 'Failed to capture photo or location.');
    }
  };

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef}>
        <View style={styles.buttonContainer}>
          <TouchableOpacity style={styles.captureButton} onPress={handleCapture} />
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  buttonContainer: { flex: 1, justifyContent: 'flex-end', alignItems: 'center', marginBottom: 32 },
  captureButton: { width: 70, height: 70, borderRadius: 35, backgroundColor: 'white', borderWidth: 4, borderColor: '#ccc' },
});

Your camera screen now captures a photo, tags it with GPS coordinates and a place name, and saves it permanently. Next up, you'll wire this into a global state store so the Feed and Calendar tabs can display your journal entries.

Set Up State Management and the Feed Screen

Your camera screen captures photos, grabs GPS coordinates, and logs a journal entry object to the console. That's a solid pipeline, but the data vanishes the moment you close the app.

In this step, you'll create a global state store that persists entries to disk. You'll also build a scrollable Feed screen so your photos show up in a timeline the moment they're captured.

In this step, get ready to:
  • Create a Zustand store with persist middleware for saving journal entries.
  • Wire the camera screen to save entries to the store instead of just logging them.
  • Build an optimized Feed screen with a memoized FlatList.
Create the Zustand store with persist middleware

Right now each journal entry lives only in a console.log call. You need a single source of truth that every screen can read from and that survives app restarts.

What is Zustand?

Zustand is a lightweight state management library for React. It gives you a global store that any component can subscribe to via a hook, with zero boilerplate.

Its persist middleware automatically serializes the store to AsyncStorage on every change. When the app reopens, it rehydrates the state from disk.

  • Create a store folder inside src by running this command from the photo-journal project folder:
mkdir src/store
  • Create a new file called useJournalStore.ts inside src/store and paste in the following code:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Define the shape of a journal entry
export interface JournalEntry {
  id: string;
  uri: string;
  latitude: number;
  longitude: number;
  placeName: string;
  timestamp: string;
}

// Define the store's state and actions
interface JournalStore {
  entries: JournalEntry[];
  addEntry: (entry: JournalEntry) => void;
  getEntriesByDate: (dateString: string) => JournalEntry[];
}

// Create the store with persist middleware for local storage
export const useJournalStore = create<JournalStore>()(
  persist(
    (set, get) => ({
      entries: [],
      addEntry: (entry) => set((state) => ({ entries: [entry, ...state.entries] })),
      getEntriesByDate: (dateString) =>
        get().entries.filter((e) => e.timestamp.startsWith(dateString)),
    }),
    {
      name: 'journal-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

What does this code do?

  • JournalEntry is a TypeScript interface that defines the exact shape of each photo entry (ID, file URI, coordinates, place name, and timestamp).
  • addEntry prepends a new entry to the front of the array so the most recent photo always appears first.
  • getEntriesByDate filters entries whose timestamp starts with a date string (e.g. 2026-07-07). You'll use this in the Calendar step.
  • The persist wrapper with createJSONStorage(() => AsyncStorage) saves the entire store to the device's local storage after every state change.
  • Save useJournalStore.ts.
  • Check your terminal where npx expo start is running. You should see no red errors in the console output.

Seeing a module import error?

  • Make sure the file is saved at exactly src/store/useJournalStore.ts. The folder must be store (singular, not plural).
  • Confirm you installed zustand and @react-native-async-storage/async-storage in Step 1. If not, run npm install zustand and npx expo install @react-native-async-storage/async-storage.

Help me debug a Zustand store import error in my Expo project

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

export interface JournalEntry {
  id: string;
  uri: string;
  latitude: number;
  longitude: number;
  placeName: string;
  timestamp: string;
}

interface JournalStore {
  entries: JournalEntry[];
  addEntry: (entry: JournalEntry) => void;
  getEntriesByDate: (dateString: string) => JournalEntry[];
}

export const useJournalStore = create<JournalStore>()(
  persist(
    (set, get) => ({
      entries: [],
      addEntry: (entry) => set((state) => ({ entries: [entry, ...state.entries] })),
      getEntriesByDate: (dateString) =>
        get().entries.filter((e) => e.timestamp.startsWith(dateString)),
    }),
    {
      name: 'journal-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);
Wire the camera screen to save entries to the store

The store is ready but nothing writes to it yet. You'll connect the camera screen so that every captured photo is persisted to the Zustand store instead of just logged to the console.

  • Open src/app/(tabs)/camera.tsx in VS Code.
  • Find this import line near the top of the file:
import * as FileSystem from 'expo-file-system';
  • Add this new import directly below it:
import { useJournalStore } from '../../store/useJournalStore';
  • Now find this line inside the component function:
const cameraRef = useRef(null);
  • Add this line directly below it to pull the addEntry action from the store:
const addEntry = useJournalStore((state) => state.addEntry);
  • Scroll down to the handleCapture function and find this line:
      console.log('Journal entry:', entry);
  • Replace that line with these two lines to save the entry and show a confirmation:
      addEntry(entry);
      Alert.alert('Saved!', 'Your photo has been added to your journal.');

What's happening here?

The selector pattern useJournalStore((state) => state.addEntry) subscribes the component only to the addEntry action. The component won't re-render when other parts of the store change (like entries being added).

Alert.alert shows a native Android dialog confirming the save. This gives the learner immediate visual feedback that the pipeline worked.

  • Save camera.tsx.
  • On your phone, switch to the Camera tab and take a photo.
  • You should see an alert dialog that says "Saved!" with the message "Your photo has been added to your journal."

Not seeing the alert?

  • Check that the import path uses '../../store/useJournalStore' with two levels of ../. The camera file is two folders deep inside src/app/(tabs)/.
  • Confirm you placed addEntry(entry) inside the try block, after the entry object is created. If it's outside the try, the variable won't exist.

Help me debug why addEntry isn't working in my camera screen

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import { useRef, useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as FileSystem from 'expo-file-system';
import { useJournalStore } from '../../store/useJournalStore';

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const [locationPermission, setLocationPermission] = useState(false);
  const cameraRef = useRef(null);
  const addEntry = useJournalStore((state) => state.addEntry);

  useEffect(() => {
    (async () => {
      const { status } = await Location.requestForegroundPermissionsAsync();
      setLocationPermission(status === 'granted');
    })();
  }, []);

  if (!permission) return <View />;

  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text>Camera permission is required.</Text>
        <TouchableOpacity onPress={requestPermission}>
          <Text>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  const handleCapture = async () => {
    if (!cameraRef.current) return;
    try {
      const photo = await cameraRef.current.takePictureAsync();
      const location = await Location.getCurrentPositionAsync({});
      const geocode = await Location.reverseGeocodeAsync({
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
      });
      const place = geocode[0];
      const placeName = [place.city, place.street].filter(Boolean).join(', ');
      const fileName = `photo_${Date.now()}.jpg`;
      const destUri = FileSystem.documentDirectory + fileName;
      await FileSystem.copyAsync({ from: photo.uri, to: destUri });
      const entry = {
        id: Date.now().toString(),
        uri: destUri,
        latitude: location.coords.latitude,
        longitude: location.coords.longitude,
        placeName,
        timestamp: new Date().toISOString(),
      };
      addEntry(entry);
      Alert.alert('Saved!', 'Your photo has been added to your journal.');
    } catch (e) {
      Alert.alert('Error', 'Failed to capture photo or location.');
    }
  };

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef}>
        <View style={styles.buttonContainer}>
          <TouchableOpacity style={styles.captureButton} onPress={handleCapture} />
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  buttonContainer: { flex: 1, justifyContent: 'flex-end', alignItems: 'center', marginBottom: 32 },
  captureButton: { width: 70, height: 70, borderRadius: 35, backgroundColor: 'white', borderWidth: 4, borderColor: '#ccc' },
});
Build the Feed screen with an optimized FlatList

Entries are now persisted, but the Feed tab still shows placeholder text. You'll build a scrollable timeline that renders each journal entry as a card with a photo thumbnail, place name, and date.

Why React.memo and useCallback?

FlatList renders items lazily as you scroll. Without memoization, every card re-renders whenever any state changes. React.memo tells React to skip re-rendering a card if its props haven't changed.

useCallback prevents the renderItem function from being recreated on every render. Together they keep scrolling smooth even with hundreds of entries.

  • Create a components folder inside src by running:
mkdir src/components
  • Create a new file called JournalCard.tsx inside src/components and paste in this code:
import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import { JournalEntry } from '../store/useJournalStore';

interface Props {
  entry: JournalEntry;
}

// React.memo prevents re-renders when the entry prop hasn't changed
const JournalCard = React.memo(({ entry }: Props) => {
  return (
    <View style={styles.card}>
      <Image source={{ uri: entry.uri }} style={styles.thumbnail} />
      <View style={styles.info}>
        <Text style={styles.place}>{entry.placeName}</Text>
        <Text style={styles.date}>{new Date(entry.timestamp).toLocaleDateString()}</Text>
      </View>
    </View>
  );
});

export default JournalCard;

const styles = StyleSheet.create({
  card: { flexDirection: 'row', padding: 12, borderBottomWidth: 1, borderColor: '#eee' },
  thumbnail: { width: 64, height: 64, borderRadius: 8 },
  info: { marginLeft: 12, justifyContent: 'center' },
  place: { fontSize: 16, fontWeight: '600' },
  date: { fontSize: 13, color: '#888', marginTop: 4 },
});

What does this component do?

  • JournalCard displays one row in the feed: a 64x64 photo thumbnail on the left, with the place name and formatted date on the right.
  • React.memo wraps the component so it only re-renders when its entry prop changes. This is critical for smooth scrolling performance in long lists.
  • The JournalEntry type is imported directly from the store file. This ensures the card and the store always agree on the data shape.
  • Save JournalCard.tsx.

Seeing a TypeScript error on the import?

  • The import path is '../store/useJournalStore' (one level up). The components folder sits directly inside src/, just like the store folder.
  • Make sure you exported the JournalEntry interface with the export keyword in your store file.

Help me fix a TypeScript import error in my JournalCard component

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import { JournalEntry } from '../store/useJournalStore';

interface Props {
  entry: JournalEntry;
}

const JournalCard = React.memo(({ entry }: Props) => {
  return (
    <View style={styles.card}>
      <Image source={{ uri: entry.uri }} style={styles.thumbnail} />
      <View style={styles.info}>
        <Text style={styles.place}>{entry.placeName}</Text>
        <Text style={styles.date}>{new Date(entry.timestamp).toLocaleDateString()}</Text>
      </View>
    </View>
  );
});

export default JournalCard;

const styles = StyleSheet.create({
  card: { flexDirection: 'row', padding: 12, borderBottomWidth: 1, borderColor: '#eee' },
  thumbnail: { width: 64, height: 64, borderRadius: 8 },
  info: { marginLeft: 12, justifyContent: 'center' },
  place: { fontSize: 16, fontWeight: '600' },
  date: { fontSize: 13, color: '#888', marginTop: 4 },
});
  • Now open src/app/(tabs)/index.tsx and replace all of its placeholder content with this code:
import { useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
import { useJournalStore, JournalEntry } from '../../store/useJournalStore';
import JournalCard from '../../components/JournalCard';

export default function FeedScreen() {
  // Subscribe to the entries array from the store
  const entries = useJournalStore((state) => state.entries);

  // useCallback prevents renderItem from being recreated on every render
  const renderItem = useCallback(({ item }: { item: JournalEntry }) => (
    <JournalCard entry={item} />
  ), []);

  // Stable keyExtractor for FlatList performance
  const keyExtractor = useCallback((item: JournalEntry) => item.id, []);

  if (entries.length === 0) {
    return (
      <View style={styles.empty}>
        <Text>No entries yet. Take a photo!</Text>
      </View>
    );
  }

  return (
    <FlatList
      data={entries}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
    />
  );
}

const styles = StyleSheet.create({
  empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

What does this code do?

  • useJournalStore((state) => state.entries) subscribes the Feed screen to the entries array. Every time the store changes, this component re-renders with the latest data.
  • renderItem is wrapped in useCallback with an empty dependency array. This means the function reference stays stable across renders, which prevents FlatList from re-rendering all visible items.
  • keyExtractor tells FlatList how to uniquely identify each row. This lets React efficiently add and remove items without rebuilding the entire list.
  • The empty state check displays a helpful message instead of a blank screen when the learner hasn't taken any photos yet.
  • Save index.tsx.
  • On your phone, switch to the Camera tab and take 2-3 photos in different spots.
  • Switch to the Feed tab. You should see your photos listed in reverse chronological order (newest first), each with a thumbnail, place name, and date.
  • Close the Expo Go app completely (swipe it away from recents), then reopen it and navigate back to the Feed tab. Your entries should still be there.

Feed is empty even after taking photos?

  • Confirm you see the "Saved!" alert when taking a photo. If not, the camera screen isn't calling addEntry(). Go back and check the camera edits.
  • Verify the import path in index.tsx is '../../store/useJournalStore' and '../../components/JournalCard'. Both need two levels of ../.
  • If entries appear but photos are blank, the file copy in the camera screen may have failed silently. Check the Expo terminal log for any filesystem errors.

Help me debug why my Feed screen isn't showing journal entries

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before moving on.

ⓧ I'd like to double check the full code

import { useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
import { useJournalStore, JournalEntry } from '../../store/useJournalStore';
import JournalCard from '../../components/JournalCard';

export default function FeedScreen() {
  const entries = useJournalStore((state) => state.entries);

  const renderItem = useCallback(({ item }: { item: JournalEntry }) => (
    <JournalCard entry={item} />
  ), []);

  const keyExtractor = useCallback((item: JournalEntry) => item.id, []);

  if (entries.length === 0) {
    return (
      <View style={styles.empty}>
        <Text>No entries yet. Take a photo!</Text>
      </View>
    );
  }

  return (
    <FlatList
      data={entries}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
    />
  );
}

const styles = StyleSheet.create({
  empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

Your journal entries now survive app restarts and display in a smooth scrollable feed. Next up, you'll build a calendar view where each day shows stacked photo thumbnails so you can browse your journal by date.

Build the Calendar View with Photo Stacks

Your feed shows every photo in a scrollable list, but scrolling through dozens of entries to find a specific day is tedious. A calendar gives you a bird's-eye view of your entire photo history at a glance.

In this step, you'll build a calendar screen that highlights days with photos and lets you drill into any date. You'll use react-native-calendars for the calendar grid and override its day rendering with a custom component that shows tiny photo thumbnails stacked on top of each other.

In this step, get ready to:
  • Render a calendar with marked dates derived from your journal entries.
  • Replace the default day cells with a custom component showing stacked photo thumbnails.
  • Show a detail list of all photos when you tap a specific day.
Set up the Calendar component with marked dates

The react-native-calendars library renders a full month grid out of the box. To show which days have photos, you pass it a markedDates object where each key is a date string.

  • Open src/app/(tabs)/calendar.tsx in VS Code.
  • Replace the entire placeholder content with the following code:
import { useState, useMemo, useCallback } from 'react';
import { View, Text, Image, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
import { Calendar } from 'react-native-calendars';
import { useJournalStore, JournalEntry } from '../../store/useJournalStore';

export default function CalendarScreen() {
  const entries = useJournalStore((state) => state.entries);
  const getEntriesByDate = useJournalStore((state) => state.getEntriesByDate);

  // Derive a markedDates object from store entries
  const markedDates = useMemo(() => {
    const result: Record<string, { marked: boolean }> = {};
    entries.forEach((e) => {
      const dateKey = e.timestamp.slice(0, 10);
      result[dateKey] = { marked: true };
    });
    return result;
  }, [entries]);

  return (
    <View style={{ flex: 1 }}>
      <Calendar
        markedDates={markedDates}
      />
    </View>
  );
}

What does this code do?

The markedDates object is computed with useMemo so it only recalculates when entries changes. Each entry's timestamp is sliced to the first 10 characters (YYYY-MM-DD format), creating a lookup of which dates have photos.

The Calendar component reads this object and renders a dot indicator on each marked date by default.

  • Save calendar.tsx.
  • On your phone, switch to the Calendar tab. You should see a full month grid. If you've already taken photos, you'll see small dots on the days those photos were captured.

Calendar not showing up?

Make sure you saved the file and your Expo dev server is still running. Check the terminal for any import errors.

If the month header shows a raw timestamp instead of the month name, add monthFormat="MMMM yyyy" as a prop on the Calendar component.

help me debug why my Calendar component isn't rendering

Implement a custom dayComponent with stacked photo thumbnails

The default dot indicator is functional but doesn't give you a visual preview of what you shot that day. The dayComponent prop lets you fully replace how each calendar cell renders.

  • In calendar.tsx, add the following DayComponent function above the CalendarScreen function (below the imports):
function DayComponent({ date, state, marking, getEntriesByDate }: any) {
  const dateString = date?.dateString ?? '';
  const dayEntries = getEntriesByDate(dateString);
  const previews = dayEntries.slice(0, 3);
  return (
    <View style={styles.dayCell}>
      <Text style={[styles.dayNumber, state === 'disabled' && styles.disabled]}>
        {date?.day}
      </Text>
      <View style={styles.thumbRow}>
        {previews.map((entry: JournalEntry, i: number) => (
          <Image
            key={entry.id}
            source={{ uri: entry.uri }}
            style={[styles.thumb, { marginLeft: i > 0 ? -8 : 0 }]}
          />
        ))}
      </View>
    </View>
  );
}

What does this component do?

The Calendar library passes each day cell date, state, and marking props. Your custom function also receives getEntriesByDate (which you'll pass through shortly) so it can pull photos for that specific date.

The previews.slice(0, 3) limits the thumbnails to 3 per day. The negative marginLeft: -8 on each thumbnail after the first creates the overlapping stack effect, like a pile of photos.

  • Now add a renderDayComponent callback inside the CalendarScreen function, below the markedDates useMemo:
  const renderDayComponent = useCallback(
    (props: any) => <DayComponent {...props} getEntriesByDate={getEntriesByDate} />,
    [getEntriesByDate]
  );
  • Update the Calendar component to use your custom day renderer. Find the <Calendar tag and add the dayComponent prop:
      <Calendar
        markedDates={markedDates}
        dayComponent={renderDayComponent}
      />

Why wrap DayComponent in useCallback?

The Calendar component re-renders all 30+ day cells whenever a prop reference changes. Wrapping your renderer in useCallback ensures the function reference stays stable across renders, preventing unnecessary re-renders of every day cell.

  • Add the day-related styles to the StyleSheet.create call at the bottom of the file:
const styles = StyleSheet.create({
  dayCell: { alignItems: 'center', paddingVertical: 4 },
  dayNumber: { fontSize: 14 },
  disabled: { color: '#ccc' },
  thumbRow: { flexDirection: 'row', marginTop: 2 },
  thumb: { width: 16, height: 16, borderRadius: 8, borderWidth: 1, borderColor: 'white' },
});
  • Save calendar.tsx and check your phone.
  • The calendar should now show tiny circular photo thumbnails overlapping on days where you've taken photos. Days without photos just display the day number.

Thumbnails not appearing on days with photos?

Make sure DayComponent is defined above CalendarScreen in the file, not inside it. Functions defined inside the component would recreate on every render.

Verify that you passed getEntriesByDate through the renderDayComponent callback. Without it, the day component has no way to fetch entries for a date.

help me figure out why my custom day thumbnails aren't showing in the calendar

Add a day detail view showing all photos for a tapped date

Seeing thumbnails on the calendar is helpful, but you also need a way to view the full photos for a specific day. You'll track which date the user tapped and render a detail list below the calendar.

  • Inside the CalendarScreen function, add a selectedDate state and a selectedEntries memo. Place these below the markedDates useMemo:
  const [selectedDate, setSelectedDate] = useState<string | null>(null);

  const selectedEntries = useMemo(
    () => (selectedDate ? getEntriesByDate(selectedDate) : []),
    [selectedDate, entries]
  );

What does this do?

When the user taps a day, selectedDate stores that date string. The selectedEntries memo then filters all journal entries down to only those from the selected date, recalculating only when the selection or entries change.

  • Update the Calendar component to handle day presses. Add the onDayPress prop:
      <Calendar
        markedDates={markedDates}
        dayComponent={renderDayComponent}
        onDayPress={(day: any) => setSelectedDate(day.dateString)}
      />
  • Below the closing /> of the Calendar component (but still inside the outer <View>), add a conditional FlatList that shows the detail cards:
      {selectedDate && (
        <FlatList
          data={selectedEntries}
          keyExtractor={(item) => item.id}
          renderItem={({ item }) => (
            <View style={styles.detailCard}>
              <Image source={{ uri: item.uri }} style={styles.fullPhoto} />
              <Text style={styles.place}>{item.placeName}</Text>
              <Text style={styles.time}>{new Date(item.timestamp).toLocaleTimeString()}</Text>
            </View>
          )}
          ListEmptyComponent={<Text style={styles.empty}>No photos for this day.</Text>}
        />
      )}

What does this FlatList do?

The conditional {selectedDate && (...)} hides the list until a date is tapped. Once tapped, the FlatList renders a card for each photo taken that day showing the full image, place name, and time.

The ListEmptyComponent handles the edge case where the user taps a date that had entries deleted.

  • Add the remaining styles to your StyleSheet.create call. Your full styles object should now look like this:
const styles = StyleSheet.create({
  dayCell: { alignItems: 'center', paddingVertical: 4 },
  dayNumber: { fontSize: 14 },
  disabled: { color: '#ccc' },
  thumbRow: { flexDirection: 'row', marginTop: 2 },
  thumb: { width: 16, height: 16, borderRadius: 8, borderWidth: 1, borderColor: 'white' },
  detailCard: { padding: 12, borderBottomWidth: 1, borderColor: '#eee' },
  fullPhoto: { width: '100%', height: 200, borderRadius: 8 },
  place: { fontSize: 16, fontWeight: '600', marginTop: 8 },
  time: { fontSize: 13, color: '#888', marginTop: 4 },
  empty: { textAlign: 'center', marginTop: 16, color: '#888' },
});
  • Save calendar.tsx.
  • On your phone, tap any day that has photo thumbnails. A list of full-size photos with place names and times should appear below the calendar.

Tapping a day doesn't show photos?

Check that onDayPress is set on the Calendar component and calls setSelectedDate(day.dateString). The day object from the library has a dateString property in YYYY-MM-DD format.

Make sure your conditional render uses {selectedDate && (...)} and that the FlatList receives selectedEntries as its data prop.

help me debug why tapping a calendar day doesn't show my photos below

✔️ Awesome, I've got everything!

Great. Double check you've saved calendar.tsx and that you can see thumbnails on your calendar and tap a day to see its photos.

ⓧ I'd like to double check the full code

Here's the complete src/app/(tabs)/calendar.tsx file. Compare yours against it:

import { useState, useMemo, useCallback } from 'react';
import { View, Text, Image, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
import { Calendar } from 'react-native-calendars';
import { useJournalStore, JournalEntry } from '../../store/useJournalStore';

function DayComponent({ date, state, marking, getEntriesByDate }: any) {
  const dateString = date?.dateString ?? '';
  const dayEntries = getEntriesByDate(dateString);
  const previews = dayEntries.slice(0, 3);
  return (
    <View style={styles.dayCell}>
      <Text style={[styles.dayNumber, state === 'disabled' && styles.disabled]}>
        {date?.day}
      </Text>
      <View style={styles.thumbRow}>
        {previews.map((entry: JournalEntry, i: number) => (
          <Image
            key={entry.id}
            source={{ uri: entry.uri }}
            style={[styles.thumb, { marginLeft: i > 0 ? -8 : 0 }]}
          />
        ))}
      </View>
    </View>
  );
}

export default function CalendarScreen() {
  const entries = useJournalStore((state) => state.entries);
  const getEntriesByDate = useJournalStore((state) => state.getEntriesByDate);
  const [selectedDate, setSelectedDate] = useState<string | null>(null);

  const markedDates = useMemo(() => {
    const result: Record<string, { marked: boolean }> = {};
    entries.forEach((e) => {
      const dateKey = e.timestamp.slice(0, 10);
      result[dateKey] = { marked: true };
    });
    return result;
  }, [entries]);

  const selectedEntries = useMemo(
    () => (selectedDate ? getEntriesByDate(selectedDate) : []),
    [selectedDate, entries]
  );

  const renderDayComponent = useCallback(
    (props: any) => <DayComponent {...props} getEntriesByDate={getEntriesByDate} />,
    [getEntriesByDate]
  );

  return (
    <View style={{ flex: 1 }}>
      <Calendar
        markedDates={markedDates}
        dayComponent={renderDayComponent}
        onDayPress={(day: any) => setSelectedDate(day.dateString)}
      />
      {selectedDate && (
        <FlatList
          data={selectedEntries}
          keyExtractor={(item) => item.id}
          renderItem={({ item }) => (
            <View style={styles.detailCard}>
              <Image source={{ uri: item.uri }} style={styles.fullPhoto} />
              <Text style={styles.place}>{item.placeName}</Text>
              <Text style={styles.time}>{new Date(item.timestamp).toLocaleTimeString()}</Text>
            </View>
          )}
          ListEmptyComponent={<Text style={styles.empty}>No photos for this day.</Text>}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  dayCell: { alignItems: 'center', paddingVertical: 4 },
  dayNumber: { fontSize: 14 },
  disabled: { color: '#ccc' },
  thumbRow: { flexDirection: 'row', marginTop: 2 },
  thumb: { width: 16, height: 16, borderRadius: 8, borderWidth: 1, borderColor: 'white' },
  detailCard: { padding: 12, borderBottomWidth: 1, borderColor: '#eee' },
  fullPhoto: { width: '100%', height: 200, borderRadius: 8 },
  place: { fontSize: 16, fontWeight: '600', marginTop: 8 },
  time: { fontSize: 13, color: '#888', marginTop: 4 },
  empty: { textAlign: 'center', marginTop: 16, color: '#888' },
});

Your calendar view is complete. You've built a fully working photo journal with three distinct ways to browse your entries: a chronological feed, a camera for capturing new moments, and a calendar for navigating by date. Next up, the Secret Mission challenges you to add a map view.

Secret mission

Add a Map View

Your journal entries already store GPS coordinates. What if you could see all your photos plotted on a map, showing exactly where each memory was captured? In this secret mission, you will add a fourth tab with an interactive map that drops a pin for every journal entry and lets you tap each pin to preview the photo and place name.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally and on your phone, so there are no ongoing costs.

Resources you used:

  • photo-journal project folder on your Desktop.
  • Photos and journal data stored within Expo Go's app sandbox on your Android phone.
  • Expo Go app installed on your Android phone.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep taking photos with the app.

  • The photo-journal folder stays on your Desktop. You can relaunch the dev server anytime with npx expo start.
  • All photos and journal entries remain stored on your phone within Expo Go.
  • There is no ongoing cost. Photos consume only device storage.

Pause - I'll come back to this later

Shut down the development server to free up your terminal. Your project files and phone data stay intact.

  • In the terminal where the Expo dev server is running, press Ctrl+C to stop it.
  • Your photo-journal folder remains on your Desktop, ready to resume.
  • All photos and journal entries stay on your phone. You can browse them again the next time you connect to the dev server.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes both your local code and the app data on your phone.

  • Stop the dev server by pressing Ctrl+C in the terminal if it is still running.
  • Delete the project folder from your Desktop by running this command (macOS/Linux):
rm -rf ~/Desktop/photo-journal

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\photo-journal
  • Uninstall Expo Go from your Android phone. Long-press the Expo Go icon, then tap Uninstall. This removes all photos and journal data stored in the app's sandbox automatically.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional photo journal app running on your Android phone.

This is a real mobile app with device-native features, optimized rendering, and persistent state management.

You've learned how to:

  • Build a multi-screen React Native app from scratch using Expo SDK 55 and file-based routing with Expo Router.
  • Capture photos using the device camera and auto-tag them with GPS coordinates and reverse-geocoded place names.
  • Persist journal entries locally using Zustand with AsyncStorage, and render them in an optimized FlatList feed and a custom calendar view with stacked photo thumbnails.
  • Secret Mission: Added a map view with markers showing where each photo was taken.

Ready to quiz yourself?