1
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?

Ruby on Rails × Reactでポートフォリオ制作

1
Posted at

📄 記事の概要

ポートフォリオ制作にあたり、本記事にて開発背景、要件定義、そのほか気づいたことをまとめました。

📚 アプリ概要と開発の背景

本アプリケーションは、本屋とその周辺のカフェを一度に検索できる 「ほんカフェマップ」 です。

外出先で時間が空いたときに、本屋に立ち寄ることがよくあります。

「気に入った本があれば、このまま近くのカフェで読みたい」と思っても、通常のマップアプリでは本屋とカフェを別々に検索する必要があり、ルートが定まらず結局行かないことがありました。

このように、“本屋 → カフェ” の流れが自然につながる導線がないことに不便さを感じ、本屋とカフェをペアで探せるアプリを作ろうと考えました。

📝 要件定義

開発の背景を踏まえ、本アプリではまず本屋を検索しその本屋を起点として徒歩圏内のカフェを検索ができることで本屋とカフェのペア検索、保存できるという一連の体験を、1つの画面フローの中で完結できることを目指しました。

本屋とカフェの検索体験向上により、本屋とカフェに訪れるきっかけを増やすことを目的とします。

🧩 機能要件

  • 本屋 → カフェのペア検索機能
  • 本屋 ・カフェ単体検索
  • 本屋・カフェ・ペアへのいいね機能
  • いいねしたスポット をマイページで一覧表示
  • ユーザー登録 / ログイン / ログアウト
  • 各スポットの詳細表示
  • 詳細画面からの Google マップ遷移(ルート表示)

⚙️ 非機能要件

  • モバイル利用を想定したレスポンシブデザイン
  • ユーザー操作に対してストレスにならないレスポンス速度

🗂️ 機能一覧(要件定義を踏まえ)

👤 ユーザー機能

  • サインアップ(メール認証あり)・ログイン・ログアウト
  • マイページ(プロフィール確認)
  • パスワードリセット

🔍 検索機能

  • 単体検索:本屋のみ、またはカフェのみを検索
  • ペア検索:検索した本屋を起点に徒歩圏内のカフェを検索
  • 現在地・キーワードからの検索
  • 検索結果を地図のピンとカード一覧で表示
  • カードから詳細情報ページへアクセス
  • 共有リンクを生成

🤝 ペア機能

  • 本屋とカフェのペア作成
  • ペアごとの詳細情報表示

❤️ いいね機能

  • 本屋・カフェ単体へのいいね
  • 本屋とカフェペアへのいいね
  • マイリストでカテゴリ別にいいねを確認

🗺️ ルート表示機能

  • 各スポット詳細からワンタップで Google マップに遷移

🧱 使用技術と選定の背景

🛠️ 使用技術

カテゴリー 技術名
Frontend React(19.0), Vite(6.3.1), React Router DOM(7.5.2), Tailwind CSS(4.1.5), DaisyUI, Heroicons, Axios,
Backend Ruby(3.1.6), Ruby on Rails(7.2.2), HTTParty
Infrastructure Render(APIデプロイ), Cloudflare Pages(フロントエンド), Neon(DBaaS), resend(メール送信)
CI/CD GitHub Actions
Database PostgreSQL
Environment Docker, Docker Compose, Node.js(22.15.0), npm
Testing Vitest, React Testing Library, RSpec
Lint / Format ESLint, Prettier, RuboCop
Version Control Git, GitHub
External API Google Places API, Google Maps JavaScript API, Google Geolocation API

🎯 技術選定の背景

Webアプリケーションを開発するにあたり、自己完結のアプリケーションから発展し、外部APIを活用したアプリケーションを開発したいと考えました。併せて、要件定義に基づき、店舗情報を取得とマップ表示に必要であるGoogle Places API, Google Maps JavaScript APIと現在地を取得するGoogle Geolocation API を採用しました。

フロントエンドにはReactを採用しました。

