Rails 5.2 App with Webpacker and React - Part 5

In the last post we wired up some of the UI with remote requests to our local api from our SPA section using React. In this post we're going to continue fleshing out our CRUD features and wrap up the series.

I hope this has been helpful for anyone struggling to understand how to glue React and Rails together for building modern Javascript driven apps.

The Plan

  • Add Update Post features
  • Add Delete Post Features

Doesn't seem like many features but there's a bit of work involved. So lets' get started.

Add Edit Post Actions

We're going to handle the edit feature first which will involve some major changes. If you get stuck on something, just refer to the source code repo for help or hit me up on twitter: @tmartin8080

Update controllers

Let's first update our edit/show actions in the Rails controller to handle our json requests. Open app/controllers/posts_controller.rb and update the update and show actions:

    def show
      respond_with(@post, status: :ok)
    end
    
    ... 
    
    def update
      if @post.update(post_params)
        respond_with(@post, status: :ok, location: '/posts')
      else
        render json: { errors: @post.errors.full_messages }, status: :unprocessible_entity
      end
    end

This will respond with the post data and give us the location to redirect to after success. If there is an error it will respond with the message.

Update PostsIndex component

Open: app/frontend/app/components/Posts/index.js and adding an Actions column to our posts table:

<tr>
  <th>Title</th>
  <th>Description</th>
  <th>Actions</th>
</tr>

Rename PostNew component

As a normal part of development, we'll encounter situations where we need to make some improvements to previous code. The fact that our New and Edit form are virutally identical, it would be beneficial for us to refactor it to handle both situations. One benefit would be if we wanted to add a field to the form, we wouldn't have to manage two different components for the same form.

Important: We're going to rename our PostNew component to PostForm so it can be reused on the edit route.

Let's rename the file and update the references to it:

$ mv app/frontend/app/components/Posts/PostNew.js app/frontend/app/components/Posts/PostForm.js

And update the component class name and export default line to PostForm.

You'll start to see some errors in the app so we'll just follow them to update references to our renamed component:

Open app/frontend/app/components/App/index.js and update the import statement and the route:

import PostForm from 'components/Posts/PostForm'

...

<Route path="/posts/new" component={PostForm} />

Now we can add our Edit route above the previous one:

<Route path="/posts/:id" component={PostForm} />
<Route path="/posts/new" component={PostForm} />

Because we're using the <Switch> wrapper, the first route found will be rendered rather than both.

Add PostRow actions

Open app/frontend/app/components/Posts/PostRow.js and add update the content:

  import React from 'react'
  import { ButtonGroup, Button } from '@blueprintjs/core'
  import { Link } from 'react-router-dom'
  
  class PostRow extends React.Component {
  
    render() {
      const { post, handleDelete } = this.props
      return (
        <tr>
          <td>{post.title}</td>
          <td>{post.description}</td>
          <td>
            <ButtonGroup>
              <Link to={`/edit/${post.id}`}><Button icon="edit">Edit</Button></Link>      
            </ButtonGroup>
          </td>
        </tr>
      )   
    }       
  }         
          
  export default PostRow

This will give us an edit button using Link from react-router-dom so we can navigate to our Edit route.

Refactor PostForm component

So now we're ready to update our PostForm component to handle the case in which we are editing a post.

Because this series is concentrating on integrating React with Rails, we not going to go into too much detail about this updated component.

Open: app/frontend/app/components/Posts/PostForm.js and update it to have this content:

  import React from 'react'
  import { Redirect } from 'react-router'
  import {
    FormGroup, TextArea, InputGroup, Intent, Button, Callout
  } from '@blueprintjs/core'
  
  import { clientGet, clientPost, clientPatch } from 'modules/client';
  
  class PostForm extends React.Component {
  
    state = { 
      submitDisabled: true,
      redirectTo: '', 
      errorMessage: '', 
      post: {
        id: '', 
        title: '', 
        description: ''
      },  
      formAction: {
        text: 'Create',
        type: 'create'
      }   
    }
  
    componentDidMount() {
      const { id } = this.props.match.params
      if (id === 'new') return
      this.fetchPostData(id)
    }
  
    fetchPostData(id) {
      clientGet(`/posts/${id}`)
        .then(response => {
          this.setState({
            post: {
              id: response.data.id,
              title: response.data.title,
              description: response.data.description,
            },  
            formAction: {
              text: 'Update',
              type: 'update'
            }   
          })  
          this.validateForm()
        })  
        .catch(errorMessage => {
          this.setState({errorMessage: errorMessage})
        })  
    }
  
    handleChange = e => {
      const { name, value } = e.target
      const newPost = { ...this.state.post, [name]: value }
      this.setState({ post: newPost })
      this.validateForm()
    }
  
    validateForm = () => {
      const { post } = this.state
      if (post.title && post.description) {
        this.setState({submitDisabled: false})
      }   
    }
    
    handleSubmit = e => {
      e.preventDefault();
      const { post, formAction } = this.state
      let apiResponse
  
      if (formAction.type === 'create') {
        apiResponse = this.createPost(post)
      } else {
        apiResponse = this.updatePost(post)
      }
      apiResponse
        .then(response => {
          const { location } = response.headers
          if (location) {
            this.setState({redirectTo: location})
          } else {
            this.setState({redirectTo: '/posts'})
          }
        })
        .catch(errorMessage => {
          this.setState({errorMessage: errorMessage})
        })
    }
  
    createPost(post) {
      return clientPost('/posts', {
        title: post.title,
        description: post.description
      })
    }
  
    updatePost(post) {
      return clientPatch(`/posts/${post.id}`, {
        title: post.title,
        description: post.description
      })
    }
    
    render() {
      const { errorMessage, redirectTo, submitDisabled, post, formAction } = this.state
      if (redirectTo) {
        return <Redirect to={{ pathname: redirectTo, state: { message: `Successfully ${formAction.text}d post.` }}} />
      } else {
        return(
          <div className="post-new__wrapper">
            { errorMessage && <Callout title={errorMessage} intent={Intent.ERROR}/> }
            <form onSubmit={this.handleSubmit}>
              <h1>{formAction.text} 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">{formAction.text} Post</Button>
            </form>
          </div>
        )
      }
    }
  }
  export default PostForm
  

