0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React × Rails API】Active Storageを使って画像投稿 + ツイート投稿機能を実装する

0
Last updated at Posted at 2025-12-26

はじめに

この記事は、プログラミング初学者が他の記事を参考にしたり、実際に実装してみたりして、アウトプットの一環としてまとめたものです。内容に不備などあればご指摘いただますと幸いです。

今回、Rails(API) x ReactでX(旧Twitter)のクローンサイトを作る際にツイート投稿機能を実装しました。
そのツイート投稿機能の実装方法について備忘録も兼ねて記事を作成していきます。

この記事では下記を解説しています。

  • ツイート本文 + 複数画像を投稿
  • ツイート本文のみの投稿
  • Active Storage に複数画像を保存

実装の流れ

先にツイートを作成

そのツイートに画像を紐づける
という 2段階方式 を実装していきます。

ActiveStorage のセットアップ

ActiveStorage インストール

下記のコマンドを実行します。

rails active_storage:install
rails db:migrate

画像保存用モデルを作成(Tweetモデル)

下記のコマンドを実行します。

rails g model tweet user:references content:text
rails db:migrate

Tweet モデルで ActiveStorage を紐付け

下記の行を追加します。

app/models/tweet.rb
class Tweet < ApplicationRecord
  belongs_to :user
  has_many_attached :images # 追加
end

コントローラの作成(API)

下記のコマンドを実行します。

rails g controller api/v1/images
rails g controller api/v1/tweets

app/controllers/api/v1/tweets_controller.rbに以下を書きます。

app/controllers/api/v1/tweets_controller.rb
module Api
  module V1
    class TweetsController < ApplicationController
      before_action :authenticate_api_v1_user!

      def create
        tweet = current_api_v1_user.tweets.build(content: params[:content])

        if tweet.save
          render json: {
            id: tweet.id,
            content: tweet.content,
            createdAt: tweet.created_at
          }, status: :created
        else
          render json: tweet.errors, status: :unprocessable_entity
        end
      end
    end
  end
end

ポイント

  • current_api_v1_user.tweets.buildtweet.user_id = current_api_v1_user.id を自動でセットした状態で Tweet を生成します

app/controllers/api/v1/images_controller.rbに以下を書きます。

app/controllers/api/v1/images_controller.rb
module Api
  module V1
    class ImagesController < ApplicationController
      before_action :authenticate_api_v1_user!

      def create
        # すでに作られているtweetを検索
        tweet = Tweet.find(params[:tweet_id])

        # 画像は複数想定
        params[:images]&.each do |image|
          tweet.images.attach(image)
        end

        render json: {
          tweetId: tweet.id,
          # url_for は Rails のヘルパーで、ActiveStorage の画像を実際にアクセスできるURLに変換する
          images: tweet.images.map { |img| url_for(img) }
        }, status: :created
      end
    end
  end
end

ポイント

  • params[:images] に React から送られたファイル群が入ります
  • each で ActiveStorage に attach
  • params[:images]&.each は &. (ぼっち演算子) を使ってレシーバ(params[:images])が nil の場合に NoMethodError を発生させず、ただ nil を返すようにしています
  • url_for で実際にアクセスできるURLに変換して返します(Reactで表示可能)
  • tweet.images.map { |img| url_for(img) } はTweet に紐づく画像を “表示可能なURL” に変換して配列で返しています

ルーティングの設定

下記の行を追加します。

config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: 'users', controllers: {
        registrations: 'api/v1/registrations'
      }
      resources :tweets, only: %i[index show create destroy] # 追加
      resources :images, only: %i[create] # 追加
    end
  end
end

CORSの設定

config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:5173" # ReactサーバーのURL

    resource "*", # すべてのエンドポイントに適用
      headers: :any, # すべてのヘッダーを許可
      expose: ['access-token', 'expiry', 'token-type', 'uid', 'client'], #フロント側(React)に特定のレスポンスヘッダーを見えるようにする設定
      methods: [:get, :post, :put, :patch, :delete, :options, :head] # 許可するHTTPメソッドを定義
  end
end

React側

src/apis/apiClient.js
import axios from "axios";

const apiClient = axios.create({
  baseURL: "http://localhost:3000/api/v1",
});

export default apiClient;

上記では、baseURLをセットしたaxiosインスタンスを作成して、以下のコンポーネント内で axios を使用する際のURLを簡略化しています。
Axios インスタンス

import { FaEarthAmericas } from "react-icons/fa6";
import { CiImageOn } from "react-icons/ci";
import { RiFileGifLine } from "react-icons/ri";
import { RiListRadio } from "react-icons/ri";
import { CiFaceSmile } from "react-icons/ci";
import { TbCalendarTime } from "react-icons/tb";
import { FiMapPin } from "react-icons/fi";
import PostButton from "../ui/PostButton";
import { useState } from "react";
import apiClient from "../../apis/apiClient";

const TweetForm = () => {
  const [content, setContent] = useState("");
  const [images, setImages] = useState([]);

  const submitTweet = async (e) => {
    e.preventDefault();

    try {
      // Tweet を作成
      const res = await apiClient.post("/tweets", {
        content: content,
      });

      const tweetId = res.data.id;

      // 画像があれば紐づける
      if (images.length > 0) {
        const formData = new FormData();
        // 複数画像を FormData に追加
        images.forEach((img) => formData.append("images[]", img));
        // TweetのIDを FormData に追加
        formData.append("tweet_id", tweetId);

        await apiClient.post("/images", formData);
      }

      setContent("");
      setImages([]);
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <div className="flex border-b border-gray-600 px-4 pt-1">
      <div className="me-2 mt-3 h-10 w-10 rounded-full bg-white"></div>
      <div className="flex-1 pt-1">
        <form onSubmit={submitTweet}>
          {/* ツイート投稿フォーム */}
          <input
            type="text"
            name="content"
            placeholder="いまどうしてる?"
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="mx-0.5 mt-0.5 w-full py-3 text-xl focus:border-0 focus:outline-0"
          />
          <div className="mt-1 flex items-center border-b border-gray-600 px-2 pb-3 text-sky-500">
            <FaEarthAmericas size={13} />
            <span className="ml-1 text-sm font-bold">全員が返信できます</span>
          </div>
          <div className="flex justify-between py-2">
            <div className="flex items-center gap-4 px-1.5 text-sky-500">
              {/* 画像投稿アイコン */}
              <label>
                <CiImageOn size={20} className="cursor-pointer" />
                <input
                  type="file"
                  multiple
                  onChange={(e) => {
                    const images = Array.from(e.target.files);
                    setImages(images);
                  }}
                  className="hidden"
                />
              </label>
              <RiFileGifLine size={20} />
              <RiListRadio size={20} />
              <CiFaceSmile size={20} />
              <TbCalendarTime size={20} />
              <FiMapPin size={20} />
            </div>
            <PostButton size="small" />
          </div>
        </form>
      </div>
    </div>
  );
};

export default TweetForm;

ポイント

  • multiple で複数画像選択ができます
<input type="file" multiple />
  • Array.fromメソッドは、文字列や配列風オブジェクトから新しい配列を生成するメソッド
  • FormDataオブジェクトは、ファイルアップロードを含む送信を簡潔に処理するのに便利なオブジェクト
  • フォームデータでは images[] として送ります。
    HTTP リクエストで配列を送る場合は、キー名に [] を付ける必要があります。
    モデルに has_many_attached :images と定義したことで、 Rails 側では配列を求めています。そのため、送る側も配列形式で送る必要があります。

おわりに

最後まで読んでいただきありがとうございました。

参考にしたサイト

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?