The Ionic React Image Guide with Capacitor (Capture & Store) Last update: 2022-12-13

The Ionic React Image Guide with Capacitor (Capture & Store)

Working with files, particularly images, is common in various applications such as social media and e-commerce websites. Besides the process of capturing and storing images being a tricky task, having to support it on multiple platforms adds an even more challenge.

In this tutorial, we will create a simple Ionic React photo gallery app that can be run and deployed on the web, iOS, and Android with the help of Capacitor. The app will allow image capturing, viewing, and deleting. (You can read the equivalent Angular tutorial here).

Ionic React Images

Moreover, we’ll utilize the plugins of Capacitor including Camera, Filesystem, and Preferences (previously Storage) for the necessary functions such as capturing an image, storing it locally, and persisting the state.

Let’s get started!

Get the Code 🔐

Handling Images and Files with Ionic React & Capacitor

Receive all the files of the tutorial right to your inbox and subscribe to my weekly no BS newsletter!

Pre-requisites

You should first have the following tools installed in your environment. (Following the pinned versions are optional but recommended for compatibility).

  • Node.js — v18.9.0
  • Ionic CLI — v6.20.1.

Starting our Ionic Photo Gallery App

We start by creating a blank Ionic React app with Capacitor enabled and installing the Camera, Filesystem, and Preferences plugins from Capacitor.

ionic start devdacticImagesReact blank --type=react
cd ./devdacticImagesReact

npm i @capacitor/camera @capacitor/filesystem @capacitor/preferences

We also need to install Ionic PWA Elements dependency to be able to test the camera functionality in a web browser.

npm i @ionic/pwa-elements

Then, if you haven’t already, open the project in your preferred code editor. Insert the code for PWA Elements’ defineCustomElements in src/index.tsx like below.

import { defineCustomElements } from '@ionic/pwa-elements/loader';

ReactDOM.render(
	<React.StrictMode>
		<App />
	</React.StrictMode>,
	document.getElementById('root')
);

defineCustomElements(window);

What the code above does is it calls the element loader after the app has rendered the first time.

Back to the terminal, let’s run our first build and add the native platforms that you want to use afterwards:

ionic build

ionic cap add ios
ionic cap add android

Because we are accessing the camera, we need to define the permissions for the native platforms next.

For iOS:

Add the following lines to your ios/App/App/Info.plist file:

    <key>NSCameraUsageDescription</key>
    <string>To capture images</string>
    <key>NSPhotoLibraryAddUsageDescription</key>
    <string>To add images</string>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>To store images</string>

For Android:

Add the following lines to your android/app/src/main/AndroidManifest.xml file:

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

We now have the permissions we need for capturing and storing our images.

Interface for Photos

Next, under src/types/Photo.ts (create that file & folder), we’ll create an interface for photos that we can use throughout the tutorial. This is not required but it can help us have something like a uniform data structure for photos in our components.

export interface Photo {
	filePath: string;
	webviewPath?: string;
}

The Home page

Let’s proceed with the home page.

Open the src/pages/Home.tsx and change the Home component like below.

import {
	IonContent,
	IonFab,
	IonFabButton,
	IonHeader,
	IonIcon,
	IonPage,
	IonTitle,
	IonToolbar
} from '@ionic/react';
import { camera } from 'ionicons/icons';
import PhotoGallery from '../components/PhotoGallery';
import { usePhotoGallery } from '../hooks/usePhotoGallery';

const Home: React.FC = () => {
	const { photos, takePhoto, deletePhoto } = usePhotoGallery();

	return (
		<IonPage>
			<IonHeader>
				<IonToolbar>
					<IonTitle>Ionic Photo Gallery</IonTitle>
				</IonToolbar>
			</IonHeader>

			<IonContent>
				<PhotoGallery photos={photos} deletePhoto={deletePhoto} />

				<IonFab vertical="bottom" horizontal="end" slot="fixed">
					<IonFabButton onClick={() => takePhoto()}>
						<IonIcon icon={camera}></IonIcon>
					</IonFabButton>
				</IonFab>
			</IonContent>
		</IonPage>
	);
};

export default Home;

Basically, we will have a PhotoGallery component on our home page for displaying the photos in a grid and a fab button with the camera icon for taking photos.

The home page supplies the photos and deletePhoto props and the takePhoto click handler (provided by the custom hook usePhotoGallery) to the PhotoGallery component and the camera button, respectively.

