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
Source code on GitHub