DEV Community

Mbonu Blessing
Mbonu Blessing

Posted on

Using React to manage your views in Rails 6 - Todo App

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.
Rails default welcome page

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

Generate Todos controller

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.

Root route content - Hello React

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.

Todo list

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!!

Edit first todo item

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.

Completed second Items

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.

Add todo button

Add todo input field

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.

Links and Resources

Top comments (1)

Collapse
 
blwinters profile image
Ben Winters • Edited

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 has render json: { completed: completed, uncompleted: uncompleted }, but inside Home.jsx it is looking for the key pending: <Pending pending={todos.pending} />. This key is undefined so it doesn't render. Changing uncompleted to pending fixes the issue.