Build a Serverless MERN Story App With Webtask.io -- Zero to Deploy: 2

Chris Nwamba
πŸ‘οΈ 3,960 views
πŸ’¬ comments

This is a continuation of the previous article where we discussed Webtask. We shared few Webtask concepts and built a RESTful API using Express and Node. Our data is persisted in a MongoDB database provisioned by Mongolab.

In this part of the article, we will consume the REST API in a React-based UI app. At the end, we will deploy the app to Github Pages so as to have both our API and Frontend available remotely.

Creating a React App

For maintenance purposes and task distribution among teams, it's always preferable to split the entire application into different projects. We have our API ready in a project directory, it's not much of good practice to build our React app right in the same directory. Rather, we will create an independent React project that will communicate with the API via endpoints.

Facebook makes creating a React project easy by providing a CLI tool for that:

# 1. Install CLI tool
npm install -g create-react-app
# 2. Create a React app, "wt-mern-ux"
create-react-app wt-mern-ux
# 3. cd into app
cd wt-mern-ux
# 4. Launch app
npm start

Component Structure

React is a component-based tool, therefore, it is easier to visualize what is expected from an app when the app's components hierarchical structure is analyzed. Let's have a look:

The App component is the container component as well as the 1st in the hierarchy. This makes it the entry point of our app thereby serving as the control unit for all other presentation components.

The obvious components are the presentation components because they paint the browser with contents and visuals. In that regards, we will build the app starting with presentation components and when a logic in App is needed, we will discuss that as well.

Story List

We need to read a list of stories from the API endpoint and display them on the webpage. This should be some pretty basic stuff:

// ./src/Components/StoryList/StoryList.js
import React from 'react'
// FlipMove for list animations
import FlipMove from 'react-flip-move';

import StoryItem from '../StoryItem/StoryItem'
import './StoryList.css'

export default ({stories, handleEdit, handleDelete}) => (
    <div className="StoryList clearfix">
        <FlipMove duration={350} easing="ease-in-out" enterAnimation="accordionHorizontal">
            {stories.map(story => <StoryItem
                story={story}
                key={story._id}
                handleEdit={handleEdit}
                handleDelete={handleDelete}
            />)}
        </FlipMove>
    </div>
)

And there it is; A functional component that receives a list of stories, and some event handlers from the App component. It iterates over this stories and passes each of the items down to a child StoryItem component. The event handlers, handleEdit and handleDelete are also passed down to StoryItem.

Let's see how App fetches stories:

import React, { Component } from 'react';
import Axios from 'axios';

import StoryList from './Components/StoryList/StoryList';
import './App.css';

class App extends Component {

  constructor() {
    super();

    this.state = {
      stories: [],
    };

    this.apiUrl = 'https://wt-<WEBTASK-ACCOUNT>-0.run.webtask.io/wt-mern-api/stories'

    this.handleEdit = this.handleEdit.bind(this);
    this.handleDelete = this.handleDelete.bind(this)
  }

  componentDidMount() {
    // Fetch stories from API and
    // and update `stories` state
    Axios.get(this.apiUrl).then(({data}) => {
      this.setState({stories: data});
    })
  }

  handleEdit(id) {
     // Open a modal to update a story
     // Uncomment this line later
    // this.openModal(this.state.stories.find(x => x._id === id))
  }

  handleDelete(id) {
    // Delete story from API
    Axios.delete(`${this.apiUrl}?id=${id}`).then(() => {
       // Remove story from stories list
      const updatedStories = this.state.stories.findIndex(x => x._id === id);
      this.setState({states: [...this.state.stories.splice(updatedStories, 1)]})
    })
  }

  render() {

    return (
      <div className="App">
        <div className="col-md-4 col-md-offset-4 Story">

          <div className="StoryHeader">
            <h2>Stories</h2>
          </div>
          {/* pass stories and 
          event handlers down to StoryList*/}
          <StoryList
              stories={this.state.stories}
              handleEdit={this.handleEdit}
              handleDelete={this.handleDelete}
          />

          <div className="StoryFooter">
            <p>Thank you!</p>
          </div>

        </div>
      </div>
    );
  }
}

