Building a mobile app with React and Ionic

Building a mobile app with React and Ionic

In this article, we will cover the basics of React and the Ionic framework. We will learn how to build a simple application with React and Ionic. We will cover the application structure by explaining how React and Ionic applications are laid out and organized.

What is Ionic?

According to its documentation, Ionic is an open-source UI toolkit for building performant, high-quality mobile apps using web technologies — HTML, CSS, and JavaScript — with integrations for popular frameworks like Angular, React, and Vue. Ionic allows developers to build apps for all major app stores and the mobile web, all while keeping the UI consistent with the device OS using Adaptive Styling.

Previously, Ionic was tightly coupled with and built mainly for Angular. Still, from version 4, it has been re-engineered to work as a standalone web component library. With integrations for the latest JavaScript frameworks, current versions can be used in most JavaScript, like React, as we'll look for in this article.

Ionic with React

Ionic officially supports React, and with @ionic/react, developers can use their current web development skills to create iOS, Android, and web apps. This allows devs to use all the Ionic core components, but in a way that feels like using native React components.

Ionic CLI

Ionic provides an official CLI tool that quickly scaffolds Ionic apps and provides a number of helpful commands. It is also used for installing and updating Ionic. The CLI also comes with a built-in development server, and build and debugging tools.

Now that we have an idea of what Ionic is all about, let's dive into building an Ionic application.

Set up the Ionic project

The Ionic CLI provides various development tools and support choices and is the recommended installation approach.

Install the Ionic CLI

We'll install the Ionic CLI globally with npm, well also install native-run, used to run native binaries on devices and simulators/emulators, and cordova-res, used to generate native app icons and splash screens:

npm install -g @ionic/cli native-run cordova-res

Create an Ionic React app

To create an Ionic React app that uses the "Tabs" starter template and adds Capacitor for native functionality, we'll run the following command:

ionic start ionic-react-app tabs --type=react --capacitor

Once the installation is complete, we can navigate to and start our starter template:

cd ionic-react-app/
ionic serve

Now we should see something like this when we go to http://http://localhost:8100/ in our browser. We should see this:

1

Awesome! Since the starter project comes complete with three pre-built pages and best practices for Ionic development, we can easily add more features as we start working on the app.

Theming the app

Ionic is built to be very customizable, and there are multiple ways we can customize colors and styling to suit our needs. First, we'll look at how to customize the app's color.

Customizing colors

Numerous Ionic components can change their colors using one of the nine default colors. Each color in Ionic is essentially a collection of various properties, including a shade and tint.

To successfully change or add color, all the related properties, including shade and tint, must be added to the list of properties for that color. To make this easier, Ionic provides a Color Generator tool that makes this simple to accomplish, but if preferred, these can also be manually generated.

To generate a color theme, go to Ionic color generator and customize the colors:

2

Once satisfied with the customization, we can copy out the generated code and place it in the ./src/theme/variables.css:

/* ./src/theme/variables.css */
/* Ionic Variables and Theming. For more info, please see:
http://ionicframework.com/docs/theming/ */
/** Ionic CSS Variables **/
:root {
  /** primary **/
  --ion-color-primary: #04724f;
  --ion-color-primary-rgb: 4, 114, 79;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #046446;
  --ion-color-primary-tint: #1d8061;
  /** secondary **/
  --ion-color-secondary: #0f9b9c;
  --ion-color-secondary-rgb: 15, 155, 156;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255, 255, 255;
  --ion-color-secondary-shade: #0d8889;
  --ion-color-secondary-tint: #27a5a6;
  /** tertiary **/
  --ion-color-tertiary: #ffb129;
  --ion-color-tertiary-rgb: 255, 177, 41;
  --ion-color-tertiary-contrast: #000000;
  --ion-color-tertiary-contrast-rgb: 0, 0, 0;
  --ion-color-tertiary-shade: #e09c24;
  --ion-color-tertiary-tint: #ffb93e;
  /** success **/
  --ion-color-success: #2dd36f;
  --ion-color-success-rgb: 45, 211, 111;
  --ion-color-success-contrast: #ffffff;
  --ion-color-success-contrast-rgb: 255, 255, 255;
  --ion-color-success-shade: #28ba62;
  --ion-color-success-tint: #42d77d;
  /** warning **/
  --ion-color-warning: #ffc409;
  --ion-color-warning-rgb: 255, 196, 9;
  --ion-color-warning-contrast: #000000;
  --ion-color-warning-contrast-rgb: 0, 0, 0;
  --ion-color-warning-shade: #e0ac08;
  --ion-color-warning-tint: #ffca22;
  /** danger **/
  --ion-color-danger: #d04155;
  --ion-color-danger-rgb: 208, 65, 85;
  --ion-color-danger-contrast: #ffffff;
  --ion-color-danger-contrast-rgb: 255, 255, 255;
  --ion-color-danger-shade: #b7394b;
  --ion-color-danger-tint: #d55466;
  /** dark **/
  --ion-color-dark: #222428;
  --ion-color-dark-rgb: 34, 36, 40;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255, 255, 255;
  --ion-color-dark-shade: #1e2023;
  --ion-color-dark-tint: #383a3e;
  /** medium **/
  --ion-color-medium: #2a4f48;
  --ion-color-medium-rgb: 42, 79, 72;
  --ion-color-medium-contrast: #ffffff;
  --ion-color-medium-contrast-rgb: 255, 255, 255;
  --ion-color-medium-shade: #25463f;
  --ion-color-medium-tint: #3f615a;
  /** light **/
  --ion-color-light: #7cda98;
  --ion-color-light-rgb: 124, 218, 152;
  --ion-color-light-contrast: #000000;
  --ion-color-light-contrast-rgb: 0, 0, 0;
  --ion-color-light-shade: #6dc086;
  --ion-color-light-tint: #89dea2;
}

