連載目次
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計 |
| #4 | データ処理・ロジック実装編 |
| #5 | UI・画面実装編 |
| #6 | テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題(この記事) |
| #8 | 完成・振り返り |
はじめに
前回は、テストと自動チェック・自動公開の仕組み(CI/CD)を書きました。
今回は、開発中に実際に起きた問題を4つ取り上げます。それぞれ「問題・原因・調査・解決」の順に書きます。
| # | 問題 | 分類 |
|---|---|---|
| 1 | 改行で区切った文章が、分割されない | 文字列処理 |
| 2 | 分割機能が何度直しても安定しない → 機能ごと廃止 | 設計 |
| 3 | Vercel の最初の公開が失敗した | ブランチ運用 |
| 4 | コードを読み返して見つかった不具合(フォルダ削除・日付) | データの整合性・レンダリング |
問題1:改行で区切った文章が、分割されない
問題
公開前に、実際にブラウザで一通り操作して確かめる工程(手動動作確認)を設けていました。その中で、自分で文章を入力して登録すると、何文あっても必ず1つのブロックにまとまってしまう、という現象が起きました。用意していたサンプル文では、正しく分割されていました。
調査
開発用のブラウザで同じ操作をしても、最初は再現しませんでした。そこで、実際に使っているブラウザの localStorage に保存されたデータを直接見て、「何を入力したのか」を確かめました。
- サンプル文:「〜です。〜ます。」のように、句点「。」で文が区切られている
- 自分で入力した文:句点を付けず、1行に1文ずつ改行で区切って 入力していた
原因
文を分ける関数(splitSentences)が、「。!?」でしか文を区切っていませんでした。改行だけで区切った文章は、全体が1つの長い文として扱われていました。
解決
改行も文の区切りとして扱うように直しました。
// 改行も文の区切りとして扱う(句読点を付けずに1行1文で入力するケースに対応するため)
const lines = trimmed.split(/\r?\n/);
for (const line of lines) {
const parts = line.trim().split(/(?<=[。!?])\s*/);
// …
}
あわせて、「句読点がなく改行だけの文章」をテストに加えました。
学び
- 開発者が用意したサンプルは、無意識に「うまく動く入力」になりがちです。実際に使う人は、想定と違う書き方をします
- 「自分の環境では再現しない」ときは、推測で直す前に、相手の環境で何が保存され、何が入力されたのかを事実として確かめるのが近道でした
問題2:分割機能が何度直しても安定しない → 機能ごと廃止
問題
当初は、長文をいくつかのブロックに分け、少しずつ覚える機能がありました。「1ブロックに何文入れるか」を指定して「分割してはじめる」を押す仕組みです。
ところが手動動作確認で、「分割してはじめるを押しても分割されない」という現象が3回続けて起きました。見た目は同じ現象なのに、原因は毎回違いました。
調査と、その都度の修正
| 回 | 原因 | 修正 |
|---|---|---|
| 1回目 | 5文の文章に対して「1ブロック6文」と指定していた。文の数より大きいので、全文が1ブロックに収まった | 指定できる数の上限を、文の数までにした |
| 2回目 | 「1ブロック5文」と、文の数ちょうどを指定すると、やはり1ブロックになった | 上限を「文の数 − 1」にして、必ず2ブロック以上になるようにした |
| 3回目 | 句読点も改行もない文章では、そもそも「1文」を決められない | ― |
原因
3回目で、問題が個々の不具合ではなく 設計そのもの にあると分かりました。
分割の単位を「文」にしていたため、分割の結果が「句読点や改行がどう書かれているか」に左右されていました。書き方は人によってばらばらなので、どれだけ上限を調整しても、別の入力で別の穴が見つかります。
解決(1):文字数で均等に分ける
分割の単位を「文」から「文字数」に変えました。指定した数のブロックに、文字数でほぼ均等に分けます(例:50文字を10個に → 5文字ずつ)。句読点や改行があってもなくても、同じように分けられます。
// 本文を文字数ベースでblockCount個のブロックに均等分割する(句読点・改行の有無に依存しない)
const count = Math.max(1, Math.min(Math.floor(blockCount), text.length));
for (let i = 0; i < count; i++) {
const start = Math.floor((i * text.length) / count);
const end = Math.floor(((i + 1) * text.length) / count);
// text.slice(start, end) を1ブロックにする
}
解決(2):それでも、機能ごとやめた
文字数での分割は正しく動くようになりました。しかし改めて使ってみると、文字数で機械的に切ると言葉の途中でブロックが切れてしまいます。これでは、覚えるための区切りとしては使いにくいものでした。
分割にこだわるほど、「どこで区切るか」という面倒な問題がついて回ります。そこで、分割機能そのものを廃止 しました。
- 「分割数」の入力欄を削除し、ボタンを「分割してはじめる」から「はじめる」に変更した
- テキストは常に1つのブロック(本文全体)として登録する
暗記・穴埋め・キーワードの3つのモードと「隠す/表示」は、もともとブロックの数に関係なく動く作りでした。そのため、機能をやめても、ほかの部分のコードはほとんど変えずに済みました。
学び
- 同じ種類の不具合が繰り返し起きるときは、小さな修正を重ねる前に、「設計そのものが間違っていないか」を疑う
- 機能を足すことだけが改善ではありません。問題を生み続ける機能は、やめてしまうのも1つの解決です
問題3:Vercel の最初の公開が失敗した
問題
MVPがひととおりできたので、Vercel と GitHub をつないで公開しようとしました。ところが、最初の公開(デプロイ)が次のエラーで失敗しました。
No Next.js version detected
「Next.js のバージョンが見つからない」という意味です。Next.js で作ったアプリなのに、なぜ見つからないのか分かりませんでした。
調査
エラーの文面から推測で設定を変えるのではなく、Vercel の管理画面でビルドのログを開き、どのブランチの、どのコードを使って公開しようとしていたかを確かめました。
原因
Vercel は、main ブランチのコードを本番に公開します。ところが main には、リポジトリを作った直後の最初のコミットしか入っていませんでした。
このプロジェクトは Git Flow で運用していて、機能の開発はすべて feature/* → develop の流れで進めていました。本来は、区切りのよいところで develop から main に「リリース」する工程があります。しかしその工程を一度も行っていなかったため、main にはアプリのコード(package.json を含む)が何もありませんでした。Next.js が見つからないのは当然でした。
解決
main に直接 push して済ませるのではなく、Git Flow の手順どおりにリリースしました。
-
developからrelease/1.0.0ブランチを作る -
release/1.0.0をmainにマージし、v1.0.0のタグを付ける -
developにも反映し、release/1.0.0ブランチを削除する
main が更新されると Vercel が自動で公開を始め、今度は成功しました。以降、公開後の小さな修正は hotfix/* ブランチで行い、main と develop の両方に反映しています(v1.0.1)。
学び
- ブランチの運用ルールは、「決めた」だけでは不十分で、すべての工程を一度実際に通してみて初めて、抜けている工程に気づけます
- エラーの原因は、エラーが出た場所(Vercel)ではなく、その前の工程(ブランチ運用)にあることがあります
問題4:コードを読み返して見つかった不具合
完成後、すべてのソースコードを1ファイルずつ読み返しました。その中で、テストも手動確認もすり抜けていた不具合が見つかりました。2つ取り上げます。
4-1. フォルダを削除すると、テキストが「未分類」に出てこない
問題
フォルダを削除すると、そのフォルダに入っていたテキストが「すべて」には表示されるのに、「未分類」を選ぶと表示されませんでした。どのフォルダを選んでも出てこない、行き場のないテキストになっていました。
原因
#3 の設計で、フォルダとの関係は「テキスト側だけが folderId を持つ」ことにしていました。フォルダを削除する処理は、フォルダの一覧から消すだけで、テキスト側の folderId を書き換えていませんでした。
そのため、テキストの folderId には、もう存在しないフォルダのIDが残ります。「未分類」は folderId === null のテキストを表示するので、このテキストは対象になりませんでした。
解決
フォルダを削除するときに、そのフォルダに入っていたテキストの folderId を null(未分類)に戻すようにしました。
// フォルダを消したら、中にあったテキストは未分類(folderId: null)に戻す
const handleDeleteFolder = (id: string) => {
handleFoldersChange(folders.filter((f) => f.id !== id))
const nextTexts = texts.map((t) => (t.folderId === id ? { ...t, folderId: null } : t))
setTexts(nextTexts)
saveTexts(nextTexts)
if (folderId === id) setFolderId("")
}
メニュー(MenuDrawer)はフォルダのデータしか受け取っていないので、テキストを書き換えられません。そこで、削除のときだけは専用の関数(onDeleteFolder)でホーム画面に伝え、テキストとフォルダの両方をまとめて持っているホーム画面が、両方を更新する形にしました。
データベースでいえば、外部キーの ON DELETE SET NULL(参照先が消えたら null にする)に当たる処理です。データベースを使わない構成では、この後始末を自分で書く必要がありました。
4-2. トップページの日付が、ビルドした日のまま固まる
問題
トップページのフッターには、「今日: 2026/09/25」のように今日の日付を表示しています。この日付を作る処理に、問題がありました。
原因
Next.js は、データを使わないページのHTMLを ビルドのときに作っておきます(静的生成)。トップページもその対象でした。
部品の中でそのまま new Date() を使っていたので、ビルドで作られたHTMLには「ビルドした日」の日付が入っていました。実際にビルド結果のHTMLを確認すると、日付がそのまま書き込まれていました。
ブラウザで開くと、React はブラウザ側でもう一度画面を作り、サーバーで作ったHTMLと一致するか確かめます(ハイドレーション)。ビルドした日と開いた日が違うと、日付が一致せず、エラーになります。
解決
最初はサーバー側でもブラウザ側でも日付を空にしておき、画面ができた後(マウント後)に今日の日付を入れるようにしました。
const [today, setToday] = useState<Date | null>(null)
useEffect(() => {
setToday(new Date())
}, [])
これで、最初の表示はサーバーとブラウザで必ず一致し、そのあとで正しい日付に置き換わります。
学び
- ビルドのときにHTMLを作るページでは、日付・乱数・ブラウザにしかない情報(localStorage など)を、描画の中でそのまま使わない
- 今回の不具合は、デプロイした当日には起きず、翌日以降に初めて表に出る種類のものでした。動作確認だけでは見つけにくく、コードを読み返したことで見つかりました
そのほかの修正
読み返しでは、ほかにも次の改善点が見つかり、あわせて直しました。
- 習熟度の表示名(「未着手」など)が2か所に書かれていたので、1か所にまとめた
- 同じ機能を「お気に入り」と「ブックマーク」の2通りで呼んでいたので、「ブックマーク」に統一した
- 使っていないフォントを読み込んでいたので、削除した
- 穴埋めの表示処理がトップページと練習画面の2か所にあったので、共通の部品にまとめた
- トップページのデモのタブに、読み上げ用の属性を付けた
まとめ
| # | 問題 | 本当の原因 |
|---|---|---|
| 1 | 改行で区切った文章が分割されない | 想定していなかった書き方(改行区切り) |
| 2 | 分割機能が安定しない | 「文」を単位にした設計そのもの |
| 3 | Vercel の最初の公開が失敗 |
develop → main のリリースを一度もしていなかった |
| 4 | フォルダ削除・日付の不具合 | 参照している側の後始末漏れ/ビルド時に作られるHTML |
4つとも、表に出た症状と本当の原因は、別の場所にありました。症状が出た場所だけを直すのではなく、「なぜそうなるのか」をたどって原因を確かめることが大切でした。
次回は最終回、完成・振り返り編です。