Rails 5.2 App with Webpacker and React - Part 4

We're continuing a short series we've been working on using React and Webpacker, replacing the Rails Asset Pipeline.

There are various ways to design the architecture for an application like this, but we're focusing on keeping assets and the rendering of React components within the Rails templating system.

This is a longer Post but we will cover a lot of ground to get our application up and running and solve some complex problems. Here's the plan:

The Plan

  • Configure webpacker to find our modules and components more easily.
  • Add new client routes using react-router for our SPA.
  • Add PostNew component for creating posts.
  • Add Rails API endpoints for creating/listing posts.
  • Validate new post form data.
  • Add system wide client module for connecting to remote APIs from React.
  • Handle Errors from remote api calls.
  • Redirect using react-router to send state messages.
  • List available posts from the database on the posts index page.

Let's dig in.

Webpacker Configuration Changes

Open config/webpacker.yml and add to the resolved_paths array:

resolved_paths: ['app/frontend/app']

This simple change allows us to import components and modules without the need to keep track of ../../ relative paths.

You can also update any imports to use just the base directory. eg. import Thing from 'components/Thing'

Create posts/new client Route

Open app/frontend/app/components/App/index.js and change it's contents to this:

import React, { Component } from 'react'
import { Switch, Route } from 'react-router' 
import { BrowserRouter } from 'react-router-dom'
import PageWrapper from 'components/PageWrapper'
import Dashboard from 'components/Dashboard'
  
import PostsIndex from 'components/Posts' 
import PostNew from 'components/Posts/PostNew'
    
class App extends Component {  
      
  render() {
    return (
      <BrowserRouter>          
        <PageWrapper>          
          <Switch> 
            <Route exact path="/" component={Dashboard} />
            <Route exact path="/posts" component={PostsIndex} />
            <Route path="/posts/new" component={PostNew} />
          </Switch> 
        </PageWrapper>         
      </BrowserRouter>         
    )
  } 
} 
  
export default App 

Create PostNew Component

We're going to try to use generic react without too many packages in order to get our footing working with React.

Let's create a new component: app/frontend/app/components/Posts/PostNew.js

import React from 'react'      
import {
  FormGroup, TextArea, InputGroup, Intent, Button, Callout
} from '@blueprintjs/core'    
  
class PostNew extends React.Component {
    
  render() {
    return(
      <div className="post-new__wrapper">
        <h1>Create a Post</h1> 
        <FormGroup label="Post Title" labelFor="post-title" labelInfo="(required)" >
          <InputGroup id="post-title" placeholder="Enter a title" />
        </FormGroup>           
        <FormGroup label="Post Description" labelFor="post-description" labelInfo="(required)" >
          <TextArea id="post-description" fill={true} placeholder="Description" />
        </FormGroup>           
        <Button disabled={true} icon="tick-circle">Create Post</Button>
      </div>
    )   
  }     
}           
export default PostNew                   

This is the basic layout for a Form using Blueprint.js.

Now that we have everything prepared, let's add a "Create Post" link to app/frontend/app/components/Posts/index.js

import { Link } from 'react-router-dom'
...
<h1>Posts</h1>     
<Link to="/posts/new">New Post</Link>
...

So, when on the Posts page, there is a new link to create a post, and we should have the makings of a Post creating form.

Create Post API Endpoint

There are some good articles on how to build an API with Rails, but we're going to stick to the basics in order to get our app up and running quickly so we can focus on the SPA and connecting the dots.

Let's start with a basic Post resource:

$ rails g resource Post title description:text user:references
$ rails db:migrate

Then let's override the controller to setup a scaffold:

$ rails g scaffold_controller Post --skip-template-engine

Overwrite the previous controller so we don't have to wire up all the actions.

Next, let's move our route inside of the authenticated block.

# config/routes.rb
authenticated :user do
  root 'app/dashboard#index'
  resources :posts
  match "*path", to: 'app/dashboard#index', via: :get
end

When you try to reload the page, you'll notice that Rails is now trying to load the request. Let's fix this by adding a constraint to the posts route:

