If you know how to create React web apps, you can use those same skills to develop mobile apps using the Ionic framework. In this article, we’ll look at how to create an Android recipe app with the Ionic framework and React.
Let’s get started by installing a few things. First, install the Ionic CLI globally by running:
npm install -g @ionic/cli native-run cordova-res
Next, let’s create our Ionic app project:
ionic start react-ionic-recipes-app blank --type=react --capacitor
blank
means we’re creating a blank app. A blank app has routing added — and that’s the only thing we need. type
is set to react
, which means we’ll create a React project. Lastly, --capacitor
adds Capacitor, a cross-platform native runtime from Ionic that allows us to run and build a native app from our project files.
Then we’ll install the React Hooks for our project by running the following command in the ionic-app
project folder:
npm install @ionic/react-hooks @ionic/pwa-elements
Finally, let’s run the app in the browser:
ionic serve
We should be able to see our app on http://localhost:8100
.
Now we should have a project that we can run on our Android mobile device or an emulator like Genymotion.
App.tsx
is the entry point component for the project; this is also where the routing code is located. Let’s add more routes to it by writing:
// src/App.tsx import React from 'react'; import { Redirect, Route } from 'react-router-dom'; import { IonApp, IonRouterOutlet } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; import Home from './pages/Home'; /* Core CSS required for Ionic components to work properly */ import '@ionic/react/css/core.css'; /* Basic CSS for apps built with Ionic */ import '@ionic/react/css/normalize.css'; import '@ionic/react/css/structure.css'; import '@ionic/react/css/typography.css'; /* Optional CSS utils that can be commented out */ import '@ionic/react/css/padding.css'; import '@ionic/react/css/float-elements.css'; import '@ionic/react/css/text-alignment.css'; import '@ionic/react/css/text-transformation.css'; import '@ionic/react/css/flex-utils.css'; import '@ionic/react/css/display.css'; /* Theme variables */ import './theme/variables.css'; import RecipeForm from './pages/RecipeForm'; const App: React.FC = () => ( <IonApp> <IonReactRouter> <IonRouterOutlet> <Route path="/home" component={Home} exact={true} /> <Route exact path="/" render={() => <Redirect to="/home" />} /> <Route exact path="/recipe-form/:id" component={RecipeForm} /> <Route exact path="/recipe-form" component={RecipeForm} /> </IonRouterOutlet> </IonReactRouter> </IonApp> ); export default App;
Ionic comes with its own router, so we can use it as we like. To add our routes, we added the recipe-form
routes to the IonRouterOutlet
. The exact
prop means the Ionic router does an exact match on the route. path
, of course, defines the path of the route, and :id
is a route parameter placeholder. component
defines which component we want to mount when we navigate to the given path.
We want to display the recipes list on the homepage of our app. To do this, we add the Home.tsx
file into the pages
folder. Then, we write:
// src/pages/Home.tsx import { IonButton, IonCol, IonContent, IonHeader, IonItem, IonLabel, IonList, IonPage, IonRow, IonTitle, IonToolbar } from '@ionic/react'; import React, { useEffect, useState } from 'react'; import './Home.css'; import { Plugins } from '@capacitor/core'; import { RouteComponentProps } from 'react-router'; import { Recipe } from '../interfaces/recipe'; const { Storage } = Plugins; const Home: React.FC<RouteComponentProps> = ({ match }) => { const [recipes, setRecipes] = useState<Recipe[]>([]) const getRecipes = async () => { const ret = await Storage.get({ key: 'recipes' }); const recipes: Recipe[] = JSON.parse(ret.value as string) || []; setRecipes(recipes) } useEffect(() => { getRecipes() }, [match]) const saveRecipes = async (data: Recipe[]) => { await Storage.set({ key: 'recipes', value: JSON.stringify(data) }); } const deleteRecipe = async (id: string) => { const ret = await Storage.get({ key: 'recipes' }); const recipes: Recipe[] = JSON.parse(ret.value as string) || []; await saveRecipes(recipes.filter(r => r.id !== id)) await getRecipes() } return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle>Recipe App</IonTitle> </IonToolbar> </IonHeader> <IonContent fullscreen> <IonRow> <IonCol size="12"> <IonButton routerLink="/recipe-form">Add Recipe</IonButton> </IonCol> </IonRow> <IonRow> <IonCol> <IonList> {recipes.map(({ id, title }) => ( <IonItem key={id}> <IonLabel>{title}</IonLabel> <IonButton routerLink={`/recipe-form/${id}`}>Edit</IonButton> <IonButton onClick={deleteRecipe.bind(undefined, id)}>Delete</IonButton> </IonItem> ))} </IonList> </IonCol> </IonRow> </IonContent> </IonPage> ); }; export default Home;
In this file, we use the Capacitor storage library to let us manipulate storage asynchronously.
In our case, this is a better choice than local storage because local storage can only store a small amount of temporary data. Local storage also has a data eviction policy that frees up space if the data takes up more than 50 percent of the space of the storage device. Capacitor’s storage API is also async, so it won’t hold up the UI.
In Home.tsx
, we get the data with key recipes
via the getRecipes
function. Then, we call setRecipes
to set the recipes
state.
We run the getRecipes
function whenever the match
prop is changed. The match
prop contains the navigation data, like URL and URL parameters; thus, we get the latest data whenever navigation is done.
The saveRecipes
function is called when we call deleteRecipe
. We get the value with key recipes
, parse that, then call saveRecipes
without the item with the given id
. This lets us delete the entry with the given id
.
Since this app is so small, we can just get all the entries without fear of performance penalties — however, we should consider other storage solutions if our app stores a lot of data.
We call deleteRecipe
when we click on the Delete button, and when we click on Edit, we go to the recipe-form
URL with the ID of the recipe so we can edit the entry with the given ID. We also have the Add Recipe button to go to the recipe-form
. The IonLabel
has the title
, which we display in the list.
Now we need to create the page to add or edit recipes. To do this, we create a RecipeForm.tsx
file in the src/pages
folder and write:
// src/pages/RecipeForm.tsx import { IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonImg, IonInput, IonItem, IonLabel, IonList, IonPage, IonTextarea, IonTitle, IonToolbar } from '@ionic/react'; import React, { useCallback, useEffect, useState } from 'react'; import { useCamera } from '@ionic/react-hooks/camera'; import { CameraResultType, CameraSource } from "@capacitor/core"; import { RouteComponentProps } from 'react-router'; import { Plugins } from '@capacitor/core'; import { v4 as uuidv4 } from 'uuid'; import { base64FromPath } from "@ionic/react-hooks/filesystem" import { Recipe } from '../interfaces/recipe'; const { Storage } = Plugins; function usePhotoGallery() { const { getPhoto } = useCamera(); const [photo, setPhoto] = useState<string>(''); const takePhoto = async () => { const cameraPhoto = await getPhoto({ resultType: CameraResultType.Uri, source: CameraSource.Camera, quality: 100 }); const base64Data = await base64FromPath(cameraPhoto.webPath!); setPhoto(base64Data) }; return { photo, takePhoto }; } interface RecipeFormProps extends RouteComponentProps<{ id?: string; }> { } const RecipeForm: React.FC<RecipeFormProps> = ({ match, history }) => { const { photo, takePhoto } = usePhotoGallery(); const [title, setTitle] = useState<string>('') const [steps, setSteps] = useState<string>('') const [existingPhoto, setExistingPhoto] = useState<string>('') const saveRecipes = async (data: Recipe[]) => { await Storage.set({ key: 'recipes', value: JSON.stringify(data) }); } const save = async () => { if (!title) { alert('Title is required') return } if (!steps) { alert('Steps is required') return } const recipe: Recipe = { photo, title, steps, id: uuidv4() } const ret = await Storage.get({ key: 'recipes' }); const recipes: Recipe[] = JSON.parse(ret.value as string) || []; if (!match.params.id) { await saveRecipes([...recipes, recipe]) setExistingPhoto('') setTitle('') setSteps('') } else { const recipeIndex = recipes.findIndex(r => r.id === match.params.id) recipes[recipeIndex] = { photo, title, steps, id:match.params.id } await saveRecipes(recipes) } history.push('/'); } const getRecipe = useCallback(async () => { if (match.params.id) { const ret = await Storage.get({ key: 'recipes' }); const recipes: Recipe[] = JSON.parse(ret.value as string) || []; const recipe = recipes.find(r => r.id === match.params.id) if (match.params.id && recipe) { const { photo, title, steps } = recipe; setExistingPhoto(photo) setTitle(title) setSteps(steps) } } }, [match.params.id]) useEffect(() => { getRecipe() }, [getRecipe, match.params.id]) useEffect(() => { setExistingPhoto(photo) }, [photo]) return ( <IonPage> <IonHeader> <IonToolbar> <IonButtons slot="start"> <IonBackButton defaultHref="/" /> </IonButtons> <IonTitle>{match.params.id ? 'Edit' : 'Add'} Recipe Form</IonTitle> </IonToolbar> </IonHeader> <IonContent fullscreen> <IonHeader collapse="condense"> <IonToolbar> <IonTitle size="large">Recipe Form</IonTitle> </IonToolbar> </IonHeader> <IonList> <IonItem> <IonButton onClick={takePhoto}>Take Recipe Photo</IonButton> </IonItem> <IonItem> <IonLabel>Recipe Photo</IonLabel> {existingPhoto && <IonImg src={existingPhoto} />} </IonItem> <IonItem lines="none"> <IonInput value={title} placeholder="Title" onIonChange={e => setTitle(e.detail.value!)}></IonInput> </IonItem> <IonItem lines="none"> <IonTextarea rows={10} value={steps} placeholder="Steps" onIonChange={e => setSteps(e.detail.value!)}></IonTextarea> </IonItem> <IonItem lines="none"> <IonButton onClick={save}>Save</IonButton> </IonItem> </IonList> </IonContent> </IonPage > ); }; export default RecipeForm;
In this component, we create the usePhotoGallery
Hook to let us take a photo and return the Base64 string version of it.
In the Hook, we call the useCamera
hook to get the getPhoto
function, which we call to take the photo. resultType
is set to CameraResultType.Uri
to return the URL to the photo. We set the photo source
to the phone’s camera with CameraSource.Camera
. quality
is set to a number; 100 is high quality.
It returns a promise with the camera photo data. The base64FromPath
lets us get the Base64 string version of the photo. We call setPhoto
to get the photo, and we return the photo
and takePhoto
objects so we can use them in our RecipeForm
component.
In the RecipeForm
component, we have the match
prop to get the ID of the recipe entry. history
has the browser history object, which lets us navigate programmatically.
The saveRecipes
function, if it wasn’t obvious, saves the recipes data; it’s used in the save
function. In it, we check for the title
and steps
states. If they’re set, then we proceed to saving the entry.
title
is the recipe title, and steps
has the content for the recipe steps. We then create the recipe
object with an ID. This is used for new entries. We call Storage.get
to get all the entries with key recipes
, then we parse it with JSON.parse
.
If match.params.id
is falsy, which means we’re adding an entry, we add the entry to the end of the array of recipes and call saveRecipes
with it. We clear all the states after that. Otherwise, we find the index of the recipes
array with the findIndex
method, then we update the entry with our states.
We then call saveRecipes
to save the data, then history.push
to move back to the homepage. The getRecipe
function is used to get the recipe data for an existing entry if match.params.id
exists.
We set the photo
, title
, and steps
state from the retrieved entry. We watch match.params.id
, so we always call getRecipe
if we get a new value for it.
Then we use the useEffect
Hook to watch for match.params.id
, and we call getRecipe
when they change. match.params.id
has the ID of the recipe, so we use it to get the recipe when we click Edit.
We also set the existingPhoto
state when we get the photo
state updated so that we can display it — it’s easier to only display existingPhoto
rather than display both with different conditions. photo
and existingPhoto
are both Base64 strings of the recipe photo.
In the JSX, we have the IonList
with a button to call takePhoto
to take the photo, and we have inputs for the title and steps. We also added a Save button to save the entry by calling save
.
Finally, we’ll build an interface for our components. Create the file src/interfaces/recipe.ts
and add:
// src/interfaces/recipe.ts export interface Recipe { photo: string; title: string; steps: string; id: string; }
To run our app with Genymotion, we need to do a few more things. First, we create an assets folder by running:
ionic build
We’ll need this to run and build our Android app. Next, add the Android dependencies by running:
npx cap add android npx cap sync
Now we need to install Android Studio and Genymotion, then we install the Genymotion plugin for Android Studio. Once that’s complete, we run our Ionic React project with the following command:
ionic capacitor run android --livereload --external --address=0.0.0.0
When asked, we should choose ‘VirtualBox Virtual Adapter’. This way, we can use the internet from Genymotion. Then, in Android Studio, hit Alt+Shift+F10, and we should see our app running in Genymotion.
With Ionic, we can use our knowledge of React to create an Android mobile app that can access hardware and work with storage devices.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]