連載目次
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計 |
| #4 | データ処理・ロジック実装編 |
| #5 | UI・画面実装編(この記事) |
| #6 | テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
はじめに
前回は、画面を持たない処理(lib フォルダ)として、データの型・ブラウザへの保存・キーワードを自動で見つける仕組みを実装しました。
今回は画面の実装です。V0で作ったデザインをどう取り込んだか、ホーム画面・練習画面・トップページで何を工夫したかを書きます。
V0のデザインを取り込む
#3 で書いたとおり、画面のデザインはV0で作りました。V0のコードは見た目としては完成していましたが、そのままでは動きません。
- フォルダや保存済みテキストが、コードの中に書かれた仮のデータ(ダミーデータ)で固定されていた
- データの形が、自分で設計したものと違っていた。V0では
MemoTextやBlock、文は文字列の配列(sentences: string[])、キーワードも手で指定した配列(keywords: string[])になっていた
そこで、練習画面の部品6つすべてで、型を自分の設計(Text/Chunk/Sentence)に書き換えました。仮のデータの部分は、#4 で作った loadTexts などの関数に一つずつつなぎ直しています。
取り込むときは、「まず見た目のコードを入れ、そのあとに処理をつなぐ」という順番を守りました。見た目と処理を同時に変えると、うまく動かないときに原因がどちらにあるのか分からなくなるためです。
画面の全体構造
page.tsx は入口だけ
Next.js の App Router では、app フォルダの page.tsx がそのURLの画面になります。page.tsx には処理を書かず、components の部品を呼ぶだけにしました。
// app/practice/[id]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <PracticeScreen textId={id} />;
}
[id] のように角かっこで囲んだフォルダ名にすると、URLのその位置にある文字列(テキストのID)を受け取れます。Next.js 15 以降は params が Promise で渡されるので、await で中身を取り出しています。古い書き方(params.id と直接書く)とは違うので、注意が必要な点でした。
データを持つ部品は画面ごとに1つ
ホーム画面では、保存済みテキストとフォルダの一覧を HomeScreen だけが持っています。メニュー(MenuDrawer)はデータを持たず、表示に必要な値と「変えたいときに呼ぶ関数」を props で受け取るだけです。
<MenuDrawer
folders={folders}
filter={viewFilter}
onSelectAll={() => setViewFilter("all")}
onFoldersChange={handleFoldersChange}
// ほか省略
/>
フォルダの一覧は、ホーム画面の登録フォームでもメニューでも使います。1か所で持っていれば、メニューでフォルダ名を変えたとき、登録フォームの選択肢にもすぐ反映されます(状態の持ち上げ)。保存(saveFolders)も HomeScreen の中でだけ行うので、保存し忘れが起きません。
練習画面も同じで、データを持って保存するのは PracticeScreen だけです。
localStorage は画面ができた後に読む
useEffect(() => {
// localStorageはサーバーサイドでは参照できないため、マウント後に読み込む
// eslint-disable-next-line react-hooks/set-state-in-effect
setTexts(loadTexts())
setFolders(loadFolders())
}, [])
"use client" を付けた部品も、最初の1回はサーバー側で下書きのHTMLが作られます。サーバーには localStorage がないので、画面がブラウザで出来上がった後(マウント後)に useEffect で読み込んでいます。
この書き方は、React の新しいチェックルール(react-hooks/set-state-in-effect:useEffect の中で state を更新しない)に引っかかりました。ここでは「サーバーでは読めないデータを、マウント後に1回だけ読む」という意図どおりの使い方なので、理由をコメントに書いたうえで、この行だけルールを外しています。
ホーム画面
登録したらすぐ練習へ
「はじめる」を押すと、テキストを保存してすぐにそのテキストの練習画面(/practice/ID)へ移動します。本文が空白だけのときは、ボタンを押せない状態にしています。
const charCount = body.trim().length
// …
<button onClick={handleStart} disabled={charCount === 0}>はじめる</button>
「未分類」と表示の切り替え
V0のメニューは、「未分類」も普通のフォルダの1つとして配列に入れる作りでした。しかし自分の設計では、未分類は「folderId が null」という状態で、フォルダのデータとしては存在しません。
そこで「未分類」はフォルダの配列に入れず、メニューの先頭に常に表示される特別な選択肢にしました。また、「すべて」「ブックマークのみ」「特定のフォルダ」の3つの表示は、1つの状態にまとめています。
export type ViewFilter = "all" | "bookmark" | "folder"
3つを別々の true/false で持つと、「すべて」と「ブックマークのみ」が同時に ON になるような矛盾が起こり得ます。1つの値にまとめれば、必ずどれか1つだけになります。
2回押して削除する
削除ボタンは、1回目で赤い「本当に削除?」に変わり、2回目で実際に削除します。確認のポップアップ(confirm())はスマホで操作の流れを止めてしまうので、ボタン自体を確認の表示に切り替える形にしました。
日本語入力の Enter に注意する
フォルダ名やタイトルの入力欄では、Enter で確定できるようにしています。ただし日本語入力では、変換を確定するときにも Enter を押します。
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === "Enter") commitEdit()
}}
isComposing(変換中かどうか)を確かめないと、「めんせつ」を「面接」に変換した瞬間に、変換途中の文字で確定されてしまいます。keyCode === 229 は、変換中の Enter をこの値で伝えるブラウザ(Safari など)への対策です。
練習画面
変更の保存は1つの関数にまとめる
ブックマーク・タイトル変更・習熟度の変更は、すべて updateText という関数を通して保存しています。
const updateText = (updater: (t: Text) => Text) => {
setText((prev) => {
if (!prev) return prev
const updated = updater(prev)
const all = loadTexts().map((t) => (t.id === updated.id ? updated : t))
setAllTexts(all)
saveTexts(all)
return updated
})
}
const toggleBookmark = () =>
updateText((t) => ({ ...t, bookmarked: !t.bookmarked, updatedAt: Date.now() }))
保存する直前に loadTexts() で全件を読み直し、今のテキストだけを差し替えています。画面を開いた時点のデータで保存すると、別のタブで変えた内容を古いデータで上書きしてしまう恐れがあるためです。
表示の状態は保存しない
「隠しているか」「どのモードか」「穴埋めのどの空欄を開いたか」は、画面の中だけで持ち、保存しません。どれも「今この瞬間の練習の都合」なので、次に開いたときは最初からやり直せるほうが練習しやすいと考えました。
3つのモード
| モード | 表示 |
|---|---|
| 暗記 | 本文をそのまま表示。「隠す」で見えなくし、「タップで表示」で答え合わせ |
| 穴埋め | キーワードを空欄にし、空欄を1つずつタップして答え合わせ |
| キーワード | キーワードだけを丸いラベルで並べ、文全体を思い出す手がかりにする |
穴埋めとキーワードは、どちらも #4 の extractKeywordSegments の結果を使っていて、見せ方だけが違います。
穴埋めの空欄には、答えと同じ文字数の全角スペースを入れ、文字の色を透明にしています。
{isRevealed ? seg.text : " ".repeat(seg.text.length)}
空欄の幅が答えの長さに比例するので、「何文字くらいの言葉か」が思い出す手がかりになります。
トップページ(LP)
最後に、アプリの説明と「はじめる」ボタンを置いたトップページを作りました。
最初にデザイン生成ツール(Stitch)で作ったLPは、色の雰囲気が Google の Material Design 寄りで、アプリ本体の紺・インディゴ系と合いませんでした。primary や surface-container のような色の名前が大量に使われていたので、それぞれの色が画面のどこに効いているかを一つずつ確かめながら、アプリの配色に直しました。
また、作ったLPを見返すと、説明を読んだだけでは使い方が分かりませんでした。足りなかったのは説明の量ではなく、このアプリの中心である「隠す/表示する」の操作感です。そこで、LPの中に例文で3つのモードを試せるデモを置きました。
デモは、アプリ本体と同じキーワード抽出(extractKeywordSegments)と、同じ穴埋めの部品を使っています。登録しなくても、実際のアプリと同じ動きをその場で試せます。
まとめ
- V0のデザインは見た目だけを取り込み、データの形は自分の設計に合わせて書き換えた
- データを持って保存する部品は画面ごとに1つにし、ほかの部品は props で受け取る形にした
- localStorage はマウント後に読み、変更は1つの関数(
updateText)を通して保存した - 表示の状態は保存せず、練習のたびに最初からやり直せるようにした
- LPには本物と同じ処理で動くデモを置き、触って使い方が分かるようにした
次回はテスト・CI/CD構築編です。Vitest でのテストの書き方と、GitHub Actions・Vercel で自動チェックと公開をする仕組みを書きます。