Hello everyone. Today, we will be building a todo app to showcase how to use React with Rails 6. This tutorial will be as in-depth as possible and will focus on performing CRUD operations. Our Todo app will support seeing a list of your todo, creating a today, making a todo as done, and deleting a todo. We will be leveraging react to build a reactive app and use material UI for the design.
Prerequisites
- Rails 6
- Nodejs - version 8.16.0 and above
- PostgreSQL - version 12.2
- Yarn - version 1.21.1
Step 1 - Creating the Rails app
Let's start off by creating the rails app and specifying postgres as our database.
$ rails new todo-app -d=postgresql
The above command creates the todo-app using postgres as its DB, sets up webpack, install yarn dependencies etc. Once installation is complete, run the command below to create your database.
$ rails db:create
cd
into your app and start up your server.
$ cd todo-app
$ rails s
We should have our app up and running.
Step 2 - Install React
Stop the rails server and run the command below to install and setup React.
$ rails webpacker:install:react
This should add react and react-dom to your package.json
file and setup a hello.jsx
file in the javascript pack.
Step 3 - Generate a root route and use react for the view
We are going to generate a controller called Todo
with an index route that we will eventually change to the root route.
$ rails g controller Todos index
Next we update the routes.rb
file to make the index route our root route
# config/routes.rb
root "todos#index"
Next, we need to create an entry point to your React environment by adding one of the javascript packs to your application layout. Let's rename the app/javascript/packs/hello_react.jsx
to app/javascript/packs/index.jsx
$ mv app/javascript/packs/hello_react.jsx app/javascript/packs/index.jsx
Update the application.html.erb
javascript pack tag to point to the index.jsx file
<-! app/views/layouts/application.html.erb ->
<%= javascript_pack_tag 'index' %>
Delete all the content in your app/views/todos/index.html.erb
and start up your server again. We should have the react app content rendered on our root route.
Note: I am using localhost:3001
because I have another app running on port 3000
. If you want to change your port, you just need to update line 13 of your config/puma.rb
file.
Congratulations. You have a react app rendering the view of our rails app.
Step 4 - Generate and Migrate the Todo model
Next we need to create a model for Todo. Run the code below to do that:
$ rails g model Todo title:string completed:boolean
This should generate a model file and a migration file. Next, we need to edit the migration file to set the completed
attribute to false as a default.
# db/migrate/migration_file_name.rb
class CreateTodos < ActiveRecord::Migration[6.0]
def change
create_table :todos do |t|
t.string :title
t.boolean :completed, default: false
t.timestamps
end
end
end
Next we run migration command to create the table in our db.
$ rails db:migrate
Step 5 - Add seed data to our database
Let's add some seed that we can use for our index page before we add the ability to create a todo. Open up your seed file and add the following code:
# db/seeds
5.times do |index|
Todo.create!({ title: "Todo #{index + 1}", completed: false})
end
puts "5 uncompleted todos created"
5.times do |index|
Todo.create!({ title: "Todo #{index + 1}", completed: true})
end
puts "5 completed todos created"
Next, we ran the rails seeds command to add the data to our database.
$ rails db:seed
Step 6 - Build out our index page
First let's add bootstrap to our app for styling.
$ yarn add bootstrap jquery popper.js
Next, we update the routes file and add a controller action to return our todo list.
Note: Every React route must point to a controller action that has an empty view file else when you refresh the page, it will get a missing route error. You need to remember this is you decide to add other routes. We won't be doing that in this article.
# config/routes.rb
Rails.application.routes.draw do
root "todos#index"
get "todos/all_todos"
end
# app/controllers/todo_controller
def all_todos
completed = Todo.where(completed: true)
uncompleted = Todo.where(completed: false)
render json: { completed: completed, uncompleted: uncompleted }
end
As you can see above, i added a all_todos
route and a corresponding controller action.
Next, we create a component folder in the javascript folder and add a Home.jsx
file where we will be performing all our actions. Add the following code to it:
# components/Home.jsx
import React, { useState, useEffect } from 'react';
import Loader from './Loader';
import Pending from './Pending';
import Completed from './Completed';
const Home = () => {
const [todos, setTodos] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const url = "/todos/all_todos";
fetch(url)
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error("Network response was not ok.");
})
.then(response => {
setTodos(response);
setLoading(false);
})
.catch(() => console.log('An error occurred while fetching the todos'));
}, []);
return (
<div className="vw-100 vh-100 primary-color d-flex justify-content-center">
<div className="jumbotron jumbotron-fluid bg-transparent">
<div className="container secondary-color">
<h1 className="display-4">Todo</h1>
<p className="lead">
A curated list of recipes for the best homemade meal and delicacies.
</p>
<hr className="my-4" />
{
loading ? <Loader /> : (
<div>
<Pending pending={todos.pending} />
<hr className="my-4" />
<Completed completed={todos.completed} />
</div>
)
}
</div>
</div>
</div>
)
}
export default Home;
Lets create the loader, pending and completed components
# components/Loader.jsx
import React from 'react';
const Loader = () => (
<div className="d-flex justify-content-center">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
)
export default Loader;
# components/Pending.jsx
import React from 'react';
const Pending = ({ pending }) => {
return (
<div>
<h4>Pending</h4>
{pending.map((todo, i) => {
return (
<div class="form-check" key={i}>
<input class="form-check-input" type="checkbox" checked={todo.completed} value="" id={`checkbox${todo.id}`} />
<label class="form-check-label" for={`checkbox${todo.id}`}>
{todo.title}
</label>
</div>
)
})}
</div>
)
}
export default Pending;
# components/Completed.jsx
import React from 'react';
const Completed = ({ completed }) => {
return (
<div>
<h4>Completed</h4>
{completed.map((todo, i) => {
return (
<div class="form-check" key={i}>
<input class="form-check-input" type="checkbox" checked={todo.completed} value="" id={`checkbox${todo.id}`} disabled />
<label class="form-check-label" for={`checkbox${todo.id}`}>
{todo.title}
</label>
</div>
)
})}
</div>
)
}
export default Completed;
Next, we update our index.jsx
file to this:
import React from 'react'
import ReactDOM from 'react-dom'
import 'bootstrap/dist/css/bootstrap.min.css';
import $ from 'jquery';
import Popper from 'popper.js';
import 'bootstrap/dist/js/bootstrap.bundle.min';
import Home from '../components/Home';
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(
<Home />,
document.body.appendChild(document.createElement('div')),
)
})
And our app should be looking like what we have below. It shows a list of pending todos above and completed todos below.
Step 7 - Editing a todo item
We are going to be splitting the edit into 2. First will be editing the todoItem title and the second is to mark it as completed. Let's hop on the first option. I am going to be moving the html that is returned in the Pending.jsx
loop to a component of its own so we can better manage its state. We are also going to be making an update to the routes.rb
file, todos_controller etc.
Let's update our routes.rb
. This below should be our updated file.
# config/routes.rb
Rails.application.routes.draw do
root "todos#index"
get "todos/all_todos"
put "todos/update"
end
Next, an update to our controller to add the update action. I also updated the all_todos
action to return an ordered pending items.
class TodosController < ApplicationController
def index
end
def all_todos
completed = Todo.where(completed: true)
pending = Todo.where(completed: false).order(:id)
render json: { completed: completed, pending: pending }
end
def update
todo = Todo.find(params[:id])
if todo.update_attributes!(todo_params)
render json: { message: "Todo Item updated successfully" }
else
render json: { message: "An error occured" }
end
end
private
def todo_params
params.require(:todo).permit(:id, :title, :completed)
end
end
Next, we need to update our Pending.jsx
and move the return contents to its own file. Let's create a PendingItem.jsx
and add this content.
import React, { useState } from 'react';
const PendingItems = ({ todo, handleSubmit }) => {
const [editing, setEditing] = useState(false);
const [pendingTodo, setPendingTodo] = useState(todo);
const handleClick = () => {
setEditing(true);
}
const handleChange = (event) => {
setPendingTodo({
...pendingTodo,
title: event.target.value
})
}
const handleKeyDown = (event) => {
if (event.key === 'Enter') {
setEditing(false);
handleSubmit(pendingTodo);
}
}
return editing ? (
<div className="form-check editing">
<input className="form-check-input" disabled type="checkbox" defaultChecked={pendingTodo.completed} id={`checkbox${pendingTodo.id}`} />
<input type="text" className="form-control-plaintext" id="staticEmail2" value={pendingTodo.title} onChange={handleChange} onKeyDown={handleKeyDown} autoFocus/>
</div>
) : (
<div className="form-check">
<input className="form-check-input" type="checkbox" defaultChecked={pendingTodo.completed} id={`checkbox${pendingTodo.id}`} />
<label className="form-check-label" htmlFor={`checkbox${pendingTodo.id}`} onClick={handleClick} >
{pendingTodo.title}
</label>
</div>
)
}
export default PendingItems;
This component accepts 2 props; the todoItem and the handleSubmit method. We also created methods to handle click events, change events and keydown events. handleClick
is used to switch to editing mode so you can edit the todo item. handleChange
updates the todo state and handleKeyDown
helps us submit the form.
In the return statement, you will notice that we are switching which content is displayed based on if we are in the editing mode. When editing, an input field is shown with the checkbox disabled. Otherwise, an enabled checkbox with a label is displayed.
Next, we update our Pending.jsx
file to reflect this new component we created and pass a handleSubmit
method to it. We also need to create the handleSubmit
to make an api call to update the todoItem.
import React from 'react';
import PendingItems from './PendingItems';
const Pending = ({ pending }) => {
const handleSubmit = (body) => {
const url = "/todos/update";
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch(url, {
method: "PUT",
headers: {
"X-CSRF-Token": token,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error("Network response was not ok.");
})
.then(response => {
console.log(response);
window.location.reload(false);
})
.catch(() => console.log('An error occurred while adding the todo item'));
}
return (
<div>
<h4>Pending</h4>
{pending.map((todo, i) => {
return (
<PendingItems key={i} todo={todo} handleSubmit={handleSubmit} />
)
})}
</div>
)
}
export default Pending;
If you noticed, we are passing a token to the request. Quoting from a source, below is an explanation of why we need to pass that:
To protect against Cross-Site Request Forgery (CSRF) attacks, Rails attaches a CSRF security token to the HTML document. This token is required whenever a non-GET request is made. With the token constant in the preceding code, your application verifies the token on the server and throws an exception if the security token doesnโt match what is expected.
I made some css update to the edit mode. You can add this styles to your todos.scss
file:
.form-check.editing {
display: flex;
align-items: center;
.form-check-input {
margin-top: 0;
}
}
label {
cursor: auto;
}
I tried editing the first today and it works. Voila!!
Lets quickly work on setting a todo item to completed. Update your PendingItem.jsx
file to what we have below. I changed the handleChange
method to handleTitleChange
. I added a handleCompletedChange
method that makes the api call to mark as complete. And I also removed the id
in the checkbox input. We don't need them.
import React, { useState } from 'react';
const PendingItems = ({ todo, handleSubmit }) => {
const [editing, setEditing] = useState(false);
const [pendingTodo, setPendingTodo] = useState(todo);
const handleClick = () => {
setEditing(true);
}
const handleTitleChange = (event) => {
setPendingTodo({
...pendingTodo,
title: event.target.value
})
}
const handleCompletedChange = (event) => {
handleSubmit({
...pendingTodo,
completed: event.target.checked
})
}
const handleKeyDown = (event) => {
if (event.key === 'Enter') {
setEditing(false);
handleSubmit(pendingTodo);
}
}
return editing ? (
<div className="form-check editing">
<input className="form-check-input" disabled type="checkbox" defaultChecked={pendingTodo.completed} />
<input type="text" className="form-control-plaintext" id="staticEmail2" value={pendingTodo.title} onChange={handleTitleChange} onKeyDown={handleKeyDown} autoFocus/>
</div>
) : (
<div className="form-check">
<input className="form-check-input" type="checkbox" defaultChecked={pendingTodo.completed} id={`checkbox${pendingTodo.id}`} onChange={handleCompletedChange} />
<label className="form-check-label" htmlFor={`checkbox${pendingTodo.id}`} onClick={handleClick} >
{pendingTodo.title}
</label>
</div>
)
}
export default PendingItems;
Refresh the page and mark an item as complete to test it out. I marked the second item as completed.
I don't want the article to be too long so I stop here. I will add the link to the repo below so you can checkout how I implemented the addTodo feature but I think you should be able to do that from the example above. Remember, you React route must match a controller action with an empty view file else, when you refresh the page, you get an unknown route error. Our final app looks like this with the add todo feature.
Feel free to refactor the react app as you see fit. I just wanted to focus on integrating with React with Rails and might have missed some best practices implementation styles. Let me know what you think of the article in the comment below.
Until next week.
Top comments (1)
This is a great tutorial for getting a quick understanding of how to structure and setup this kind of project. So thank you for creating it!
Here are a couple notes on issues I ran into:
With the very first command to setup the rails project, it gave me errors that the database was not found. I had to remove the
=
after-d
. This worked for me:$ rails new todo-app -d postgresql
The json returned by
todos_controller.rb
hasrender json: { completed: completed, uncompleted: uncompleted }
, but insideHome.jsx
it is looking for the keypending
:<Pending pending={todos.pending} />
. This key is undefined so it doesn't render. Changinguncompleted
topending
fixes the issue.