Rails 5 API, React, Bootstrap, CRUD example

In this post I’ll outline steps to create an example CRUD app using a Rails 5 API with a React frontend. I wrote this on OSX using RMV, NVM, and Homebrew.

Part 1: Rails API

Scaffold Rails project

# create directory
mkdir api
cd api

# setup RMV files for Ruby version and gemset
echo ruby-2.5.3 > .ruby-version
echo rails5-api-react-frontend > .ruby-gemset
rvm use .

# install rails gem
gem install rails

# scaffold project with API and PostgreSQL flags
rails new --api -d postgresql .

# initialize database
rake db:create && rake db:migrate

Create Post model with title and body

# execute generator
rails g model Post title:string body:string

Update migration to not allow null in fields. Edit file: db/migrate/SOMEDATE_create_posts.rb

class CreatePosts < ActiveRecord::Migration[5.2]
  def change
    create_table :posts do |t|
      t.string :title, null: false
      t.string :body, null: false
      t.timestamps
    end
  end
end

Execute rake db:migrate to create posts table.

Add basic model validation, edit file: app/models/post.rb

class Post < ApplicationRecord
  validates :title, :body, presence: true
end

Create controller

# execute generator, namespaced to "api" for Post model
rails g scaffold_controller api/posts --api --model-name=Post

Update generated controller file and set required params and remove location: @post from create method. edit file: app/controllers/api/posts_controller.rb

class Api::PostsController < ApplicationController
  before_action :set_post, only: %i[show update destroy]

  def index
    render json: Post.all
  end

  def show
    render json: @post
  end

  def create
    post = Post.new(post_params)

    if post.save
      render json: post, status: :created
    else
      render json: post.errors, status: :unprocessable_entity
    end
  end

  def update
    if @post.update(post_params)
      render json: @post
    else
      render json: @post.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :body)
  end
end

Add API namespaced controller routes, edit file: config/routes.rb

Rails.application.routes.draw do
  namespace :api do
    resources :posts
  end
end

Enable CORS for frontend acces by adding gem 'rack-cors' to Gemfile and executing bundle install. Update CORS initializer, file: config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:3001'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Start Rails API via: rails s -p 3000 -b 0.0.0.0

Test API endpoints via CURL

# create new post
curl -XPOST -H "Content-Type: application/json" 'http://localhost:3000/api/posts' -d '{
  "post": {
    "title": "test title",
    "body": "test body"
  }
}' 2>/dev/null | python -m json.tool

# response
{
    "body": "test body",
    "created_at": "2019-01-13T20:50:58.909Z",
    "id": 1,
    "title": "test title",
    "updated_at": "2019-01-13T20:50:58.909Z"
}

# list all posts
curl -XGET -H "Content-Type: application/json" 'http://localhost:3000/api/posts' 2>/dev/null | python -m json.tool

# response
[
    {
        "body": "test body",
        "created_at": "2019-01-13T20:50:58.909Z",
        "id": 1,
        "title": "test title",
        "updated_at": "2019-01-13T20:50:58.909Z"
    }
]

Part 2: React frontend

To scaffold the React frontend I decided to use reactstrap for Bootstrap 4 components and React Router for navigational components.

# set nodejs version
nvm use v10.15.0

# install npm packages (global)
npm install yarn create-react-app -g

# create new react app. NOTE: execute this outside the rails folder
npx create-react-app frontend
cd frontend

# create NVM file
nvm current > .nvmrc

# add npm dependencies
yarn add react-router-dom reactstrap bootstrap

# start frontend
yarn start

To get started I added a JS module to handle all Rails API calls using the Fetch API. Each module export method returns an Array containing error (Boolean) and data (or errors). new file: src/Api.js

const apiHost = 'http://localhost:3000'

const capitalizeFirstLetter = (string) => {
  return string.charAt(0).toUpperCase() + string.slice(1)
}

const collectErrors = (response) => {
  let errors = []

  if (response.status === 404) {
    errors.push(response.error)
    return errors
  }

  const fields = Object.keys(response)
  fields.forEach(field => {
    const prefix = capitalizeFirstLetter(field)
    response[field].forEach(message => {
      errors.push(`${prefix} ${message}`)
    })
  })
  return errors
}

const deletePost = (id) => {
  let response_ok = null
  return fetch(`${apiHost}/api/posts/${id}`, {
    method: 'delete',
    headers: {
      'Content-Type': 'application/json'
    }
  })
  .then(response => {
    response_ok = response.ok
    if (response.status === 204) {
      return ''
    } else {
      return response.json()
    }
  })
  .then(response => {
    if (response_ok) {
      return [false, response]
    } else {
      return [true, collectErrors(response)]
    }
  })
}