以前に Ruby on RailsのMVC 構成での簡単な Web アプリケーションを開発したが、近年主流の SPA 構成を意識し、モダンな開発スタイルを経験するために React を採用しました。

バックエンドはRuby on RailsをAPIモードで採用しました。

もともと Ruby on Railsを学習していたため知識を活かしやすく、またフロントと分離した構成やクライアントとのデータ通信を学ぶ上で適した選択と考えました。

Devise Token Auth によるトークンベース認証を導入することで、フロントと API 間の認証やデータ通信の仕組みを理解しつつ、RESTful な API 設計を実践できる構成としました。

CSSはTailwind CSS を採用しました。
クラスベースで直感的に適用できるため、UI構築に時間を取られず、アプリの仕組み部分の学習に集中できると考え採用しました。

一方で、開発後半では自作UIの統一感や完成度に課題を感じたため、
Tailwindと親和性の高いUIキットであるDaisyUIを導入し、デザインの一貫性と操作性を向上させました。

インフラに関してはAWSの利用を検討したが、学習コストを踏まえ、Render(バックエンド)と静的デプロイをサポートしているCloudflare Pages(フロントエンド)を採用しました。

メール送信には、RenderではSMTPの制限によりGmailが使えず、代替のSendGridは無料枠廃止に伴い、Resendを採用しました。1

データベースはRuby on Railsの標準的とされるPostgreSQLを採用しました。

ホスティングにはDBaaSにNeonを採用し、運用コードを抑えアプリケーションコードに集中できる構成です。

インフラ構成図
infrastructure.png

作成ツール:https://app.diagrams.net/

🗄️ DB設計

ER図
er.png

作成ツール:https://dbdiagram.io/home

Google Places API の制約により、 place_id2 のみを自前DBに保持し、その他の情報(店舗名、住所)は毎回 API から取得する方針を取りました。3

Bookstore, Cafe, Pairテーブルはユーザーのいいねをトリガーに登録されます。

likes は polymorphic: truelikeable_type(Bookstore, Cafe, Pair) に応じて参照先が切り替わります。

- likeable_type = 'Bookstore' → likeable_id は bookstores.id を参照
- likeable_type = 'Cafe'      → likeable_id は cafes.id を参照
- likeable_type = 'Pair'      → likeable_id は pairs.id を参照

🎨 UI設計

UIイメージ

UI design.gif

検索動線は以下の2つを実装しました。

  • 現在地から近くの店舗を検索(Gifにて実演)
  • キーワードで指定した地点からの検索

また、ユーザ操作や実装の観点から、ペア検索ボタンは設置せず、本屋検索ボタンから本屋単体、もしくは、そのままカフェを検索できる動線を設けました。

検索モーダルはフッターナビゲーションがある「検索」ボタンからどの画面からでも開けます。

ユーザー自身がいいねした店舗を確認できる「マイリスト」ではタブ操作で本屋、カフェ、ペアのいいねを確認できます。

🏗️ 実装プロセス

🔎 検索機能と外部API連携

Google Places API / Google Maps JavaScript APIを用いた検索機能を実装しました。
外部APIのレスポンスをJSONで受け取り、本屋・カフェを地図とカード一覧で表示するように実装しました。
その後、Autocompleteを用いたキーワード検索機能を追加し、現在地検索とキーワード検索の2つの検索導線を設けました。

🗂️ DB設計

検索機能をMVPとして実装後、Bookstore, Cafe, Pair, Like, Userテーブルを用意しました。
ユーザー機能、いいね機能、ペア機能の実装を見据えて永続化に必要な DB 設計を行いました。
Bookstore, Cafe, Pairの3種類のいいねを一つのテーブルで扱うためにポリモーフィックを採用し、DBを簡潔に保つことを意識しました。

🔐 認証の実装

検索機能をMVPで作成したところでDevise Token Authを用いて、サインアップ、サインイン、サインアウト、パスワードリセットといった認証基盤を整えました。後続のマイリスト機能や、いいね機能の基盤となります。

