Developing a Real World App in Ionic React

Karan Gandhi - Jun 7 '19 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can use npx:

npx create-react-app conduit --typescript
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's initialize Ionic in this project.

ionic init "Conduit" --type=custom
Enter fullscreen mode Exit fullscreen mode

We need to enable Capacitor in our project

ionic integrations enable capacitor
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now, we can add a platform to our project.

npx capacitor add android

Enter fullscreen mode Exit fullscreen mode

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/"
  };
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Protect your Code with Jscrambler

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

Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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> 
    );       
  }
}  
Enter fullscreen mode Exit fullscreen mode

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>      
  )
}
Enter fullscreen mode Exit fullscreen mode

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>           
  )
}
Enter fullscreen mode Exit fullscreen mode

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);
      }
    )
}
Enter fullscreen mode Exit fullscreen mode

Finally, let’s return the component based on user state.

  render() {   
      return (
        <>
        {localStorage.getItem("isLogin") === "true" ? this.loggedInCard() : this.loggedOutCard()} 
        </>               
      );    
  }
}
Enter fullscreen mode Exit fullscreen mode

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>    
      </>
      );
    }

Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

We will initialize showdown using:

  this.converter = new Showdown.Converter({
          tables: true,
          simplifiedAutoLink: true,
          strikethrough: true,
          tasklists: true,
          requireSpaceBeforeHeadingText: true
        });  

Enter fullscreen mode Exit fullscreen mode

And we will display markdown display using:

   <div dangerouslySetInnerHTML={{ __html: this.converter.makeHtml(article.body)}}></div>

            </div>
Enter fullscreen mode Exit fullscreen mode

The rest of the source can be viewed here.

Protect your React App with Jscrambler

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>
  </>
    )
  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>    
Enter fullscreen mode Exit fullscreen mode

Creating a Build Using Capacitor

We will use run build to build our React project:

npm run build
Enter fullscreen mode Exit fullscreen mode

We will then use Capacitor to copy the files:

npx capacitor copy
Enter fullscreen mode Exit fullscreen mode

Now, we will need to compile the project natively:

npx capacitor open android|ios
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .