Rails (API mode) x React x TypeScript simple Todo app

Target audience

--People who want to create an app by separating the back end and front end (this time Rails and React)

I've been running away from the front end for a long time, but it's a memo when I started React because I thought it was a bad idea to learn something.

If anything, it will be written with an emphasis on "building an environment that separates the back end and front end", so there may be almost no explanation of the code itself.

I think it's better to read Rails as well as React and TypeScript after learning to some extent in advance.

We start by writing a Dockerfile, so if you follow the steps properly, you can make the same thing. For those who want to move their hands and get a feel for the atmosphere.

Configuration aimed at in this article

** Technology used **

rails-react-ts-sample
├─ backend
  ├─ app
  ├─ bin
  ├─ config
  ├─ db
  ├─ lib
  ├─ log
  ├─ public
  ├─ storage
  ├─ test
  ├─ tmp
  ├─ vendor
├─ Other files
├─ frontend
  ├─ react-app
     ├─ node_modules
     ├─ public
     ├─ src
        ├─ components
           ├─ TodoForm.tsx
           ├─ TodoItem.tsx
           ├─ TodoList.tsx
           ├─ Types.d.tsx
        ├─ App.tsx
        ├─ index.tsx
├─ Other files
├─ Other files
├─ docker-compose.yml

I think that a common pattern is to partially incorporate React into a Rails-based project using gems such as "react-rails", but this time the back end (Rails) and front end (React) ) Divided.

Rails will be created in API mode.

Complete image

todo.gif

Preparation

First of all, create various folders and files to build the environment. For those who are troublesome, we have prepared a sample code, so please clone it appropriately and use it. https://github.com/kazama1209/rails-react-ts-sample

Project body

$ mkdir rails-react-ts-sample
$ cd rails-react-ts-sample

This area can be any name you like.

$ touch docker-compose.yml

yml:./docker-compose.yml


version: "3"
services:
  db:
    image: mysql:5.7
    command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 3306:3306
  api:
    build:
      context: ./backend/
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - ./backend:/myapp
      - ./backend/vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
  front:
    build: 
      context: ./frontend/
      dockerfile: Dockerfile
    volumes:
      - ./frontend:/usr/src/app
    command: sh -c "cd react-app && yarn start"
    ports:
      - "8000:3000"
volumes:
  mysql-data:

Back end

$ mkdir backend
$ touch backend/Dockerfile
$ touch backend/entrypoint.sh
$ touch backend/Gemfile 
$ touch backend/Gemfile.lock

./backend/Dockefile


FROM ruby:2.6.6

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]

sh:./backend/entrypoint.sh


#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

./backend/Gemfile


source 'https://rubygems.org'
gem 'rails', '~> 6.0.2'

rb:./backend/Gemfile.lock


Gemfile.lock is empty and OK.

front end

$ mkdir frontend
$ touch frontend/Dockerfile

./frontend/Dockerfile


FROM node:14.4.0-alpine3.10
WORKDIR /usr/src/app

Create Rails app (API mode)

Now that we're ready, let's create it from the Rails app first.

rails new

$ docker-compose run api rails new . --force --no-deps -d mysql --api

Created in API mode as described above.

Edit database.yml

Since it is not possible to connect to the database in the default state, rewrite a part of "./backend/config/database.yml".

yml:./backend/config/database.yml


default: &default
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch('RAILS_MAX_THREADS') { 5 } %>
  username: root
  password: password #It should be blank by default, so add it
  host: db #The default is "localhost", so rewrite

Create a React app

Next, we will create a React app.

create-react-app

$ docker-compose build
$ docker-compose run front sh -c 'npx create-react-app react-app --template typescript'

Start container

Start the container to see if it works.

$ docker-compose up -d
$ docker-compose run api bundle exec rake db:create

localhost:3000 スクリーンショット 2020-12-21 6.19.20.png

localhost:8000 スクリーンショット 2020-12-24 1.30.43.png

If you access "localhost: 3000" and "localhost: 8000" respectively and the familiar screen is displayed, it is successful.

Create API

It's finally time to start from here. We will create the API for the backend part.

Create a model