constraints: lambda { |req| req.format == :json }

This way, the posts controller will only pick up requests with json format.

Now we're ready to start sending requests to our "API" or Rails app to manage our data.

Validating Create Form with State

The plan is to hold the value of our form data in state to determine if the form is valid. We're keeping our SPA and simple as possible for now, and we may expand and improve on it in future posts.

For now, let's jump back into app/frontend/app/components/Posts/PostNew.js and add interactivity to the form:

First we're going to add some defaults to our state. (If you're not familiar with state in React, it might be a good time to read more about it.)

class PostNew extends React.Component {

  state = {
    submitDisabled: true,
    post: {
      title: '',
      description: ''
    }
  } 
  
  render() {
    ...
  }

Next, we'll update our form to use the component's state values. Here is the updated render method:

  render() {                   
    const { submitDisabled, post } = this.state
    return(                    
      <div className="post-new__wrapper">
        <form onSubmit={this.handleSubmit}> 
          <h1>Create a Post</h1>
          <FormGroup label="Post Title" labelFor="post-title" labelInfo="(required)" >
            <InputGroup        
              id="post-title"  
              placeholder="Enter a title"
              value={post.title}
              name="title"     
              onChange={this.handleChange}/>
          </FormGroup>
          <FormGroup label="Post Description" labelFor="post-description" labelInfo="(required)" >
            <TextArea          
              id="post-description"
              fill={true} placeholder="Description"
              value={post.description}
              name="description"
              onChange={this.handleChange}/>
          </FormGroup>         
          <Button disabled={submitDisabled} type="submit" icon="tick-circle">Create Post</Button>
        </form> 
      </div>                   
    ) 
  }   

Notice we're pulling in data from our state, and handling the changes with a custom handleChange function.

Let's add a placeholder for handleSubmit:

  handleSubmit = e => {
    e.preventDefault();
    console.log('form submitted')
  }

And here is our handleChange function:

  handleChange = (e) => { 
    const { name, value } = e.target
    const newPost = { ...this.state.post, [name]: value }
    this.setState({ post: newPost })
  }

This function will handle both the title and description fields, and determines which field to update based on the target's name attribute.

NOTE: { ...this.state.post, [name]: value } is known as Spread Syntax. It creates a new object and replaces only the key/value pairs passed to it.

Let's create another function to check if our form is valid, and call it after we handle the input changes:

  handleChange = (e) => { 
    ...
    this.validateForm()
  }     

  validateForm = () => {
    const { post } = this.state
    if (post.title && post.description) {
      this.setState({submitDisabled: false})
    }
  }

Now if you fill out the post title and description, the submit button will be enabled and we can continue submitting the form. We should also see "form submitted" in the console.

Create API Client

Next we're going to create an API client that will handle RESTful calls, and errors sent from our SPA.

First, we're going to add axios using yarn:

$ yarn add axios

Next, create app/frontend/app/modules/client/index.js with the following content:

import axios from 'axios'
const SERVER_DOMAIN = process.env.SERVER_DOMAIN

const getHeaders = () => ({
  headers: { 
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  } 
})

// HTTP GET Request - Returns Resolved or Rejected Promise
export const clientGet = (path: string) => { 
  return new Promise((resolve, reject) => { 
    axios.get(`${path}`, getHeaders())
      .then(response => { resolve(response) })
      .catch(error => { reject(handleError(error)) })
  })
}
// HTTP PATCH Request - Returns Resolved or Rejected Promise
export const clientPatch = (path: string, data: any) => { 
  return new Promise((resolve, reject) => { 
    axios.patch(`${path}`, data, getHeaders())
      .then(response => { resolve(response) })
      .catch(error => { reject(handleError(error)) })
  })
}
// HTTP POST Request - Returns Resolved or Rejected Promise
export const clientPost = (path: string, data: any) => { 
  console.log(`${path}`)
  return new Promise((resolve, reject) => { 
    axios.post(`${path}`, data, getHeaders())
      .then(response => { resolve(response) })
      .catch(error => { reject(handleError(error)) })
  })
}
// HTTP DELETE Request - Returns Resolved or Rejected Promise
export const clientDelete = (path: string) => { 
  return new Promise((resolve, reject) => { 
    axios.delete(`${path}`, getHeaders())
      .then(response => { resolve(response) })
      .catch(error => { reject(handleError(error)) })
  })
}

const handleError = (error: any) => { 
  const { status, message } = error
  switch (status) { 
    case 401:
      // do something when you're unauthenticated
    case 403:
      // do something when you're unauthorized to access a resource
    case 500:
      // do something when your server exploded
    default:
      // handle normal errors with some alert or whatever
  } 
  return message
}

This will handle different method calls to our remote endpoints, and also handle any errors by returning an error message. Take your time to read through the client file to understand how it works.

Update PostNew Component

Let's update our handleSubmit function to use our new client.

Open app/frontend/app/components/Posts/PostNew.js and below validateForm, add the updated handleSubmit function:

    handleSubmit = e => {
      e.preventDefault();
      const { post } = this.state
      clientPost('/posts', { title: post.title, description: post.description })
        .then(data => {
          console.log(data)
        })
        .catch(errorMessage => {
          console.log(errorMessage)
        });
    }

Now if you submit the form, it will actually hit the server endpoint, but will return an error. Let's follow the errors, and update our server API in order to get things submitting properly.

Update Posts#create Endpoint

Open app/controllers/posts_controller.rb and first update the post_params method to permit our fields:

# app/controllers/posts_controller.rb

    def post_params
      params.permit(:title, :description)
    end

Next, because we want meaningful responses from our API, we going to handle them the using the responders gem which is a dependency of devise so we already have it installed.

Let's go back to our contoller and update the create method to return something meaningful. Open app/controllers/posts_controller.rb

# app/controllers/posts_controller.rb

    def create
      @post = Post.new(post_params)
      current_user.posts << @post
      if @post.save
        respond_with(@post, status: :created, location: '/posts')
      else
        render json: { errors: @post.errors.full_messages }, status: :unprocessible_entity
      end
    end

We need to add the post to our current_user which is also available to us because we're still inside our Rails context. This is one of the many benefits of keeping our SPA within the Rails context.

Notice also that we're responding with a location on success.

Now, our endpoint will successfully create the post, but we still need to handle the response with our handleSubmit method. Instead of just throwing window.location into the mix, we're going to use react-router's redirect to load the proper component, and send a state message along with it.

Open app/frontend/app/components/Posts/PostNew.js and update the handleSubmit function with this:

    handleSubmit = e => {
      e.preventDefault();
      const { post } = this.state
C     clientPost('/posts', { title: post.title, description: post.description })
        .then(response => {
          const { location } = response.headers 
          if (location) {
            this.setState({redirectTo: location})
          } else {
            this.setState({redirectTo: '/posts'})
          }
        })
        .catch(errorMessage => {
          console.log(errorMessage)       
        });
    }

As you'll notice, we're grabbing the location response header to determine where to redirect the user to. There is also a default location in case that isn't set for whatever reason.

We also need to make a few other changes:

  1. Add redirectTo to our default state in order for our Component to load properly:
    state = {                  
      submitDisabled: true,    
      redirectTo: '',          
      post: {                  
        title: '',             
        description: ''        
      }                        
    }
  1. Add the Redirect component to our render method. Here's the full updated render method:
    render() {
      const { redirectTo, submitDisabled, post } = this.state
      if (redirectTo) {
        return <Redirect to={{ pathname: redirectTo, state: {message: 'Successfully created post.'}}} />
      } else {
        return(
          <div className="post-new__wrapper">
            <form onSubmit={this.handleSubmit}>
              <h1>Create a Post</h1>          
              <FormGroup label="Post Title" labelFor="post-title" labelInfo="(required)" >
                <InputGroup
                  id="post-title"
                  placeholder="Enter a title"
                  value={post.title}
                  name="title"
                  onChange={this.handleChange}/>
              </FormGroup>
              <FormGroup label="Post Description" labelFor="post-description" labelInfo="(required)" >
                <TextArea
                  id="post-description"
                  fill={true} placeholder="Description"
                  value={post.description}
                  name="description"
                  onChange={this.handleChange}/>
              </FormGroup>
              <Button disabled={submitDisabled} type="submit" icon="tick-circle">Create Post</Button>
            </form>
          </div>
        )
      }

This adds a condition that when redirectTo is set in the state, it'll return whatever's set, and send a message along with the state. Otherwise it'll just load the new form as usual.

Handling Post Error

Before we go too much futher, there's the sad path we need to consider in which an error occurs while posting the post to the server.

Let's handle this in our PostNew component.

  1. We're going to import some elements from Blueprint.js:
import {
  FormGroup, TextArea, InputGroup, Intent, Button, Callout
} from '@blueprintjs/core'
  1. Add errorMessage to our default state:
    state = {
      submitDisabled: true,
      redirectTo: '',
      errorMessage: '', 
      post: { 
        title: '',
        description: ''
      }   
    } 
  1. And set the errorMessage in the handleSubmit function's catch function:
    ...
    .catch(errorMessage => {
      this.setState({errorMessage: errorMessage})
    })

Now we can update the render function:

  1. Grab the errorMessage constant from state:
const { errorMessage, redirectTo, submitDisabled, post } = this.state
  1. Then, display the error if it exists: inside the main wrapper:
...
<div className="post-new__wrapper">
  { errorMessage && <Callout title={errorMessage} intent={Intent.Error}/> }
  ...

Great, now how do we force an error from the API call?

Open app/controllers/posts_controller.rb and comment out the connection of the new post to a user:

# current_user.posts << @post

Now when you try to submit a post, it will display an error response from the API. We could customize the error message to be more meaningful, but you get the idea, so we'll just move on for now. In future posts, we'll cover refactoring the application to handle the particulars.

Don't forget to uncomment the line before continuing

Updating PostsIndex Component

Ok, so we're handling form validation, we're submitting posts successfully, We're handling errors. The next step is to update our PostsIndex component to display the messages we send from the Redirect component, and list all the available posts.

Display Redirect message

Open app/frontend/app/components/Posts/index.js and change it's contents to this:

import React from 'react'
import { Link } from 'react-router-dom'
import { Callout, Intent, Spinner } from "@blueprintjs/core";

class PostsIndex extends React.Component { 

  constructor(props) { 
    super(props)
    this.state = { 
      loading: true,
      message: ((props.location || {}).state || {}).message,
    } 
  } 

  render() { 
    const { message, loading } = this.state
    if (loading) { 
      return <Spinner size={Spinner.SIZE_STANDARD}  />
    } else { 
      return(
        <div>
          { message && <Callout title={message} intent={Intent.SUCCESS}/> } 
          <h1>Posts</h1>
          <Link to="/posts/new">New Post</Link>
        </div>
      ) 
    } 
  } 
}
export default PostsIndex

We're using a component constructor function to set the initial state based on incoming data from react-router's Redirect component. Then we're displaying the message in a Callout component from Blueprint.js

The updated component also includes a loading state which will display a spinner when set to true.

Listing Available Posts

Next let's update our component to fetch Posts stored in the database. This step will involve making another API request to the server.

Here is the full updated component, take some time to skim through it for the changes:

import React from 'react'
import { Link } from 'react-router-dom'
import { Callout, Intent, Spinner } from "@blueprintjs/core";
import { clientGet } from 'modules/client'

class PostsIndex extends React.Component {

  constructor(props) {
    super(props)
    this.state = {
      loading: true,
      message: ((props.location || {}).state || {}).message,
      posts: [],
      errorMessage: '',        
    } 
    this.fetchPosts()          
  }     

  fetchPosts = () => {
    clientGet('/posts')
      .then(response => {      
        console.log(response) 
        this.setState({
          loading: false,
          posts: response.data, 
        })
      })
      .catch(errorMessage => { 
        this.setState({errorMessage: errorMessage})
      })
  } 

  render() {
    const { errorMessage, message, loading } = this.state
    if (loading) {             
      return <Spinner size={Spinner.SIZE_STANDARD}  />
    } else {
      return(
        <div>
          { message && <Callout title={message} intent={Intent.SUCCESS}/> }
          { errorMessage && <Callout title={errorMessage} intent={Intent.ERROR}/> }
          <h1>Posts</h1>
          <Link to="/posts/new">New Post</Link>
        </div>
      )   
    }       
  }       
}
export default PostsIndex

We added some defaults to the state, and are calling fetchPosts from the constructor to load posts into state, then setting loading to false so our component renders.

The updates also include rendering an error message from the API if one exists, similar to our PostNew component.

You can see the response data logged to the console if you would like to look through it. You can also install React Developer Tools which creates a new tab on the Chrome developer tools window to browse the components current state.

Update API Endpoint

We'll also need to update our index controller action in Rails to render the posts output.

Open app/controllers/posts_controller.rb and update the index action to respond with the current user's posts:

    def index                  
      @posts = current_user.posts   
      respond_with(@posts)     
    end

This should give us a nice clean list of posts for our user.

Display List of Posts

Let's update PostsIndex to loop through the posts list and display them:

  1. Import new elements from blueprintjs:
import { HTMLTable, Callout, Intent, Spinner } from "@blueprintjs/core";
  1. Import a new Post component: (we'll need to create it)
import Post from 'components/Posts/PostRow'
  1. Update render method to display posts:
 render() {                   
    const { errorMessage, message, loading, posts } = this.state
    if (loading) {             
      return <Spinner size={Spinner.SIZE_STANDARD}  />
      } else {                 
      return(                  
        <div>                  
          { message && <Callout title={message} intent={Intent.SUCCESS}/> }
          { errorMessage && <Callout title={errorMessage} intent={Intent.ERROR}/> }
          <h1>Posts</h1>       
          <Link to="/posts/new">New Post</Link>
          <HTMLTable>          
            <thead>            
              <tr>             
                <th>Title</th> 
                <th>Description</th>
              </tr>            
            </thead>
            <tbody>
              { posts.length > 0
                ? posts.map((post, index) => <PostRow post={post} key={index} /> )
                : <tr key={0}><td>No posts</td></tr>
              }
            </tbody>
          </HTMLTable>
        </div>
        )
    }

You'll notice we're using the common map method to loop through the posts if they exist, and render a PostRow subcomponent.

Let's create app/frontend/app/components/Posts/PostRow.js and add the basic rendering:

import React from 'react'      
                               
class PostRow extends React.Component {

  render() {                   
    const { post } = this.props
    return (
      <tr>
        <td>{post.title}</td>  
        <td>{post.description}</td> 
      </tr>
    )   
  }     
}       
export default PostRow

Now our posts should be displayed on the index page, with the title and description under their headings.

Summary

I agree, there was a lot of code and steps needed to get our simple application this far. But we've covered a lot of ground, and now have an application that fetches data from an API and we learned how to connect data into our React components state system.

Here are the features we have developed for this part of the series:

  • Configure webpacker to find our modules and components more easily.
  • Add new client routes using react-router for our SPA.
  • Add PostNew component for creating posts.
  • Add rails api endpoints for creating/listing posts.
  • Validate new post data
  • Add system wide client module for connecting to remote APIs from React.
  • Handle Errors from remote api calls
  • Redirect using react-router to send state messages.
  • List available posts from the database on the posts index page.

Here's the repo that holds all the source code, each part has it's own tag, so you can use git checkout tag/part-4 to get it running:
https://github.com/devato/rails-webpacker-react

In the next part, we'll continue building the remaining CRUD features, and make some improvements along the way.

If all goes well, we should be able to wrap this series up with one more post and as always, leave any comments/suggestions/feedback in the comments.

Series:

Follow Me on Twitter and happy coding!