❤️ いいね機能

認証が整った段階でBookstore, Cafe, Pairに対するいいね機能を実装しました。
いいね済みの店舗をマイリストで一覧表示できます。また、検索結果画面でもいいねを参照できます。

🧪 UI/UX、テスト、CI/CDの整備

レスポンシブ対応やUXの向上を目的にUIを整備しました。
あわせて RSpec・Vitest・React Testing Libraryを使用し、コア機能や条件分岐が複雑なケースをテストし、より実務的かつ実践的なものとしてGitHub Actions による CI/CD を導入して開発フローを整えました。

✨ 工夫した点

📦 Fat componentの改善

検索結果を管理するコンポーネントが肥大化(Fat Component)していたため、リファクタリングを行いました。

このコンポーネントはロジック部分では複数の副作用(useEffect)が記述され、描画部分では子コンポーネントに渡す関数や定数が増え続けた結果、可読性が大きく低下していました。

このコンポーネントに関わる部分はURLが正しく変更されないなどのバグが発生し、修正や機能追加のたびにコードをひたすら追加する形の悪循環に陥り、最終的には約350行ほどに達していました。

リファクタリングでは、重複しているコードの削除、URL操作するロジックをHook化、条件分岐の整理、命名の見直しなどを行い、最終的に270行程度に収まりました。完全にスリム化できたわけではありませんが、関数やロジックが明確になり、以前よりもコード全体の見通しがよくなりました。

また、一部の処理を Hook として切り出したり共通化などを試みましたが、むやみに共通化・再利用を進めると、かえってファイル間の移動が増えたり、条件分岐が複雑化し、全体の可読性を損ねる場合があることも分かりました。

既存のコードを切り出すにはその処理や依存関係を深く理解することが必要です。その過程でコンポーネント間のデータフローなどの理解が向上し、結果的には学習効果が高いリファクタリングでした。

😵‍💫 難しかった点

🌐 URLでどの情報を持つか

検索結果に関して最初はuseNavigateですべての状態を持っておく方針であったが、Navigateで状態を保持することで以下の問題が発生しました。

  • リロードしたときの画面遷移が崩れる
  • URLを共有したときに同じ検索結果を表示できない

そこで以下の情報をURLに載せるよう実装しました。

  • 検索起点の座標 (lat, lng)
  • 検索カテゴリ (mode: bookstore / cafe / pair)
  • 表示している画面 (view: bookstore / cafe)
  • クリックした場所のplace_id (bpid / cpid)
// 本屋単体で検索:

/search?lat=12345&lng=67890&mode=bookstore&view=bookstore

// 本屋単体で検索かつ、本屋をクリック:

/search?lat=12345&lng=67890&mode=bookstore&view=bookstore&bpid=example123

// カフェ単体で検索:

/search?lat=12345&lng=67890&mode=cafe&view=cafe

// カフェ単体で検索かつ、カフェをクリック:

/search?lat=12345&lng=67890&mode=cafe&view=cafe&cpid=example123

//本屋で検索し、本屋をクリック。その後にカフェ選択画面に遷移しカフェをクリック:

/search?lat=12345&lng=67890&mode=pair&view=cafe&bpid=example123&cpid=example5678

開発前はURLを軽視していましたが、

  • ブラウザリロードに強い
  • 共有しても同じ画面を表示できる
  • URLに必要な範囲で画面の状態管理する

という重要性を感じました。

特に検索アプリでは「共有」がひとつのコア機能であり、それを達成するための方法の一つとしてURLで画面の再現性を担保するという選択を学びました。

⚠️ 課題点

🗄️ DB設計

開発初期の段階では、Google Places API から取得した店舗名や住所などの情報をそのまま DB に保存していました。しかし、後になって公式ドキュメントを確認したところ、Google Places API 由来のデータは place_id 以外の長期保存が禁止されている ことに気づきました。そのため、DB に依存していた部分をすべて API 取得に切り替え、不要なカラムを削除する形で対応しました。

