The groundwork of a mobile hybrid app changes little from a standard React project: Ionic React boots from a setup modelled on create-react-app, with React Router handling navigation under the hood. Creating a project takes a single CLI command, and the tabs template also wires in a Capacitor integration — a cross-platform runtime for running web apps natively on iOS, Android and desktop.
ionic start marvel-client tabs --type=react --capacitor
cd marvel-client
ionic serve
Serve the result, then open https://localhost:8100 in a browser.
Building a comic card
The first piece of app-specific UI is a reusable React component that takes comic details as props and renders them through Ionic's card primitives. Clear out the ExploreContainer files in src/components, drop their imports from the .tsx files under src/pages, and empty the <IonContent></IonContent> block in Tab1.tsx.
import React from 'react'; import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';import ExploreContainer from '../components/ExploreContainer';import './Tab1.css'; const Tab1: React.FC = () => { return ( <IonPage> ... <IonContent><IonHeader collapse="condense"> <IonToolbar> <IonTitle size="large">Tab 1</IonTitle> </IonToolbar> </IonHeader> <ExploreContainer name="Tab 1 page" /></IonContent> </IonPage> ); }; export default Tab1;
Create ComicCard.tsx in src/components:
import React, { FC } from 'react';
import { Comic } from '../interfaces/comic';
import { IonImg, IonCard, IonCardTitle, IonCardSubtitle, IonCardHeader } from '@ionic/react';
type Props = {
comic: Comic;
}
const ComicCard: FC = (props): JSX.Element => {
const { comic } = props;
return (
<IonCard>
<div
style={{
height: '250px',
overflow: 'hidden',
}}
>
<IonImg
src={`${comic.thumbnail.path}.${comic.thumbnail.extension}`}
/>
</div>
<IonCardHeader>
<IonCardSubtitle>
{comic.title}
</IonCardSubtitle>
<IonCardTitle>
<h3>
{comic.series.name}
</h3>
</IonCardTitle>
</IonCardHeader>
</IonCard>
);
}
export default ComicCard;
Cards in Ionic are assembled from subcomponents, so the title and series data land in IonCardTitle and IonCardSubtitle inside an IonCardHeader.
Wiring up the Marvel API
Fetching the comic data relies on axios:
yarn add axios
Under src, add the following folder and, inside it, an api.ts file:
# ~/Desktop/marvel-client/src
mkdir -p services
# ~/Desktop/marvel-client/src/services
touch api.ts
import axios from 'axios';
import { DataContainer } from '../interfaces/data-container';
import { Comic } from '../interfaces/comic';
const API_KEY = '813xxxxxxxxxxxxxxxxxx';
const api = axios.create({
baseURL: 'https://gateway.marvel.com:443/v1/public',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.response.use((response) => {
if (response.status === 200) {
return response.data.data;
}
});
export function getComics(): Promise<DataContainer<Comic>> {
return api.get('/comics', {
params: {
apikey: API_KEY,
limit: 10,
hasDigitalIssue: true,
},
});
}
Substitute your own key for API_KEY; one can be requested from the Marvel developer site. The account must also list localhost* among its authorized referrers, otherwise requests from the local dev server are refused.
The module exports a single function that calls GET /comics and returns a comics collection, filtered to digitally available issues.
Turning Tab1 into a page
Replace the contents of Tab1.tsx with the following.
import React, { FC, useState, useEffect } from 'react';
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonSpinner, IonGrid, IonRow, IonCol } from '@ionic/react';
import './Tab1.css';
import ComicCard from '../components/ComicCard';
import { Comic } from '../interfaces/comic';
import { getComics } from '../services/api';
const Tab1: FC = () => {
const [comics, setComics] = useState(null as Comic[] | null);
const [loading, setLoading] = useState(false);
const fetchComics = () => {
setLoading(true);
getComics().then((response) => {
if (response && response.results) {
setComics(response.results);
}
}).finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchComics();
}, [])
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
{(loading) && (
<div className="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
)}
{(comics) && (
<IonGrid>
<IonRow>
{comics.map((comic) => (
<IonCol key={comic.id} sizeXs="12" sizeSm="6" sizeMd="4" sizeLg="3" sizeXl="2">
<ComicCard comic={comic} />
</IonCol>
))}
</IonRow>
</IonGrid>
)}
</IonContent>
</IonPage>
);
};
export default Tab1;
Ionic pages are components reachable by route, and each one must have IonPage as its root component so page transitions behave correctly. IonHeader sits at the top of a page and can hold the title, an IonBackButton for navigation, or an IonSearchBar; it is optional. IonContent supplies the scrollable content area and its scroll events.
A fetchComics() function runs once inside useEffect(), calling getComics() and storing the response in component state via useState(). While the request is in flight, IonSpinner shows a spinning icon; on completion the results flow into ComicCard.
Favourites and offline storage
Saving comics as favourites makes the app genuinely mobile: Capacitor's Storage plugin keeps the selections on the device, so they can be reviewed offline, and a Toast plugin surfaces notifications. Create util.ts in src:
# ~/Desktop/marvel-client/src
touch util.ts
import { Plugins } from '@capacitor/core';
import { Comic } from './interfaces/comic';
const { Storage, Toast } = Plugins;
export const updateFavourites = async (comic: Comic): Promise => {
const saved = await Storage.get({ key: 'savedFavourites' });
const favourites: Comic[] | null = (saved && saved.value)
? JSON.parse(saved.value)
: null;
if (!favourites) {
const comics = [comic];
await Storage.set({
key: 'savedFavourites',
value: JSON.stringify(comics),
});
return Toast.show({
text: 'Added to favourites',
});
}
const copyOfFavourites = favourites.slice();
const { id } = comic;
const isSavedIndex = copyOfFavourites.findIndex((c) => c.id === id);
if (isSavedIndex !== -1) {
copyOfFavourites.splice(isSavedIndex, 1);
await Storage.set({
key: 'savedFavourites',
value: JSON.stringify(copyOfFavourites),
});
return Toast.show({
text: 'Removed from favourites',
});
} else {
copyOfFavourites.unshift(comic);
await Storage.set({
key: 'savedFavourites',
value: JSON.stringify(copyOfFavourites),
});
return Toast.show({
text: 'Added to favourites',
});
}
};
export const getFavourites = async (): Promise<Comic[] | null> => {
const saved = await Storage.get({
key: 'savedFavourites',
});
return (saved && saved.value)
? JSON.parse(saved.value)
: null;
};
export const checkFavourite = async (id: number): Promise<boolean> => {
const saved = await Storage.get({
key: 'savedFavourites',
});
const favourites: Comic[] | null = (saved && saved.value)
? JSON.parse(saved.value)
: null;
if (favourites) {
const isSavedIndex = favourites.findIndex((c) => c.id === id);
if (isSavedIndex !== -1) {
return true;
}
}
return false;
};
Three helpers come out of this file. updateFavourites() accepts one Comic object and acts as a toggle — writing it to device storage when absent, deleting it when present. getFavourites() returns the saved comics. checkFavourites() takes a Comic resource ID and reports true if it is among the saved comics, otherwise false.
Give ComicCard the ability to trigger that toggle:
import { star, starOutline } from 'ionicons/icons'; import * as utils from '../util';type Props = { comic: Comic; } const ComicCard: FC<Props> = (props): JSX.Element => { const { comic } = props;const [isFavourite, setIsFavourite] = useState(false); const checkFavourite = (): void => { utils.checkFavourite(comic.id).then((value: boolean) => { setIsFavourite(value); }); } useEffect(() => { checkFavourite(); });return ( <IonCard> ... <IonCardHeader> ... </IonCardHeader><IonCardContent> <IonButton onClick={(): void => { utils.updateFavourites(comic).finally(() => { checkFavourite(); }); }} > <IonIcon icon={(isFavourite) ? star : starOutline} color="light" /> {(isFavourite) ? 'Remove' : 'Add' } </IonButton> </IonCardContent></IonCard> ); }
An IonButton now calls updateFavourites() on click, flipping the saved state. The component also needs imports for the newly used Ionic pieces: IonButton, IonCardContent and IonIcon.
The saved comics get their own view. Replace Tab2.tsx with:
import React, { useState } from 'react';
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar, IonGrid, IonRow, IonCol, useIonViewWillEnter } from '@ionic/react';
import './Tab2.css';
import { Comic } from '../interfaces/comic';
import { getFavourites } from '../util';
import ComicCard from '../components/ComicCard';
const Tab2: React.FC = () => {
const [comics, setComics] = useState(null as Comic[] | null);
const loadComics = (): void => {
getFavourites().then((result) => {
if (result) {
setComics(result);
}
})
};
useIonViewWillEnter(() => {
loadComics();
});
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Favourites</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
{(comics) && (
<IonGrid>
<IonRow>
{comics.map((comic) => (
<IonCol key={comic.id} sizeXs="12" sizeSm="6" sizeMd="4" sizeLg="3" sizeXl="2">
<ComicCard comic={comic} />
</IonCol>
))}
</IonRow>
</IonGrid>
)}
</IonContent>
</IonPage>
);
};
export default Tab2;
Structurally this mirrors Tab1, except that the data comes from local storage rather than the API. The refresh call is driven by Ionic's useIonViewWillEnter() lifecycle hook instead of useEffect() — it fires as the page being navigated to comes into view. Note that Tab3 and its files, along with the related IonTab import in App.tsx, can be deleted.
Generating the Android build
Ionic supports both Capacitor and Cordova as runtimes; this walkthrough uses Capacitor to produce native project files. Start with a production build from the project root.
ionic build
Capacitor's CLI is reachable either through npx or through the ionic CLI.
With npx
npx cap add android
This adds the android platform; ios and electron are the other accepted values.
With ionic
The project was scaffolded with --capacitor, so Capacitor already holds the project's information and a platform can be added directly.
ionic capacitor add android
That installs the dependencies the android platform needs, generates the native Android project files, and copies across the assets produced by ionic build. With Android Studio installed, the project can be opened for further work.
ionic capacitor open android
Then build it.
Where to go next
This walkthrough covered the React integration in Ionic Framework, along with the Capacitor workflow used to ship a native binary — the Android target in this case. The UI component set goes well beyond the pieces exercised here, so the API docs are the next stop. The completed project lives on GitHub.
Source material
Related reading
- A Guide To Redux Toolkit With TypeScript
- Building Mobile Apps With Capacitor And Vue.js
- How To Build A Multilingual Website With Nuxt.js
- The Era Of Platform Primitives Is Finally Here
(ks, ra, yk, il, og, mrn)
Explore more on