You may have errors in your code right now because of the missing components and functionalities. We’ll implement them one by one.

The PhotoGallery component

This component will act as a gallery that displays photos, hence the name.

Under src/components, create a PhotoGallery.tsx file containing the code below.

import {
	IonCard,
	IonCol,
	IonFab,
	IonFabButton,
	IonGrid,
	IonIcon,
	IonImg,
	IonRow,
	useIonAlert
} from '@ionic/react';
import { trash } from 'ionicons/icons';
import React from 'react';
import { Photo } from '../types/Photo';

type Props = {
	photos: Photo[];
	deletePhoto: (fileName: string) => void;
};

const PhotoGallery: React.FC<Props> = ({ photos, deletePhoto }) => {
	return (
		<IonGrid>
			<IonRow>
				{photos.map((photo, idx) => (
					<IonCol size="6" key={idx}>
						<IonCard>
							<IonFab vertical="bottom" horizontal="center">
								<IonFabButton
									onClick={() => confirmDelete(photo.filePath)}
									size="small"
									color="light"
								>
									<IonIcon icon={trash} color="danger"></IonIcon>
								</IonFabButton>
							</IonFab>

							<IonImg src={photo.webviewPath} />
						</IonCard>
					</IonCol>
				))}
			</IonRow>
		</IonGrid>
	);
};

export default PhotoGallery;

This displays the photos props in two cards per row in a grid. On each card, we’ll also have a button for deleting the photo.

Before the return statement of the component, insert the code for the delete confirmation functionality.

const [displayAlert] = useIonAlert();

const confirmDelete = (fileName: string) =>
	displayAlert({
		message: 'Are you sure you want to delete this photo? ',
		buttons: [
			{ text: 'Cancel', role: 'cancel' },
			{ text: 'OK', role: 'confirm' }
		],
		onDidDismiss: (e) => {
			if (e.detail.role === 'cancel') return;
			deletePhoto(fileName);
		}
	});

Observe that we’re using the useIonAlert hook from @ionic/react for presenting our confirmation alert. And with that, the PhotoGallery component is now complete.

Going back to the Home page component, you may have noticed that the usePhotoGallery hook is yet to be implemented. We’ll do that now.

The usePhotoGallery hook

With hooks, we’ll be able to attach reusable behavior to a component. They also allow us to group similar logic or responsibilities, re-use, and test them independently.

We’ll develop a custom react hook which will hold all the logic for taking, saving, and deleting photos.

Under src/hooks, create a usePhotoGallery.ts file containing the code below.

import { useState, useEffect } from 'react';
import { isPlatform } from '@ionic/react';
import { Camera, CameraResultType, CameraSource, Photo as CameraPhoto } from '@capacitor/camera';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { Preferences } from '@capacitor/preferences';
import { Capacitor } from '@capacitor/core';
import { Photo } from '../types/Photo';

export const usePhotoGallery = () => {
	const [photos, setPhotos] = useState<Photo[]>([]);

	const takePhoto = async () => {};

	const deletePhoto = async (fileName: string) => {};

	return {
		photos,
		takePhoto,
		deletePhoto
	};
};

Observe that we are maintaining an internal photos state in our hook. Moreover, you can see that we are exporting the photos state but not its setter, the setPhotos function.

This is a good practice since we are preventing the users of the hook from directly updating the internal state. We instead exposed the takePhoto and deletePhoto functions from our hook. This way, we’re hiding the actual process of updating the state inside our hook.

We’ve only added the function signatures of takePhoto and deletePhoto, so far. We’ll edit them one by one.

Capturing a photo

For the fun part, we now use the Camera plugin of Capacitor for the input of our photos into the file system.

const takePhoto = async () => {
	try {
		const photo: CameraPhoto = await Camera.getPhoto({
			resultType: CameraResultType.Uri,
			source: CameraSource.Camera,
			quality: 100
		});

		const fileName = new Date().getTime() + '.jpeg';
		const savedFileImage = await savePhoto(photo, fileName);

		const newPhotos = [...photos, savedFileImage];
		setPhotos(newPhotos);
	} catch (e) {
		return;
	}
};

What happens here is we get the input photo from our camera, save it into the file system using the current date/time as the file name, and update the photos state to include the newly added image. Notice that there’s no platform-specific code for web, iOS, or Android in using the device’s camera to take photos since the Camera plugin already does it for us.