The colors here apply to the default light mode. To apply the colors to the dark node as well, we have to replace the colors primary, secondary, success, warning, and danger in the @media (prefers-color-scheme: dark) section. We'll leave out dark, medium, and light since they are inverses of the light theme colors.

Here's what we should have about now:

3

Nice.

Adding new colors

We can always add new colors in addition to the --ion-color-primary and others that are provided by default. First, let's generate a few colors using the new color creator tool:

4

Next, define the CSS variables for all color variations at the :root.:

:root {
  /* ... */
  --ion-color-custom: #347348;
  --ion-color-custom-rgb: 52,115,72;
  --ion-color-custom-contrast: #ffffff;
  --ion-color-custom-contrast-rgb: 255,255,255;
  --ion-color-custom-shade: #2e653f;
  --ion-color-custom-tint: #48815a;
}

Then, create a new class that uses these CSS variables:

.ion-color-custom {
    --ion-color-base: var(--ion-color-custom);
    --ion-color-base-rgb: var(--ion-color-custom-rgb);
    --ion-color-contrast: var(--ion-color-custom-contrast);
    --ion-color-contrast-rgb: var(--ion-color-custom-contrast-rgb);
    --ion-color-shade: var(--ion-color-custom-shade);
    --ion-color-tint: var(--ion-color-custom-tint);
}

Now, we can use our class in any component that supports the class attribute. To illustrate this, in the ./src/components/ExploreContainer.tsx file, we can add a button with the custom class:

// ./src/components/ExploreContainer.tsx
import { IonButton } from "@ionic/react";
import "./ExploreContainer.css";
interface ContainerProps {
  name: string;
}
const ExploreContainer: React.FC<ContainerProps> = ({ name }) => {
  return (
    <div className="container">
      <strong>{name}</strong>
      <p>
        Explore{" "}
        <a
          target="_blank"
          rel= "noopener noreferrer"
          href="https://ionicframework.com/docs/components"
        >
          UI Components
        </a>
      </p>
      {/* button with custom class */}
      <IonButton color={"custom"}>custom button</IonButton>
    </div>
  );
};
export default ExploreContainer;

We should have something like this now:

5

So far, we've covered what will be just the tip of the iceberg in theming Ionic apps in React, and more information, resources, and guides on themes can be found in the Ionic docs.

In the next section, we'll look at how we can fetch data and create components in Ionic React.

Fetch data from REST API

Since we're using React in our Ionic application, there are multiple packages we can use to handle data fetching. Still, in this article, we'll take a more general approach and use the Fetch API.

We will create an image gallery app powered by the Unsplash API. To obtain API keys, we have to set up a developer account by following the steps in the developers' docs. Once the developer account is set up, we can obtain API keys by creating a new app in the Unsplash dashboard.

6

Back in our application, create a new file ./services/api.ts:

const ACCESS_KEY = "xxxxxxxxxxxxxxxxxxx";
const headerList = {
  "Content-Type": "application/json",
  Authorization: `Client-ID ${ACCESS_KEY}`,
};
export const getPhotos = async () => {
  try {
    const response = await fetch(`https://api.unsplash.com/photos/`, {
      headers: headerList,
    });
    const data = await response.json();
    return data;
  } catch (err) {
    return console.log(err);
  }
};

Here, we're exporting a function getPhotos() that fetches photos from the Unsplash API. Next, we'll create a PhotoCard component to display photos.

Create PhotoCard component

Create a new file - ./src/components/PhotoCard.tsx:

// ./src/components/PhotoCard.tsx

import {
  IonButton,
  IonCard,
  IonCardContent,
  IonCardSubtitle,
  IonCol,
  IonIcon,
  IonRow,
  useIonToast,
} from "@ionic/react";
import {  heartOutline } from "ionicons/icons";

import { Photo } from "../types/Photo";
const PhotoCard = ({ photo }: { photo: Photo }) => {
  const { id, urls, description, user, likes } = photo;

  return (
    <IonCard>
      <img
        src={urls.regular}
        alt={description}
        style={{
          height: "24rem",
          width: "100%",
          objectFit: "cover",
        }}
      />
      <IonCardContent>
        <IonRow>
          <IonCol style={{ display: "flex", flexDirection: "column" }}>
            <IonCardSubtitle>@{user.username}</IonCardSubtitle>
            <IonCardSubtitle>
              {likes} like{likes > 1 && "s"}
            </IonCardSubtitle>
          </IonCol>
          <IonCol className="ion-text-right">
            <IonButton>
              <IonIcon icon={heartOutline} />
            </IonButton>
          </IonCol>
        </IonRow>
      </IonCardContent>
    </IonCard>
  );
};
export default PhotoCard;

Here, we're using the IonCard component to wrap other components like IonCardContent and others to make up the photo card.

Now, to get the photos that will be displayed using the component we just created, in our ./pages/Tab1.tsx file, we can call our getPhotos() function and create a grid layout to display each photo card:

// ./pages/Tab1.tsx

import {
  IonCol,
  IonContent,
  IonGrid,
  IonHeader,
  IonPage,
  IonRow,
  IonTitle,
  IonToolbar,
} from "@ionic/react";
import { useEffect, useState } from "react";
import PhotoCard from "../components/PhotoCard";
import { getPhotos } from "../services/api";
import { Photo } from "../types/Photo";
import "./Tab1.css";
const Tab1: React.FC = () => {
  const [photos, setPhotos] = useState<Photo[]>([]);
  const fetchPhotos = async () => {
    const photos = await getPhotos();
    console.log({ photos });
    setPhotos(photos);
  };
  useEffect(() => {
    fetchPhotos();
  }, []);
  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonTitle>Photos</IonTitle>
        </IonToolbar>
      </IonHeader>
      <IonContent fullscreen>
        <div className="gallery">
          <IonGrid className="photo-list">
            <IonRow>
              {photos.map((photo) => (
                <IonCol
                  sizeXs="12"
                  sizeMd="6"
                  sizeXl="4"
                  className="photo-list-item"
                  key={photo.id}
                >
                  <PhotoCard photo={photo} />
                </IonCol>
              ))}
            </IonRow>
          </IonGrid>
        </div>
      </IonContent>
    </IonPage>
  );
};
export default Tab1;

We should have something like this:

7

Sweet! So far, we've seen how to set up an Ionic React app and perform functions like data fetching. In the following sections, we'll look at how we can make our app more interactive by allowing users to save their favorite pictures.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

OpenReplay

Start enjoying your debugging experience - start using OpenReplay for free.

Set up application storage in Ionic React

First, we have to set up our application storage. We'll use the ionic/storage package, a simple key-value Storage module for Ionic apps. This utility uses the best storage engine available on the platform without having to interact with it directly. With ionic/storage, we don't need to worry about the underlying storage method used on the target platform.

npm install @ionic/storage

Once that's installed, create a new file - ./modules/Store.ts and initialize the store:

// ./src/modules/Store.ts

import { Storage } from "@ionic/storage";
export const store = new Storage();
store.create();

Awesome! Now, we can use it in our PhotoCard component to save favorite photos.

Add favorite functionality to the PhotoCard component

We'll add a couple of functions to get favorites from the store, check if the current photo is already in the favorites list, add the photo to favorites, and remove the photo from favorites.

In the ./src/components/PhotoCard.tsx file, enter the following:

// ./src/components/PhotoCard.tsx

import { useEffect, useState } from "react";
import {
  IonButton,
  IonCard,
  IonCardContent,
  IonCardSubtitle,
  IonCol,
  IonIcon,
  IonRow,
} from "@ionic/react";
import { heart, heartOutline } from "ionicons/icons";
import { store } from "../modules/Store";
import { Photo } from "../types/Photo";
const PhotoCard = ({ photo }: { photo: Photo }) => {
  const { id, urls, description, user, likes } = photo;
  const [isFavorite, setIsFavorite] = useState(false);

  /**
   * function to get the favorites from the store
   * @returns {Promise<Photo[]>} the favorites
   */
  const getFavorites = async (): Promise<Photo[]> => {
    const favorites = await store.get("favorites");
    console.log({ favorites });
    return favorites || [];
  };

  /**
   * function to check if a photo is already in favorites
   */
  const checkIsFavorite = async () => {
    const favorites = await getFavorites();
    const isFavorite = favorites.some((favorite: any) => favorite.id === id);
    console.log({ isFavorite });
    setIsFavorite(isFavorite);
  };

  /**
   * function to add a photo to favorites
   * @param photo
   */
  const addToFavorites = async (photo: Photo) => {
    let favorites = await getFavorites();
    store.set("favorites", [...favorites, photo]);
    console.log({ favorites });
    setIsFavorite(true);
  };

  /**
   * function to remove a photo from favorites
   */
  const removeFromFavorites = async () => {
    let favorites = await getFavorites();
    store.set(
      "favorites",
      favorites.filter((favorite: { id: string }) => favorite.id !== id)
    );
    setIsFavorite(false);
  };

  /**
   * function to add or remove a photo from favorites depending on the current `isFavorite` state
   */
  const toggleFavorite = async () => {
    if (isFavorite) {
      removeFromFavorites();
    } else {
      addToFavorites(photo);
    }
  };

  useEffect(() => {
    checkIsFavorite();
  }, []);

  return (
    <IonCard>
      <img
        src={urls.regular}
        alt={description}
        style={{
          height: "24rem",
          width: "100%",
          objectFit: "cover",
        }}
      />
      <IonCardContent>
        <IonRow>
          <IonCol style={{ display: "flex", flexDirection: "column" }}>
            <IonCardSubtitle>@{user.username}</IonCardSubtitle>
            <IonCardSubtitle>
              {likes} like{likes > 1 && "s"}
            </IonCardSubtitle>
          </IonCol>
          <IonCol className="ion-text-right">
            <IonButton
              onClick={() => {
                toggleFavorite();
              }}
            >
              <IonIcon icon={isFavorite ? heart : heartOutline} />
            </IonButton>
          </IonCol>
        </IonRow>
      </IonCardContent>
    </IonCard>
  );
};
export default PhotoCard;

Let's quickly look at the changes we've made and the functions we've added to our component. To access our ionic storage, we have to import store,

import { store } from "../modules/Store";

Now, we can create the functions:

  • getFavorites - to get the favorites from the store

  • checkIsFavorite - to check if the photo is already in the favorites list

  • addToFavorites - to add a photo to favorites

  • removeFromFavorites - to remove a photo from favorites

  • toggleFavorite - to add or remove a photo from favorites depending on the current isFavorite state

To check if the photo is already in the favorites list when mounted, we use useEffect() to run the checkIsFavorite function, which sets the isFavorite state to true or false.

To add or remove a photo from favorites, we add an onClick handler to our button and change the icon accordingly:

<IonButton
  onClick={() => {
    toggleFavorite();
  }}
>
  <IonIcon icon={isFavorite ? heart : heartOutline} />
</IonButton>

Using IonToast for subtle notifications

To let the user know when a photo has been added or removed from the favorites list, we'll use useIonToast to trigger a subtle toast element.

First, we add it to our imports:

// ./src/components/PhotoCard.tsx

import {
  ...
  useIonToast,
} from "@ionic/react";

Next, we initialize useIonToast and assign it to present, which accepts the toast message and other configurations and triggers the toast.

// ./src/components/PhotoCard.tsx

import {
  ...
  useIonToast,
} from "@ionic/react";

// ...

const PhotoCard = ({ photo }: { photo: Photo }) => {
  // ...
  const [present] = useIonToast();

  /**
   * function to present a toast message
   * @param message the message to display
   */
  const presentToast = (message: string) => {
    present({
      message,
      duration: 2000,
      animated: true,
      position: "bottom",
    });
  };

  // ...

  /**
   * function to add or remove a photo from favorites depending on the current `isFavorite` state
   */
  const toggleFavorite = async () => {
    if (isFavorite) {
      removeFromFavorites();
    } else {
      addToFavorites(photo);
    }
    presentToast(isFavorite ? "Removed from favorites" : "Added to favorites");
  };
  useEffect(() => {
    checkIsFavorite();
  }, []);
  return (
    {/*  */}
  )
}

As you can see, we call presentToast from our toggleFavorite function, allowing the message to be shown after a photo has been added or removed from the favorites list.

Create a favorites page

Now, we'll create a page to display our favorite photos. For that, we'll modify ./src/pages/Tab2.tsx:

// ./src/pages/Tab2.tsx
import { useState } from "react";
import {
  IonCol,
  IonContent,
  IonGrid,
  IonHeader,
  IonPage,
  IonRow,
  IonTitle,
  IonToolbar,
  useIonViewWillEnter,
} from "@ionic/react";
import { store } from "../modules/Store";
import PhotoCard from "../components/PhotoCard";
import { Photo } from "../types/Photo";
import "./Tab1.css";
const Tab2: React.FC = () => {
  const [photos, setPhotos] = useState<Photo[]>([]);

  const getFavorites = async () => {
    const favorites = await store.get("favorites");

    return favorites || [];
  };

  useIonViewWillEnter(async () => {
    setPhotos(await getFavorites());
  });
  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonTitle>Favorites</IonTitle>
        </IonToolbar>
      </IonHeader>
      <IonContent fullscreen>
        <div className="gallery">
          <IonGrid className="photo-list">
            <IonRow>
              {photos.map((photo) => (
                <IonCol
                  sizeXs="12"
                  sizeMd="6"
                  sizeXl="4"
                  className="photo-list-item"
                  key={photo.id}
                >
                  <PhotoCard photo={photo} />
                </IonCol>
              ))}
            </IonRow>
          </IonGrid>
        </div>
      </IonContent>
    </IonPage>
  );
};
export default Tab2;

Although this is very similar to the Tab1 page, the notable differences are:

  • We're using getFavorites to get photos from storage and assigning the value to the photo state.

  • We're using useIonViewWillEnter, which is fired when the Tab2 page is about to animate into view and get the photos instead of useEffect.

Finally, we're going to remove the third tab link and change the icons in the tab bar in ./src/App.tsx:

// ./src/App.tsx

import { Redirect, Route } from "react-router-dom";
import {
  IonApp,
  IonIcon,
  IonLabel,
  IonRouterOutlet,
  IonTabBar,
  IonTabButton,
  IonTabs,
  setupIonicReact,
} from "@ionic/react";
import { IonReactRouter } from "@ionic/react-router";
import { heart, image } from "ionicons/icons";
import Tab1 from "./pages/Tab1";
import Tab2 from "./pages/Tab2";
import Tab3 from "./pages/Tab3";
/* 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";
setupIonicReact();
const App: React.FC = () => (
  <IonApp>
    <IonReactRouter>
      <IonTabs>
        <IonRouterOutlet>
          <Route exact path="/tab1">
            <Tab1 />
          </Route>
          <Route exact path="/tab2">
            <Tab2 />
          </Route>
          <Route exact path="/">
            <Redirect to="/tab1" />
          </Route>
        </IonRouterOutlet>
        <IonTabBar slot="bottom">
          <IonTabButton tab="tab1" href="/tab1">
            <IonIcon icon={image} />
            <IonLabel>Photos</IonLabel>
          </IonTabButton>
          <IonTabButton tab="tab2" href="/tab2">
            <IonIcon icon={heart} />
            <IonLabel>Favorites</IonLabel>
          </IonTabButton>
        </IonTabBar>
      </IonTabs>
    </IonReactRouter>
  </IonApp>
);
export default App;

With that, we should have this as our final result:

8

Conclusion

Congratulations on getting to the end of this article. So far, we've learned how to easily develop cross-platform applications using Ionic and React. We could customize our app, fetch data from the Unsplash API, and use multiple Ionic components and utilities.

Further reading and resources

Although not covered in this article, it's pretty easy to build and deploy applications to the target platform. You can read more about it in the Ionic documentation: Deploying to iOS and Android

Other resources you might find helpful:

You can find the source code for this project on GitHub

A TIP FROM THE EDITOR: Don't miss the Ionic+Capacitor combination, as described in our recent Cross-Platform Development Using Ionic+Capacitor article!

newsletter