🎊

【React Native(Expo)×Rails】個人開発Webアプリをスマホアプリにフルリプレイス、App Storeにリリースした話

に公開

はじめに

2023年にWebアプリとしてリリースした個人開発プロダクトを、スマホアプリへフルリプレイスし、App Storeの審査を通過して正式リリースしました。
https://apps.apple.com/jp/app/積みログ/id6761534911

本記事では、Webアプリをスマホアプリにフルリプレイスしてリリースするまでの技術的な道のりを、JWT認証・API設計など実装に苦労した箇所なども含めてまとめています。

Webからモバイルへの移行を検討している方や、React Native(Expo)でのアプリ開発に興味がある方の参考になれば嬉しいです。

https://qiita.com/aya1357/items/5abc68b557197dc39baf

積みログとは?

「1日5分、もしくは1日5ページ」という小さな習慣から読書を継続できるようにサポートする、積読本解消のための読書管理アプリです。

機能1. 読書シミュレーターで計画を立てる

「何日までに読み終えたいか」や「1日何ページ読むか」を入力することで、
必要な読書ペースや完了予定日を簡単に試算できます。

「1日何ページ読めばいいのか」が明確になるので、
無理のない読書計画を立てることができます。

読書記録シミュレーター

機能2. 本を簡単に登録して読書範囲まで管理

読みたい本は、Google Books API・国立国会図書館サーチAPI・楽天ブックスAPIを利用して検索し、簡単に登録できます。
検索結果が見つからない場合は別のAPIへ切り替えて検索するため、できるだけ多くの本をカバーできるようにしています。

また、手動での追加にも対応しているので、どんな本でも管理可能です。

さらに総ページ数や読書範囲を設定することで、
「あとどれくらいで読み終わるか」が一目でわかります。

なんとなく放置されがちな積読本も、可視化することで自然と意識できるようになります。
本の情報は自由に編集できるため、自分に合った形で柔軟に管理できます。

積みログ本の登録

機能3. 完了日を自動で計算・更新

目標ページ数や設定した読書ペースをもとに、
本の登録時に「完了予定日」を自動で計算します。

さらに、日々の読書記録に応じて完了予定日も更新されるため、
本の登録時に設定された完了日と、実際の進捗を反映した完了日の両方を確認できます。

現状のペースが適切かどうかがすぐにわかるため、
無理のない読書計画を立てることができます。

積みログ読書記録登録

機能4. 読書記録をカレンダーで可視化

読んだページ数や読書時間を記録すると、カレンダー上に反映されます。

「いつ・どれくらい読んだか」が視覚的にわかるので、
自然と読書習慣を継続しやすくなります。

完了した日は達成感が見える形で残るので、モチベーション維持にもつながります。

積みログ実績

Before / After:技術スタックの変化

Before(Webアプリ)

バックエンド:Ruby on Rails 7.0.3
フロントエンド:Ruby on Rails + jQuery + Tailwind CSS
DB:PostgreSQL
サーバー:Heroku
認証:sorcery

After(スマホアプリ)

バックエンド:Ruby on Rails 8.1.3
フロントエンド:React Native(Expo)+ TypeScript
状態管理:Zustand
API通信:orval + TanStack Query
フォーム管理:React Hook Form + Zod(バリデーション)
認証:JWT
ストレージ:expo-secure-store(native)/ localStorage(web)
DB:PostgreSQL
サーバー:Render.com
その他インフラ:AWS(S3、SESなど)

前回はRailsがHTML・CSSまで含めてアプリ全体を担っていましたが、今回のスマホアプリ化では、ユーザー向け機能を中心に、RailsはAPI、フロントエンドはExpoが担う構成に移行しました。

実装に苦労した箇所

1. APIクライアントの分離設計

今回の実装で特に苦労したのが、APIクライアントを用途ごとに分離した設計です。

■ 背景

本アプリでは、OpenAPIスキーマからAPIクライアントを自動生成するためにOrval を使用しています。
https://orval.dev/

Orvalはリクエスト処理を共通化できるため、通常のAPI通信では非常に便利です。
一方で、認証まわりの制御を一律で扱うと、挙動が複雑になるという課題がありました。

■ 課題

本アプリでは、通常のAPI通信で Orval の customInstance を利用し、トークン付与や401エラー時のリフレッシュ処理を共通化しています。
これにより、画面表示やデータ更新などの主要なAPIでは、認証切れが起きても自動で復旧できる構成になっています。

一方で、ログアウトAPIのように、この共通フローをそのまま適用すると扱いづらい処理もありました。
ログアウトは認証を維持する処理ではなく、認証状態を破棄する処理です。
そのため、401エラーが返ってきた場合でもリフレッシュして再試行するのではなく、そのまま処理を終わらせたい場面があります。

このような処理まで共通の認証フローに乗せてしまうと、不要なリフレッシュや再試行が発生し、かえって挙動が複雑になるという課題がありました。

■ 解決策

そこで、APIクライアントを1つに統一するのではなく、用途に応じて使い分ける構成にしました。
画面表示やデータ更新などの主要なAPIでは、Orval の customInstance を通して、トークン付与や401時のリフレッシュ処理をまとめて行う共通の認証フローを適用しています。
一方で、ログアウトAPIのように401時でもリフレッシュを行わず、そのまま処理を終わらせたいものについては、Orvalを経由しない apiClient を利用するようにしました。

apiClient はトークンを付与してリクエストを送るだけのシンプルな構成にしています。401が返ってきてもリフレッシュや再試行は行わず、そのまま処理を終わらせます。これにより、ログアウトのように「認証を維持したくない処理」でも、意図した通りの挙動を実現できるようにしました。

2. JWT × ゲストログインの多段階認証フロー

ゲストログインをなぜ作ったか

スマホアプリでメールアドレスの登録を求めると、そこで離脱するユーザーが一定数います

積みログは「とりあえず使ってみたい」という人のために、メールアドレス不要で簡単に始められるゲストログイン機能を実装しています。ゲストには guest_uid という識別子を発行し、端末のSecureStoreに保存します。

■ 課題

通常ユーザーはrefresh tokenで復旧できます。ゲストユーザーはrefresh tokenが切れても guest_uid による自動再ログインで復旧できますが、それも失敗した場合は完全ログアウトになります。そのため、ゲスト専用のフォールバックが必要でした。

■ 解決策

最終的に以下のような多段階リカバリフローを実装しました。

APIリクエスト
  ↓ 401エラー
refresh tokenで新しいaccess tokenを取得・再試行
  ↓ refresh tokenも期限切れで取得失敗
  ↓(ゲストユーザーの場合)
guest_uid で自動再ログイン
  ↓ それも失敗
token・キャッシュをクリアしてログアウト
api/mutator/http-client.ts
if (isUnauthorized(refreshError)) {
  // refresh tokenも切れている場合はゲスト再ログインを試みる
  const loginResponse = await guestReLogin()
  // 新しいtokenを保存してログイン状態を更新
  await useAuthStore.getState().login(loginResponse)
  // 新しいaccess tokenでリクエストを再構築
  const retryRequest = await buildRequestInit(url, config, loginResponse.data.access_token)
  // 再試行(401 が返ったらログアウト)
  return await executeWith401Logout<TData>(retryRequest, timeoutMs)
}

また、複数のAPIが同時に401を返したとき、リフレッシュが多重実行されないように refreshPromise で排他制御しています。

api/mutator/http-client.ts
// 多重リフレッシュ防止
let refreshPromise: Promise<AuthPayload> | null = null

refreshPromise ??= executeFetch<LoginResponse>(...)
  .then(...)
  .finally(() => {
    refreshPromise = null
  })

同時に複数のリクエストが401エラーとなった場合、リフレッシュ処理が重複して実行されるとトークンの不整合が発生する可能性があります。そのため、refreshPromise を用いてリフレッシュ処理を1回にまとめることで、安全にトークン更新を行えるようにしています。

3. 予測完了日の計算ロジック

積みログの予測完了日は「今日から何日後に読み終わるか」をリアルタイムで計算して表示しています。シンプルに見えて、考慮すべきケースがいくつもありました。

【考慮すべき条件】

  • 開始日が過去の日付になっている場合は「今日」を基準にする
  • 今日すでに読書記録がある場合は、今日分は消化済みとして「明日」からカウント
  • 残りページが0なら、その時点の基準日をそのまま返す
  • ceil(切り上げ)で必要日数を出し、「基準日を1日目」として数えるため -1 する
features/user-books/utils/calculateEstimatedEndDate.ts
// 基準日:過去の開始予定なら今日(00:00)から、今日以降の開始予定ならその日から
let baseDate = startDay < todayStart ? todayStart : startDay

// すでに読み終わっている場合は、その時点の基準日を返す
if (remainingPages === 0) return baseDate

// 今日すでに読書している場合は、残りは明日からとして扱う
if (hasReadingLogToday) {
  const tomorrowStart = dateUtils.addDays(todayStart, 1)
  baseDate = baseDate > tomorrowStart ? baseDate : tomorrowStart
}

const daysNeeded = Math.ceil(remainingPages / dailyGoal)

// 基準日を1日目として数えるため -1 日する
const result = new Date(baseDate)
result.setDate(result.getDate() + Math.max(daysNeeded - 1, 0))
return result

UI / UXへのこだわり

スマホアプリになったことで、前回よりユーザーの行動導線を意識して、いかにシンプルに使いやすい設計に出来るかが必要になりました。

1. Thumb Zone(親指ゾーン)

スマホ画面の下部は親指が一番届きやすいエリアです。この「Thumb Zone」の考え方を意識して、メインのアクションボタンは画面下部(親指で押しやすい場所)に配置するよう統一しました。

2. CLS防止

データ読み込み中にヘッダーが消えてチカチカするのは、UXとしてあまり良くありません。
これはWebのCore Web VitalsでいうCLS(Cumulative Layout Shift)と同様に、レイアウトの変化によってユーザーにストレスを与える問題です。
https://web.dev/articles/cls?hl=ja

そこで、ローディング状態でもヘッダーのレイアウトを維持するようにしました。
これにより、画面がすぐに表示されたように感じられ、体感的なストレスを大きく軽減できます。
積みログフォロー画面
実際の処理速度だけでなく「どれだけ速く感じるか」も重要であるため、Perceived Performanceの観点も意識して改善を行いました。

【参照】

体感パフォーマンスを向上させるには、ページの読み込み時間を最小限に抑えましょう。つまり、ユーザーがすぐに操作するコンテンツを最初にダウンロードし、残りは後から「バックグラウンドで」ダウンロードします。ダウンロードされるコンテンツの総量は実際には増えるかもしれませんが、ユーザーが待つのはごく少量なので、ダウンロードが速く感じられます。

https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/Perceived_performance#minimize_initial_load

3. ファーストビューの設計

スクロールせずに確認できる範囲に、ユーザーにとって重要な情報を集約するように設計しました。
読書記録の登録画面では、残りページ数や完了予定日など、現在の進捗に関わる情報を画面下部に固定で配置しています。
これにより、入力中でも現在の状況や読み終わりまでの見通しをすぐに確認でき、スムーズに操作を続けることができます。
積みログ読書記録画面
またトップページでは、直近の読書記録や日ごとの達成状況に加えて、シミュレーター機能をファーストビュー内に配置しています。
さらにヘッダーには連続読書日数を表示することで、継続状況も含めて現在の読書状態を一目で把握できるようにしています。
ユーザーがアプリを開いた瞬間に、読書の進捗や完了までの見通し、継続状況をまとめて把握できるようにし、余計な操作をせずに状況をすぐに理解できるUIを意識しています。
積みログホーム画面

今後追加検討中の機能

今後は、読書の継続を促進する仕組みや、記録を振り返るための可視化・分析機能など、積読本の解消により貢献できるような機能の作成を検討しています。

【今後作成予定の機能】

  • 表示キャラクターの変更機能
  • 読書する時間でリマインダー通知する機能
  • 読書記録のグラフ表示
  • 読書記録のXへの投稿機能
  • プッシュ通知
  • 連続読書日数のバッジ・実績機能
  • タイムラインへのいいね機能

おわりに

Webアプリをスマホアプリにリプレイスするというのは、作り直すのではなく全部一から設計し直すに近い作業でした。

認証フローひとつとっても、以前は Sorcery に依存した認証機能を利用していましたが、本実装ではJWTとSecureStoreを用いた認証を自前で扱う必要がありました。
また、Orval によるAPIクライアントの自動生成も、当初は利便性を期待して導入しましたが、customInstance と認証ロジックの連携において想定以上に複雑になりました。

カレンダー系のアプリは日常の中で頻繁に確認・操作される性質があり、スマートフォンで利用される場面が多いことから、より直感的かつ手軽に使える形としてネイティブアプリへの移行を進めました。

今回の移行により、ユーザーが日常の中で継続して使いやすい体験を提供できるようになり、積読本の解消にもより効果的に繋がると考えています。

もし少しでも興味を持っていただけたら、ぜひ使ってみていただけると嬉しいです。
実際に使ってみた感想や改善点などもいただけると、とても励みになります。

個人開発でスマホアプリに挑戦しようとしている方に、この記事が少しでも参考になれば嬉しいです。
https://apps.apple.com/jp/app/積みログ/id6761534911

Discussion