$ docker-compose run api rails g model Todo title:string
$ docker-compose run api rails db:migrate

rb:./backend/app/models/todo.rb


class Todo < ApplicationRecord
  validates :title, presence: true
end

Don't forget to set the validation.

Create a controller

$ docker-compose run api rails g controller Api::V1::Todos

ruby:./backend/app/controllers/api/v1/todos_controller.rb


class Api::V1::TodosController < ApplicationController
  def index
    render json: Todo.all
  end

  def create
    todo = Todo.create(todo_params)
    render json: todo
  end

  def destroy
    Todo.destroy(params[:id])
  end

  private
  
    def todo_params
      params.require(:todo).permit(:title)
    end
end

Describe the routing

ruby:./backend/config/routes.rb


Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
     resources :todos, only: [:index, :create, :destroy]
    end 
  end 
end

Create initial data

ruby:./backend/db/seeds.rb


puts 'Creating todos...'

5.times do |i|
  Todo.create(title: "Todo#{i + 1}")
end

puts '...Finished!'

Create initial data for operation check.

$ docker-compose run api rails db:seed

Check with curl command

$ curl -X GET http://localhost:3000/api/v1/todos
[
    {
        "id": 1,
        "title": "Todo1",
        "created_at": "xxxx-xx-xxTxx:xx:xx.xxxZ",
        "updated_at": "xxxx-xx-xxTxx:xx:xx.xxxZ"
    },
    {
        "id": 2,
        "title": "Todo2",
        "created_at": "xxxx-xx-xxTxx:xx:xx.xxxZ",
        "updated_at": "xxxx-xx-xxTxx:xx:xx.xxxZ"
    },
    {
        "id": 3,
        "title": "Todo3",
        "created_at": "xxxx-xx-xxTxx:xx:xx.xxxZ",
        "updated_at": "xxxx-xx-xxTxx:xx:xx.xxxZ"
    },
    {
        "id": 4,
        "title": "Todo4",
        "created_at": "xxxx-xx-xxTxx:xx:xx.xxxZ",
        "updated_at": "xxxx-xx-xxTxx:xx:xx.xxxZ"
    },
    {
        "id": 5,
        "title": "Todo5",
        "created_at": "xxxx-xx-xxTxx:xx:xx.xxxZ",
        "updated_at": "xxxx-xx-xxTxx:xx:xx.xxxZ"
    }
]

If you can confirm that the data is inserted firmly, it is successful.

CORS settings

In this configuration, the backend and frontend are completely separated, so Rails and React are launched with different port numbers. (Localhost: 3000 and localhost: 8000)

In this case, please note that the API on the Rails side cannot be used from the React side due to security issues in the default state.

In order to be able to use it, "CORS (Cross Origin Resource Sharing)" must be set.

Install rack-cors

./backend/Gemfile


gem 'rack-cors'

There is a gem that makes it easy to set up CORS, so let's install it.

If you are creating in API mode, it is already described in the Gemfile, so you can uncomment it.

$ docker-compose build

I updated the Gemfile, so build it again.

Edit cors.rb

ruby:./backend/config/initializers/cors.rb


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

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

The configuration file should already exist, so edit it so that it can be accessed from "localhost: 8000".

$ docker-compose restart

Restart the container for the configuration changes to take effect.

Create front

In order to use the API created earlier, we will create the front end part.

Delete unnecessary files

As I work from now on, the various automatically generated files will inevitably get in the way, so I will delete them.

I will not use these this time, so you can delete them.

Edit existing file

Since the problem will occur due to the effect of deleting some files earlier, rewrite the existing file.

ts:./frontend/react-app/src/App.tsx


const App: React.FC = () => {
  return (
    <h1>Hello React!</h1>
  );
}

export default App;

ts:./frontend/react-app/src/index.tsx


import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';

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

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

スクリーンショット 2020-12-23 2.47.09.png

It's OK if the default screen changes to "Hello React!".

Create each component

Prepare a "components" folder under "./frontend/react-app/src/" and place each component there.

$ mkdir ./frontend/react-app/src/components
$ touch ./frontend/react-app/src/components/Types.d.ts
$ touch ./frontend/react-app/src/components/TodoForm.tsx
$ touch ./frontend/react-app/src/components/TodoList.tsx
$ touch ./frontend/react-app/src/components/TodoItem.tsx

--Types: Type definition --Header: Header --TodoForm: Input form --TodoList: Todolist --TodoItem: Todo body

ts:./frontend/react-app/src/components/Types.d.ts


interface Todo {
  id: number;
  title: string;
}

type GetTodos = () => void;

ts:./frontend/react-app/src/components/TodoForm.tsx


import React, { useState } from 'react';

interface TodoFormProps {
  getTodos: GetTodos;
}

export const TodoForm: React.FC<TodoFormProps> = ({ getTodos }) => {
  const [title, setTitle] = useState<string>('');

  const createTodo = (e: React.FormEvent<HTMLFormElement>) => {
    fetch('http://localhost:3000/api/v1/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        title: title,
      })
    })
    .then((response) => {
      return response.json();
    })
    .then(() => {
      getTodos();
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      setTitle('');
    });

    e.preventDefault();
  }

  return (
    <form onSubmit={createTodo}>
      <input
        type='text'
        value={title}
        onChange={(e) => {
          setTitle(e.target.value)
        }}
      />
      <input type='submit' value='Add' disabled={!title} />
    </form>
  );
};

ts:./frontend/react-app/src/components/TodoList.tsx


import React from 'react';
import { TodoItem } from './TodoItem';

interface TodoListProps {
  todos: Todo[];
  getTodos: GetTodos;
}

export const TodoList: React.FC<TodoListProps> = ({ todos, getTodos }) => {
  return (
    <table>
      <thead>
        <tr>
          <th>Todos</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        {
          todos.map((todo, index) => {
            return (
              <TodoItem
                key={index}
                todo={todo}
                getTodos={getTodos}
              />
            );
          })
        }
      </tbody>
    </table>
  );
};

ts:./frontend/react-app/src/components/TodoItem.tsx


import React from 'react';

interface TodoItemProps {
  todo: Todo;
  getTodos: GetTodos;
}

export const TodoItem: React.FC<TodoItemProps> = ({ todo, getTodos }) => {
  const deleteTodo = (id: number) => {
    fetch(`http://localhost:3000/api/v1/todos/${todo.id}`, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json'
      },
    })
    .then((response) => {
      return response;
    })
    .then(() => {
      getTodos();
    })
    .catch((error) => {
      console.error(error);
    });
  }

  return (
    <tr>
      <td>{todo.title}</td>
      <td>
        <button onClick={() => deleteTodo(todo.id)}>Delete</button>
      </td>
    </tr>
  );
};

Edit App.tsx

Edit "./frontend/react-app/src/App.tsx" so that you can call each component.

ts:./frontend/react-app/src/App.tsx


import React, { useState, useEffect } from 'react';
import { TodoList } from './components/TodoList';
import { TodoForm } from './components/TodoForm';

const initialTodos: Todo[] = [];

const App: React.FC = () => {
  const [todos, setTodos] = useState(initialTodos);

  useEffect(() => {
    getTodos();
  }, [setTodos]);

  const getTodos = () => {
    fetch('http://localhost:3000/api/v1/todos')
    .then((response) => {return response.json()})
    .then((todos) => {setTodos(todos)});
  }

  return (
    <>
      <h1>Todo App</h1>
      <TodoForm getTodos={getTodos} />
      <TodoList todos={todos} getTodos={getTodos} />
    </>
  );
}

export default App;

Complete

スクリーンショット 2020-12-24 18.07.07.png

If it looks like this in the end, it is complete.

Afterword

For the time being, I couldn't give a detailed explanation because it was like a memo, but I would like to add a detailed explanation later when I have time.

Recommended Posts

Rails (API mode) x React x TypeScript simple Todo app
Place "Create a to-do list using Rails API mode and React Hooks" on docker
Launch the Rails app locally in production mode (API Server)
[Rails] Use cookies in API mode
How to return Rails API mode to Rails
[Rails] Let's create a super simple Rails API
Rails API
Rails6 [API mode] + MySQL5.7 environment construction with Docker
Make Rails API mode support cookie (session) authentication