const getPosts = () => {
  let response_ok = null
  return fetch(`${apiHost}/api/posts`, {
      method: 'get',
      headers: {
        'Content-Type': 'application/json'
      }
    })
    .then(response => {
      response_ok = response.ok
      return response.json()
    })
    .then(response => {
      if (response_ok) {
        return [false, response]
      } else {
        return [true, collectErrors(response)]
      }
    })
}

const getPost = (id) => {
  let response_ok = null
  return fetch(`${apiHost}/api/posts/${id}`, {
    method: 'get',
    headers: {
      'Content-Type': 'application/json'
    }
  })
  .then(response => {
    response_ok = response.ok
    return response.json()
  })
  .then(response => {
    if (response_ok) {
      return [false, response]
    } else {
      return [true, collectErrors(response)]
    }
  })
}

const savePost = (data, id=null) => {
  let apiUrl = `${apiHost}/api/posts`
  let apiMethod = 'post'
  if (id) {
    apiUrl = `${apiUrl}/${id}`
    apiMethod = 'put'
  }

  const body = JSON.stringify({
    post: data
  })

  let response_ok = null
  return fetch(apiUrl, {
    method: apiMethod,
    headers: {
      'Content-Type': 'application/json'
    },
    body: body
  })
  .then(response => {
    response_ok = response.ok
    return response.json()
  })
  .then(response => {
    if (response_ok) {
      return [false, null]
    } else {
      return [true, collectErrors(response)]
    }
  })
}

module.exports = {
  savePost: savePost,
  getPost: getPost,
  deletePost: deletePost,
  getPosts: getPosts
}

Next I updated the main App file and integrated with React Router. It defines a Router component with a list of Routes mapping to components. edit file: src/App.js

import React, { Component } from 'react'
import {
  BrowserRouter as Router,
  Route,
} from 'react-router-dom'

import Posts from './Posts'
import PostForm from './PostForm'
import PostDelete from './PostDelete'

class App extends Component {
  render() {
    return (
      <Router>
        <div>
          <Route exact path='/' component={Posts} />
          <Route exact path='/posts' component={Posts} />
          <Route exact path='/posts/new' component={PostForm} />
          <Route
            exact path="/posts/:id/edit"
            render={(routeProps) => (
              <PostForm {...routeProps} />
            )}
          />
          <Route
            exact path="/posts/:id/delete"
            render={(routeProps) => (
              <PostDelete {...routeProps} />
            )}
          />
        </div>
      </Router>
    )
  }
}

export default App

Next is the top level Posts component. It fetches existing posts, conditionally renders the PostsTable, and provides a button to add a new post. new file: src/Posts.jsx

import React, { Component } from 'react'
import { Link } from "react-router-dom"
import { Container, Row, Col, Alert } from 'reactstrap'
import PostsTable from './PostsTable'

const Api = require('./Api.js')

class Posts extends Component {
  constructor(props) {
    super(props)
    this.state = {
      posts: [],
      isLoaded: false,
      error: null
    }
  }

  componentDidMount() {
    Api.getPosts()
      .then(response => {
        const [error, data] = response
        if (error) {
          this.setState({
            isLoaded: true,
            posts: [],
            error: data
          })
        } else {
          this.setState({
            isLoaded: true,
            posts: data
          })
        }
      })
  }

  render() {
    const { error, isLoaded, posts } = this.state

    if (error) {

      return (
        <Alert color="danger">
          Error: {error}
        </Alert>
      )

    } else if (!isLoaded) {

      return (
        <Alert color="primary">
          Loading...
        </Alert>
      )

    } else {

      return (
        <Container>
          <Row>
            <Col>
              <PostsTable posts={posts}></PostsTable>
              <Link className="btn btn-primary" to="/posts/new">Add Post</Link>
            </Col>
          </Row>
        </Container>
      )

    }

  }
}

export default Posts

Here is the PostsTable component; it utilizes ReactStrap for Bootstrap form components and provides a link to edit and delete each post. new file: src/PostsTable.jsx

import React, { Component } from 'react'
import { Link } from "react-router-dom"
import { Table } from 'reactstrap'

class PostsTable extends Component {
  constructor(props) {
    super(props)
    this.state = {
      posts: props.posts
    }
  }