一方で、いいね機能の設計ではポリモーフィックを採用したことで、Bookstore / Cafe / Pair の 3 種類のいいねを 1 テーブルで管理でき、DB 構成をシンプルに保つことができました。この設計は結果的に保守性を高める選択となりました。

今回の経験から、アプリケーションはデータ構造に強く依存するため、DB 設計を誤ると後工程に大きく影響するという点を改めて実感しました。

🔤 キーワード検索

本アプリケーションでは、キーワード検索は次のように実装しています。

  • ユーザーが検索ワードを入力する
  • autocomplete API を叩き、検索ワードに近い候補(descriptionplace_id の組み合わせ)を取得
  • その候補を一覧表示し、ユーザーが選択したものを検索起点として扱う

この方式では、候補として表示されたものの中からしか検索できないため、

いわゆる「とりあえずキーワードを投げて、どんな結果が返ってくるかをざっくり見る」といった「かもしれない検索」 の体験が弱くなってしまいます。

検索結果としてスポットを表示するには place_id が必要であるため、現状の設計はある程度やむを得ない部分もありますが、

キーワードベースでもう少し自由度の高い検索体験を提供できないか、今後の改善余地だと感じています。

📤 Mypageからの共有

現状、検索結果画面から共有は可能ですが、Mypageからは共有ができません。

これは検索結果画面のURLが

/search?検索起点の座標&検索カテゴリ&表示画面&検索結果のplace_id

という構成になっており、Mypageからはこの検索起点の座標を復元することができないからです。

検索起点を復元する、もしくは検索結果画面ではなくなにかしら共有専用の画面を設けるなどしてMypageから共有できるように改善したと考えています。

🚀 今後の展望

🖥️ AWS環境へのデプロイ

インフラの知識として新たにAWS CLF / SAAを取得したのでデプロイに挑戦しようとも考えましたが、インフラよりもアプリケーション周辺の理解を固めたいと考え、RenderなどのPaaSを活用しました。

🔍 検索アプリとしての機能拡充

現時点で、口コミ機能やその口コミにリアクションするようなユーザ間の関わりがなく、さらに検索した結果を参照するだけの検索アプリ止まりなので、以下のような機能を追加し、SNS感を強化したいと考えております。

  • アプリ内独自の評価機能(口コミ、星5評価)
  • 口コミへのリアクション
  • ユーザの検索導線を増やす(ランキング、お店の直接検索)

🧵 Typescriptを使う

本アプリケーションを作成以前にReactとTypeScriptを使用したアプリケーションを作成しておりました。しかし当時は JavaScript の基礎理解が浅く、静的型付けの概念をうまく活用できなかったため、本プロジェクトではまず JavaScript を用いて実装しました。

一方本アプリケーション開発を通じて、コンポーネント間のデータフローや状態管理の構造などを体系的に理解できました。それにより、型が付くことで得られる安全性や可読性向上のメリットを活かせる土台が整ったと感じています。

本アプリケーションをTypeScriptで書き直す、もしくは次のプロジェクトでTypeScriptを採用することで、より安全なコードを書いていきたいと考えています。

  1. 独自ドメインを Resend に登録していないため、現状は Resend アカウントに登録済みの自分のメールアドレスにのみメール送信が行える状況です(本番運用を想定した「任意のユーザー宛の送信」は未対応)。

  2. GooglePlaceAPIで定められた地点を一意に識別するID

  3. place_idを含めたGooglePlaceAPIからのデータ取り扱いについては以下の制限があるため、place_idを自前のDBに保存しております。
    (b) No Caching. Customer will not cache Google Maps Content except as expressly permitted under the Maps Service Specific Terms.
    Google Maps Platform Terms of Service 3.2.3(b)
    プレイス ID は、Google Maps Platform 利用規約のセクション 3.2.3(b)で定められているキャッシュ制限の対象外となります。このため、プレイス ID 値は後で使用するために保存しても問題ありません。プレイスID

1
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
1
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?