export default App;
  • componentDidMount is a lifecycle method. It is called when your component is ready. This feature makes it a great place to fetch bootstrap data. In that case, we are requesting a list of stories from our server and setting the stories state to whatever is returned.
  • handleEdit method is meant to pop up a modal with a form and an existing story to be updated. /(Don’t be scared :), we’ll take a look at that soon) / We will see about that soon.
  • handleDelete makes a DELETE request for a single resource. If that was successful, rather than re-fetch the whole list, we just remove the item from the stories list.
  • The lost <StoryList /> receives the stories and event handlers as props. Functions are first class objects so it's possible to pass them around.
  • <FlipMove /> is an animation component that helps us apply different animation effects to the list when adding and removing items from the list.

Story Item

StoryItem is yet another presentation component. It takes the iteration values passed down from StoryList and displays each of them. It also receives the event handlers and binds them to some buttons.

// ./src/Components/StoryItem/StoryItem.js
import React from 'react'
import './StoryItem.css'

export default class StoryItem extends React.Component {
  render() {
    const {
      story,
      handleEdit,
      handleDelete
    } = this.props;
    return (
        <div className="StoryItem clearfix">
          <div className="col-sm-9 StoryItem__content">
            <h4>{story.author}</h4>
            <p>{story.content}</p>
          </div>
          <div className="col-sm-3 StoryItem__control">
            <span
                className="glyphicon glyphicon-edit"
                onClick={handleEdit.bind(this, story._id)}
            />
            <span
                className="glyphicon glyphicon-remove"
                onClick={handleDelete.bind(this, story._id)}
            />
          </div>
        </div>
    )
  }
}

This component doesn't have any direct relationship with App container component, so we don't have to worry about that. It's also a class component rather than functional component because FlipMove uses React refs for list items which functional components do not support.

Story Button and Modal

We need to add a button which when clicked, launches a Modal to create a new story. /Nothing strange here!/ Just a stateless functional component that returns a HTML button:

// ./src/Components/StoryButton/StoryButton.js
import React from 'react';
import './StoryButton.css'

export default ({handleClick}) =>
   <button className="StoryButton" onClick={handleClick}> + </button>

It's housed by the App components:

import React, { Component } from 'react';

import StoryButton from './Components/StoryButton/StoryButton';
...

class App extends Component {

  constructor() {
    super();

    this.state = {
      ...
    };

    ...
    this.openModal = this.openModal.bind(this);
  }

  ...

  openModal(story) {
    // Launches Modal. We will un-comment later
    /* this.setState({modalIsOpen: true});
    if(story) {
      this.setState({story});
    } */
  }

  render() {

    return (
      <div className="App">
        <div className="col-md-4 col-md-offset-4 Story">

          ...

        </div>

        <StoryButton handleClick={this.openModal.bind(this, null)} />
      </div>
    );
  }
}

export default App;

The handleClick property holds an event handler to open a modal. Now this Modal is not a mystery, let's have a look at its component:

import React from 'react';
import Modal from 'react-modal';

import './StoryModal.css'

// Modal custom styles
// Basically centering stuff
const customStyles = {
  content : {
    top                   : '50%',
    left                  : '50%',
    right                 : 'auto',
    bottom                : 'auto',
    marginRight           : '-50%',
    transform             : 'translate(-50%, -50%)'
  }
};

export default class ModalComponent extends React.Component {

  constructor(props) {
    super(props)
    // Internal state
    this.state = {
      author: '',
      content: '',
      _id: ''
    }
    this.handleInputChange = this.handleInputChange.bind(this);
  }

  handleInputChange(e) {
    // Re-binding author and content values
    if(e.target.id === 'author') {
      this.setState({author: e.target.value})
    }
    if(e.target.id === 'content') {
      this.setState({content: e.target.value})
    }
  }

  componentWillReceiveProps({story}) {
    // Update story state anytime
    // a new props is passed to the Modal
    // This is handy because the component
    // is never destroyed but it's props might change
    this.setState(story)
  }

  render() {
    const {
        modalIsOpen,
        closeModal
    } = this.props;
    // Use React's Modal component
    return (
        <Modal
            isOpen={modalIsOpen}
            onRequestClose={closeModal.bind(this, null)}
            style={customStyles}
            shouldCloseOnOverlayClick={false}
            contentLabel="Story Modal"
        >

          <div className="Modal">
            <h4 className="text-center">Story Form</h4>
            <div className="col-md-6 col-md-offset-3">
              <form>
                <div className="form-group">
                  <label>Name</label>
                  <input type="text" value={this.state.author} onChange={this.handleInputChange} id="author" className="form-control"/>
                </div>
                <div className="form-group">
                  <label>Content</label>
                  <textarea value={this.state.content} onChange={this.handleInputChange} cols="30" id="content" className="form-control"></textarea>
                </div>
                <div className="form-group">
                  <button
                      className="ModalButton"
                      onClick={closeModal.bind(this, this.state)}
                  >Save</button>
                  <button
                      className="ModalButton ModalButton--close"
                      onClick={closeModal.bind(this, null)}
                  >Cancel</button>
                </div>
              </form>
            </div>
          </div>
        </Modal>
    )
  }
}