Next, we’ll have to develop the savePhoto function for the actual process of saving the image file.

Saving a photo

Currently, we are able to take photos and display them in the PhotoGallery component. But we still need a way to store them so that they can be retrieved even after the app is closed. We can simply use the Filesystem plugin of Capacitor for that.

const savePhoto = async (photo: CameraPhoto, fileName: string): Promise<Photo> => {
	let base64Data: string;

	if (isPlatform('hybrid')) {
		const file = await Filesystem.readFile({
			path: photo.path!
		});
		base64Data = file.data;
	} else {
		base64Data = await base64FromPath(photo.webPath!);
	}

	const savedFile = await Filesystem.writeFile({
		path: fileName,
		data: base64Data,
		directory: Directory.Data
	});

	if (isPlatform('hybrid')) {
		return {
			filePath: savedFile.uri,
			webviewPath: Capacitor.convertFileSrc(savedFile.uri)
		};
	}

	return {
		filePath: fileName,
		webviewPath: photo.webPath
	};
};

The code above, as you can see, includes platform-specific logic. But in both web and mobile platforms, we use the same Filesystem.writeFile method in saving the file. The call to isPlatform(“hybrid”) checks whether the app is running on a mobile device.

If so, we use the Filesystem.readFile method to read the input photo into a base64 string and return from our function the complete image file path and the webview path using the Capacitor.convertFileSrc method.

Otherwise, we use the base64FromPath function we’ll develop in a moment and return the input fileName and photo.webPath.

We need the helper function, base64FromPath, for converting the web path into a base64 representation of our image. You can either put it in the same file outside the hook declaration or in its own file under a utils directory.

async function base64FromPath(path: string): Promise<string> {
	const response = await fetch(path);
	const blob = await response.blob();

	return new Promise((resolve, reject) => {
		const reader = new FileReader();
		reader.onerror = reject;
		reader.onload = () => {
			if (typeof reader.result === 'string') {
				resolve(reader.result);
			} else {
				reject('method did not return a string');
			}
		};

		reader.readAsDataURL(blob);
	});
}

Deleting a photo

Next is the delete functionality. For this, we simply use the Filesystem.deleteFile method which allows us to delete files for both web and native platforms.

const deletePhoto = async (fileName: string) => {
	setPhotos(photos.filter((photo) => photo.filePath !== fileName));
	await Filesystem.deleteFile({
		path: fileName,
		directory: Directory.Data
	});
};

Persisting the state

Lastly, we need to store the pointers to the image files so that we can retrieve them in between app restarts.

We’ll be using the Preferences plugin of Capacitor to persist the photos state — this allows us to keep the stored photos even after reloading the page. In the same file create a PHOTOS_PREF_KEY constant before the hook declaration. We’ll use this key to refer to our state from Preferences.

const PHOTOS_PREF_KEY = 'photos';

Insert the code below inside the hook to allow the fetching of stored photos from Preferences once the component mounts.

useEffect(() => {
	const loadSaved = async () => {
		const { value } = await Preferences.get({ key: PHOTOS_PREF_KEY });
		const photosInPreferences: Photo[] = value ? JSON.parse(value) : [];

		if (!isPlatform('hybrid')) {
			for (let photo of photosInPreferences) {
				const file = await Filesystem.readFile({
					path: photo.filePath,
					directory: Directory.Data
				});

				photo.webviewPath = `data:image/jpeg;base64,${file.data}`;
			}
		}

		setPhotos(photosInPreferences);
	};

	loadSaved();
}, []);

Observe that we have a web-specific logic in setting the photos state using the data from Preferences since we still have to convert the image read from the Filesystem into base64 format.

Afterward, we add another effect hook that activates whenever the photos state changes. It will set the photos data into Preferences every time the takePhoto and deletePhoto functions are executed on the home page.

useEffect(() => {
	Preferences.set({ key: PHOTOS_PREF_KEY, value: JSON.stringify(photos) });
}, [photos]);

Finally, serve and test the application.

Conclusion

With only a single codebase, we now have a Photo Gallery app that can be run on the web, iOS, and Android.

The challenge for you now is to add more functionality to the app — an edit photo feature, for example. You can transform (e.g., resize, flip, zoom), apply filters, overlay photos, and many more. It’s all up to your imagination.

While you’re at it, don’t forget to enjoy and share this with your friends and colleagues!