  render() {
    const posts = this.state.posts
    if (posts.length === 0) {
      return <div></div>
    } else {
      return (
        <Table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Title</th>
              <th>Body</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {posts.map(post => (
              <tr key={post.id}>
                <td>{post.id}</td>
                <td>{post.title}</td>
                <td>{post.body}</td>
                <td>
                  <Link className="btn btn-success" to={`/posts/${post.id}/edit`}>Edit</Link>{' '}
                  <Link className="btn btn-danger" to={`/posts/${post.id}/delete`}>Delete</Link>
                </td>
              </tr>
            ))}
          </tbody>
        </Table>
      )
    }
  }
}

export default PostsTable

Here is the PostForm component; it is used for editing and creating posts. On mount it conditionally (from passed params) fetches the existing post and sets the intial state. As the user enters field data onChange callbacks set the state of the component, and onSubmit the API is called to save the post. new file: src/PostForm.jsx

import React, { Component } from 'react'
import { Redirect } from 'react-router'
import { Container, Row, Col, Alert, Button, Form, FormGroup, Label, Input } from 'reactstrap'

const Api = require('./Api.js')

class PostForm extends Component {
  constructor(props) {
    super(props)

    this.state = {
      post: {
        id: this.getPostId(props),
        title: '',
        body: '',
      },
      redirect: null,
      errors: []
    }

    this.setTitle = this.setTitle.bind(this)
    this.setBody = this.setBody.bind(this)
    this.handleSubmit = this.handleSubmit.bind(this)
  }

  getPostId(props) {
    try {
      return props.match.params.id
    } catch (error) {
      return null
    }
  }

  setTitle(event) {
    let newVal = event.target.value || ''
    this.setFieldState('title', newVal)
  }

  setBody(event) {
    let newVal = event.target.value || ''
    this.setFieldState('body', newVal)
  }

  setFieldState(field, newVal) {
    this.setState((prevState) => {
      let newState = prevState
      newState.post[field] = newVal
      return newState
    })
  }

  handleSubmit(event) {
    event.preventDefault()

    let post = {
      title: this.state.post.title,
      body: this.state.post.body
    }

    Api.savePost(post, this.state.post.id)
      .then(response => {
        const [error, errors] = response
        if (error) {
          this.setState({
            errors: errors
          })
        } else {
          this.setState({
            redirect: '/posts'
          })
        }
      })
  }

  componentDidMount() {
    if (this.state.post.id) {
      Api.getPost(this.state.post.id)
        .then(response => {
          const [error, data] = response
          if (error) {
            this.setState({
              errors: data
            })
          } else {
            this.setState({
              post: data,
              errors: []
            })
          }
        })
    }
  }

  render() {
    const { redirect, post, errors } = this.state

    if (redirect) {
      return (
        <Redirect to={redirect} />
      )
    } else {

      return (
        <Container>
          <Row>
            <Col>
              <h3>Edit Post</h3>

              {errors.length > 0 &&
                <div>
                  {errors.map((error, index) =>
                    <Alert color="danger" key={index}>
                      {error}
                    </Alert>
                  )}
                </div>
              }

              <Form onSubmit={this.handleSubmit}>
                <FormGroup>
                  <Label for="title">Title</Label>
                  <Input type="text" name="title" id="title" value={post.title} placeholder="Enter title" onChange={this.setTitle} />
                </FormGroup>
                <FormGroup>
                  <Label for="body">Body</Label>
                  <Input type="text" name="body" id="body" value={post.body} placeholder="Enter body" onChange={this.setBody} />
                </FormGroup>
                <Button color="success">Submit</Button>
              </Form>
            </Col>
          </Row>
        </Container>
      )
    }
  }
}

export default PostForm

Below is the PostDelete component. It simply calls the Api delete method and redirects the user back to the Posts component. new file: src/PostDelete.jsx

import React, { Component } from 'react'
import { Redirect } from 'react-router'

const Api = require('./Api.js')

class PostDelete extends Component {
  constructor(props) {
    super(props)

    this.state = {
      id: props.match.params.id,
      redirect: null
    }
  }

  componentDidMount() {
    Api.deletePost(this.state.id)
      .then(response => {
        const [error] = response
        if (error) {
          // TODO: set flash
        }
        this.setState({
          redirect: '/posts'
        })
      })
  }

  render() {
    if (this.state.redirect) {
      return (
        <Redirect to={this.state.redirect} />
      )
    } else {
      return (
        <div></div>
      )
    }
  }

}

export default PostDelete

Last I updated the index.js file to add the Bootstrap CSS include, edit file: src/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import 'bootstrap/dist/css/bootstrap.min.css'
import App from './App'
import * as serviceWorker from './serviceWorker'

ReactDOM.render(<App />, document.getElementById('root'))

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister()

Frontend screenshot

react table crud

Source code on GitHub

Updated: