Ionic recently added support for the React and Vue frameworks. Traditionally, Ionic used Angular and Cordova frameworks to create their apps. With Ionic 4, we can develop framework-agnostic apps. In this tutorial, we will create a basic Ionic app in the React Framework.
RealWorld and Conduit
The folks at Thinkster are behind a project known as RealWorld project. Conduit is a Medium clone which can be built using multiple stacks. We can mix and match different types of backends and frontends. Every backend and frontend follow the same API spec.
Unlike simple CRUD ToDo Apps, Conduit is a full-fledged CMS. Implementing the project requires some effort.
Getting Started
For this tutorial, we will be covering the following points.
- Home Page (With and Without Auth)
- List of Articles
- Favorite an Article (Auth Required)
- Navigate to Article Page
- Navigate to Author Profile Page
- Display your Feed (Auth Required)
- Display Popular Tags.
- Filter Articles by Tags.
- Article Page (With and Without Auth)
- Read Article
- List Comments
- Add Comment (Auth Required)
- Navigate to Author Page
- Settings Page (Auth required)
- Modify user Settings
- Profile Page (With and Without Auth)
- Display User details
- Display Posts by user
- Display Posts Favorited by user
- Follow User (Auth Required)
- Create Article (Auth Required)
- Create Article
- Login / Signup Page
- Signup User
- Login User
Understanding the Conduit API
Conduit has a production API endpoint here. We will use that endpoint for this tutorial. Alternatively, you can set up your own backends using a variety of languages.
We will be using:
- /articles: For fetching articles, comments
- /users: For creating and Authenticating users
- /profiles: For following profiles
- /tags: For fetching tags
Users are authenticated via a JWT token in headers.
Setting up the Project
The Ionic CLI currently doesn't support React templates. We will be using create-react-app to set up our project. Use:
npm i -g create-react-app
create-react-app conduit --typescript
Alternatively, we can use npx:
npx create-react-app conduit --typescript
Navigate to the folder and install Ionic and React router:
cd conduit
npm install @ionic/react react-router react-router-dom @types/react-router @types/react-router-dom
Let's initialize Ionic in this project.
ionic init "Conduit" --type=custom
We need to enable Capacitor in our project
ionic integrations enable capacitor
A file capacitor.config.json
is generated in our project directory. In the file, we need to change the build directory from "webDir": "www"
to "webDir": "build"
.
Create a build folder OR generate a build using:
npm run build
Now, we can add a platform to our project.
npx capacitor add android
Additionally, we will be using this folder structure.
- src
- components/
- pages/
- constants.ts
Let's set up our constants file.
export interface AppConfig {
API_ENDPOINT : string
}
export const CONFIG: AppConfig = {
API_ENDPOINT : "https://conduit.productionready.io/api/"
};
In the App.tsx
file, we need to import the Ionic CSS files.
import '@ionic/core/css/core.css';
import '@ionic/core/css/ionic.bundle.css';
Developing the App
An offline user has access to the Home, Article, Profile and Login pages. An online user has access to the Home, Article, Profile, Settings and Login Pages.
There are 70+ Ionic components we can use to speed up our development. First, let's set up routing.
Routing
We are using React Router to manage Routing. We will be using IonRouterOutlet
to manage routes. The key difference with the website is that users can't navigate with urls. We are also creating a custom component SideMenu
for side menu navigation. IonMenuToggle
is used to toggle the menu in components.
A loggedIn key and an Event Listener are used to manage user state. The token obtained from authentication will be saved in localstorage.
class App extends Component {
constructor(props: any){
super(props);
}
render() {
return (
<Router>
<div className="App">
<IonApp>
<IonSplitPane contentId="main">
<SideMenu></SideMenu>
<IonPage id="main">
<IonRouterOutlet>
<Route exact path="/" component={HomePage} />
<Route exact path="/login" component={LoginPage} />
<Route path="/article/:slug" component={ArticlePage} />
<Route path="/profile/:authorname" component={ProfilePage} />
<Route path="/settings" component={SettingsPage} />
<Route path="/newarticle" component={NewArticlePage} />
</IonRouterOutlet>
</IonPage>
</IonSplitPane>
</IonApp>
</div>
</Router>
);
}
}
export default App;
Side menu Component:
class SideMenu extends React.Component<any, any> {
constructor(props: any){
super(props);
this.state = {
isLoggedIn: localStorage.getItem("isLogin") ? localStorage.getItem("isLogin") :"false",
routes: {
appPages: [
{ title: 'Home', path: '/', icon: 'home' },
],
loggedOutPages: [
{ title: 'Login', path: '/login', icon: 'log-in' },
] }
}
window.addEventListener('loggedIn', (e: any) => {
this.setState({
isLoggedIn : e['detail'].toString(),
routes : {
appPages: [
{ title: 'Home', path: '/', icon: 'home' },
],
loggedInPages: [
{ title: 'My Profile', path: '/profile/'+localStorage.getItem("username"), icon: 'person'},
{ title: 'New Article', path: '/newarticle', icon: 'create' },
{ title: 'Settings', path: '/settings', icon: 'settings' },
{ title: 'Logout', path: '/login', icon: 'log-out' }
],
loggedOutPages: [
{ title: 'Login', path: '/login', icon: 'log-in' },
] }
})
});
}
renderMenuItem(menu: any) {
return (
<IonMenuToggle key={menu.title} auto-hide="false">
<IonItem>
<IonIcon name={menu.icon} ></IonIcon>
<Link replace className="sidemenu-link" to={menu.path} >{menu.title}</Link>
</IonItem>
</IonMenuToggle>
)
}
render() {
return (
<IonMenu side="start" menuId="first" contentId="main">
<IonHeader>
<IonToolbar color="success">
<IonTitle>Start Menu</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList>
{this.state.routes.appPages.map((art: any) => this.renderMenuItem(art))}
{this.state.isLoggedIn === "true" ? <> {this.state.routes.loggedInPages.map((art: any) =>
this.renderMenuItem(art))} </> :<> {this.state.routes.loggedOutPages.map((art: any) =>
this.renderMenuItem(art))} </> }
</IonList>
</IonContent>
</IonMenu>
)
}
}
export default SideMenu
Home Page
As mentioned above, the Home Page has a list of articles. The /articles
endpoint provides us an array of 20 articles.
A normal user can:
- View a list of articles
- Navigate to a full article
- Navigate to author profile
- Filter articles by tags
An authenticated user can additionally:
- View his/her article feed
- Favorite an article
We will create the ArticleCard
and TagsCloud
components to make our tasks simpler.
Additionally, Header
is a wrapper around Ionic Header with title
as props.
<IonHeader>
<IonToolbar >
<IonTitle class="header" color="success">{this.props.title}</IonTitle>
<IonMenuButton slot="start"></IonMenuButton>
</IonToolbar>
</IonHeader>
TagsCloud
is a component to retrieve popular tags. We receive an array of tags at the endpoint /tags
. We will be passing a function onTagClick
in props. So, we will be able to filter the articles using the clicked tag. We are using IonChip
to render the tag chips.
handleClick(tag: any) {
console.log(tag);
this.props.onTagClick(tag);
}
componentDidMount() {
fetch(CONFIG.API_ENDPOINT+"tags")
.then(res => res.json())
.then(
(res) => {
this.setState({
tags: res.tags
});
},
(err) => {
console.log(err)
}
)
}
render() {
return (
<div className="tagcloud">
<IonLabel text-center>
Popular Tags
</IonLabel>
<div>
{this.state.tags.map((tag: any, index: number) =>
<IonChip color="success" key={index}>
<IonLabel onClick={() => this.handleClick(tag)}>{tag}</IonLabel>
</IonChip>
)}
</div>
</div>
);
}
}
The ArticleCard
component returns an IonItem
and IonItemSliding
for logged in users. A user can swipe and favorite an article if he/she chooses to.
Let's create a function loggedOutCard
to return an IonItem
.
loggedOutCard() {
return (
<IonItem >
<IonAvatar slot="start">
<img src={this.props.src} />
</IonAvatar>
<IonLabel>
<p className="title">{this.props.title}</p>
<IonGrid >
<IonRow>
<IonCol class="author" size="6">
<Link className="link" to={this.profileLink}>
{this.props.author}</Link>
</IonCol>
<IonCol size="6" text-right>
<Link className="link" to={this.routeLink}>Read More</Link>
</IonCol>
</IonRow>
</IonGrid>
</IonLabel>
</IonItem>
)
}
We create a function loggedInCard
to return an IonSlidingItem
.
loggedInCard(){
return (
<IonItemSliding>
{this.loggedOutCard()}
<IonItemOptions side="end">
<IonItemOption color={this.state.favorited ? "success": "light"} onClick={this.favoriteArticle}>
<IonIcon color={this.state.favorited ? "light": "success"} class="icon-blog-card" name="heart" />{this.state.favoritesCount}</IonItemOption>
</IonItemOptions>
</IonItemSliding>
)
}
Authenticated users can favorite an article. We will create a function favoriteArticle
to achieve this.
favoriteArticle = (params: any) => {
console.log(params);
let url = CONFIG.API_ENDPOINT+"articles/" + this.props.slug + '/favorite';
let method;
if (!this.state.favorited) {
method = 'POST'
} else {
method = "DELETE"
}
fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": "Token " + localStorage.getItem("token"),
}
})
.then(res => res.json())
.then(
(res) => {
this.setState({
favorited: res.article.favorited,
favoritesCount: res.article.favoritesCount,
})
},
(err) => {
console.error(err);
}
)
}
Finally, let’s return the component based on user state.
render() {
return (
<>
{localStorage.getItem("isLogin") === "true" ? this.loggedInCard() : this.loggedOutCard()}
</>
);
}
}
After fetching the articles from the /articles
endpoint, we will iterate the ArticleCard
component in our Home Page.
render() {
return (
<>
<Header title="Home"></Header>
<IonContent>
<IonSegment onIonChange={this.toggle} color="success">
<IonSegmentButton value="Global" color="success" checked={this.state.segment==='Global' }>
<IonLabel>Global Feed</IonLabel>
</IonSegmentButton>
{localStorage.getItem("isLogin") === "true" ? <IonSegmentButton value="myfeed" color="success"
checked={this.state.segment==='myfeed' }>
<IonLabel>Your Feed</IonLabel>
</IonSegmentButton> : '' }
</IonSegment>
<IonList>
{this.state.articles.map((article: any) =>
<ArticleCard key={article.slug} title={article.title} src={article.author.image} description={article.description} favorited={article.favorited} favoritesCount={article.favoritesCount} slug={article.slug} author={article.author.username}></ArticleCard>
)}
</IonList>
<TagCloud onTagClick={(e: any) => this.handleTagClick(e)} ></TagCloud>
</IonContent>
</>
);
}
The toggle
and handleTagClick
functions are in the source code.
Article Page
We are fetching an individual article by slug
in the article page. The body of the article is in Markdown format. To display the article, we will be using showdown to convert it. Additionally, comments for the article are fetched from the /articles/slug/comments
endpoint and logged in users can post a comment. We will be creating a separate Comment
component to iterate over the list. To post comments, logged in users can use IonTextArea
.
npm i showdown @types/showdown
We will initialize showdown using:
this.converter = new Showdown.Converter({
tables: true,
simplifiedAutoLink: true,
strikethrough: true,
tasklists: true,
requireSpaceBeforeHeadingText: true
});
And we will display markdown display using:
<div dangerouslySetInnerHTML={{ __html: this.converter.makeHtml(article.body)}}></div>
</div>
The rest of the source can be viewed here.
Login Page
Login Page is a basic form Page. We will be accepting email and password. The /users/login
endpoint accepts the POST request and returns a JWT token. We will save the JWT token in localstorage to manage our auth. In order to toggle the side menu, we will be using standard JS events. Also, we will be signing up users using the /users
endpoint. Additionally, we are using the IonToast
component to display error messages. The code used for the Login Page is below. IonInput
uses CustomEvent
and we can use the onIonChange
event to bind the input to the state.
updateUserName = (event: any) => {
this.setState({ username: event.detail.value });
};
login= () => {
let url , credentials;
if(this.state.action == 'Login'){
url = CONFIG.API_ENDPOINT + '/users/login';
credentials = {
"user": {
"email": this.state.email,
"password": this.state.password
}
}
} else {
url = CONFIG.API_ENDPOINT + '/users';
credentials = {
"user": {
"email": this.state.email,
"password": this.state.password,
"username": this.state.username
}
}
}
fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(credentials)
})
.then((res) => {
console.log(res);
if(res.status == 200){
return res.json();
} else {
if(this.state.action == 'SignUp') {
throw new Error("Error creating user");
} else {
throw new Error("Error Logging in")
}
}
} )
.then(
(result) => {
console.log(result);
localStorage.setItem("token",result.user.token);
localStorage.setItem("username", result.user.username);
localStorage.setItem("isLogin", "true");
localStorage.setItem("email", result.user.email);
this.event = new CustomEvent('loggedIn', {
detail: true
});
window.dispatchEvent(this.event);
this.props.history.push('/blog');
},
(error) => {
console.error(error);
this.setState({toastMessage: error.toString(), toastState: true});
}
)
}
render(){
return(
<>
<Header title="Login"></Header>
<IonContent padding>
<div className="ion-text-center">
<img src={image} alt="logo" width="25%" />
</div>
<h1 className="ion-text-center conduit-title">conduit</h1>
<IonToast
isOpen={this.state.toastState}
onDidDismiss={() => this.setState(() => ({ toastState: false }))}
message= {this.state.toastMessage}
duration={400}
>
</IonToast>
<form action="">
<IonItem>
<IonInput onIonChange={this.updateEmail} type="email" placeholder="Email"></IonInput>
</IonItem>
{this.state.action === 'SignUp' ?
<IonItem>
<IonInput onIonChange={this.updateUserName} type="text" placeholder="Username"></IonInput>
</IonItem>
: <></> }
<IonItem>
<IonInput onIonChange={this.updatePassword} type="password" placeholder="Password"></IonInput>
</IonItem>
</form>
<IonButton onClick={this.login}>{this.state.action}</IonButton>
</IonContent>
<IonFooter>
<IonToolbar text-center>
Click here to <a onClick={this.toggleAction}>{this.state.action === 'Login'? 'SignUp' : 'Login'}</a>
</IonToolbar>
</IonFooter>
</>
)
}
New Article Page
This page is a form page like the login page. However, for the article body, we will be using a Markdown editor instead of a text area. For this tutorial, we are going to use react-mde.
npm install --save react-mde
The form code is as below:
<form onSubmit={this.submitArticle}>
<IonInput type="text" placeholder="Title" onIonChange={this.titleChange} class="border-input"></IonInput>
<IonInput type="text" placeholder="What's this article about" onIonChange={this.descriptionChange} class="border-input"></IonInput>
<ReactMde
onChange={this.handleBodyChange}
onTabChange={this.handleTabChange}
value={this.state.articleBody}
selectedTab={this.state.tab}
generateMarkdownPreview={markdown =>
Promise.resolve(this.converter.makeHtml(markdown))
}
/>
<IonInput type="text" placeholder="Enter Tags" class="border-input" onIonChange={this.tagsChange}></IonInput>
<IonButton expand="block" onClick={this.submitArticle}>Submit Article</IonButton>
</form>
Creating a Build Using Capacitor
We will use run build
to build our React project:
npm run build
We will then use Capacitor to copy the files:
npx capacitor copy
Now, we will need to compile the project natively:
npx capacitor open android|ios
Final Thoughts
My mileage while developing Conduit varied a lot. Conduit doesn't have a spec for mobile, so it took a while to figure out the best workflow.
We can improve the app by using middleware like Redux, using skeletons loaders at places, integrating infinite scroll and handling errors better. To complete the app experience, we will have to modify the backend so it can handle push notifications. A notification system makes sense if you receive a new follower or if a person you are following publishes a new post.
As far as Ionic React is concerned, it felt like I was developing a React web application using a set of predefined components. Components like IonInfiniteScroll
are still WIP and we can hope to get them soon. Personally, I missed the tight integration of the framework and the side menu template. We can expect full Ionic CLI and Cordova support in the future.
Android users can check the app here.
Lastly, if you're building an application with sensitive logic, be sure to protect it against code theft and reverse-engineering by following our guides for React and for Ionic.
Originally published by Karan Gandhi in the Jscrambler Blog.