Notes

  • We are using the Modal component from the react-modal library.
  • /Because it has a form to keep track of, StoryModal possesses an internal state. For this reason, it’s not entirely a presentation component./ has a form to keep track of, for that reason, it's not entirely a presentation component because of it's internal state.
  • The StoryModal component can be shown or hidden but not created/mounted /n/or destroyed. Therefore, if its props changes, we update the story state with the new story props. This is why instead of using componentDidMount, we are using componentWillReceiveProps. /A/ Possible occurrence of such /a/ situation is when story state changes from empty property values to values that need to be updated.

Next, we need to uncomment openModal and handleEdit logics in App:

// ./src/App.js
...
constructor() {
 super();

 this.state = {
   modalIsOpen: false,
   }
 };
openModal(story) {
  this.setState({modalIsOpen: true});
   if(story) {
     this.setState({story});
   }
 }
handleEdit(id) {
  this.openModal(this.state.stories.find(x => x._id === id))
 }
 ...

If openModal is passed a story, we will set the state's story object to its content. This is passed down to the Modal for us to edit. If no story is passed, we just create a new story via the form.

Let's now complete the Modal wire by writing logic for what happens when the Modal is closed:

import React, { Component } from 'react';
import Axios from 'axios';

import StoryModal from './Components/StoryModal/StoryModal';
import './App.css';

class App extends Component {

  constructor() {
    super();

    this.state = {
      modalIsOpen: false,
      stories: [],
      story: {
        author: '',
        content: '',
        _id: undefined
      }
    };

    ...
  }

 ...

  closeModal(model) {
    this.setState({modalIsOpen: false});
    if(model) {
      if(!model._id) {
        Axios.post(this.apiUrl, model).then(({data}) => {
          this.setState({stories: [data, ...this.state.stories]});
          this.setState({isLoading: false})
        })
      } else {
        Axios.put(`${this.apiUrl}?id=${model._id}`, model).then(({data}) => {
          const storyToUpdate = this.state.stories.find(x => x._id === model._id);
          const updatedStory = Object.assign({}, storyToUpdate, data)
          const newStories = this.state.stories.map(story => {
            if(data._id === story._id) return updatedStory;
            return story;
          })
          this.setState({stories: newStories});
        })
      }
    }
    this.setState({story: {
      author: '',
      content: '',
      _id: undefined
    }})
  }

  ...

  render() {

    return (
      <div className="App">

        <StoryModal
            modalIsOpen={this.state.modalIsOpen}
            story={this.state.story}
            closeModal={this.closeModal}
        />
      </div>
    );
  }
}

export default App;

Notes

Three possible outcomes:

  1. A story model/data from the form does NOT exist. This means no argument was sent to closeModal when calling it. If that's the case, nothing should happen. A typical example is the Modal's Cancel button.

  2. If model._id is NOT undefined, it means the model existed before, so we just need to make an update write rather than creating a new entry entirely. We do this by using axios's put method to send a PUT request with the payload to a single resource. The response contains the updated record which we can shove into the array.

  3. In a situation where model._id is undefined, then we create a new story using a POST request and adding the new story to the top of our array.

Exercise

Extend the app a little bit to show a loading spinner for every HTTP request that's fired.

Deploy to Github Pages

To deploy the React app to GitHub pages, we need to carefully follow the steps below:

  • Build the app to generate production bundle:
npm run build

This will generate a production bundle in the build directory.

  • Create a Github repository for the app and add the following line in your package.json:
"homepage": "https://<GH-USERNAME>.github.io/<REPO-NAME>",
  • Install gh-pages:
npm install --save gh-pages
  • Add a script to deploy the build directory:
"scripts": {
   ...
   "deploy": "gh-pages -d build"
 }
  • Run the deploy script
npm run deploy

Conclusion

Hopefully, I have proven to you that you do not need to be a backend expert before you can make your UI come to life. Tools like Webtask and even Node with a little bit of digging docs will provide a server for you while you focus on writing the ever awesome JavaScript. The frontend can be anything; not necessarily React.

Chris Nwamba

49 posts

JavaScript Preacher. Building the web with the JS community.