It's alot of code, but take your time to skim through it to see what's happening:

  • It sets an initial state, then grabs the id from react-router's params using componentDidMount.
  • If the param is new it loads the keeps the default state data and loads the Create Post path.
  • Otherwise it will fetch remote data for the post from the server, populate the form and load the Update Post path.

Test it in your browser

You should now be able to edit posts now in your dashboard. If there are any issues just follow them and refer to the source code if you get stuck.

Like I said earlier, we're not concentrating on React as much as we are focused on getting it working with a Rails application. There are many ways we could improve on this component, but let's just leave it as is for now and move onto our Delete feature.

This takes care of our CRU operations for CRUD. Now let's add the delete feature.

Add Delete Post

Update Rails controller

Let's again update our Rails controller to handle the delete action:

Open app/controllers/posts_controller.rb and update the destroy action:

    def destroy
      @post.destroy
      respond_with(@post, status: :created, location: '/posts')
      rescue => e
      render json: { errors: { error: e.message } }, status: :unprocessible_entity
    end

Set up client

We're going to add a method to PostsIndex because it holds the state of the current posts.

Open: app/frontend/app/components/Posts/index.js and add this new method just above render:

    handleDelete = id => {     
      this.setState({ loading: true })
      const { posts } = this.state
      clientDelete(`/posts/${id}`)
        .then(response => {    
          this.setState({      
            loading: false,    
            posts: posts.filter(p => p.id != id),
            message: 'Successfully deleted post'
          })                   
        })                     
        .catch(errorMessage => {
          this.setState({errorMessage: errorMessage})
        })
    }

This will send a DELETE request to the API server, remove the post, display a success message, then update the Posts list in state by filtered the deleted item

To pass this method down to our child PostRow component, just add it as a prop to PostRow in PostsIndex render method:

  <PostRow post={post} key={index} handleDelete={this.handleDelete} />

Then open app/frontend/app/components/Posts/PostRow.js which is where we'll add our new actions inside a new td tag:

Here is the updated component code:

  import React from 'react'
  import { ButtonGroup, Button } from '@blueprintjs/core'
  import { Link } from 'react-router-dom'
  
  class PostRow extends React.Component {
  
    render() {
      const { post, handleDelete } = this.props
      return (
        <tr>
          <td>{post.title}</td>
          <td>{post.description}</td>
          <td>
            <ButtonGroup>
              <Link to={`/edit/${post.id}`}><Button icon="edit">Edit</Button></Link>
              <Button
                icon="trash"   
                intent="danger"
                onClick={(e) => handleDelete(post.id)}
              ></Button>       
            </ButtonGroup>
          </td>
        </tr>
      )   
    }       
  }         
          
  export default PostRow

A few things to notice:

  • we imported Link from react-router-dom to handle the edit action
  • we're destructuring the new handleDelete method from props
  • we set up the onClick for the delete button by passing an arrow function that calls handleDelete with the current post id.

Wrap up

That wraps up our 5 Part series on integrating React into a Rails application. I really hope you enjoyed it, and were able to gain some understanding on out to work with these tools in your own projects.

You can grab the full source code for this series: https://github.com/devato/rails-webpacker-react

Here's a summary of everything we've accomplished in the series:

Part 1:

  • Completely remove and bypass the asset pipeline (sprockets)
  • Exclude turbolinks
  • Use webpacker to handle stylesheets and all other assets

Part 2:

  • Add user auth using Devise
  • Structure and organize our views
  • Structure and organize JS and assets
  • Add Blueprint.js library
  • Set up public application
  • Build a simple user dashboard

Part 3:

  • Added root element to Dashboard rails view for React to connect to.
  • Configured app pack to render on the root element
  • Built App and PageWrapper components to handle and render presentation components.
  • Added react-router to route requests
  • Added Blueprint.js navbar
  • Configured basic client react-router routes
  • Added logout feature

Part 4:

  • 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.

Part 5:

  • Add Upate Post features
  • Add Delete Post Features

We're planning on expanding this series into a full resource where we can go into great detail about every step, add many more features, and cover how to add tests and deploy the application to a production environment. If you're interested in this feel free to follow me on twitter: @tmartin8080 where I'll post updates.

Thanks for following along and happy hackin!

Series: