# 日付ロジックの3つの落とし穴—— null センチネル / コントローラブル化 / ?? 演算子の優先順
## 導入
日々の活動量を週次目標に対して可視化する Web アプリで、週次ペースラインとカレンダーまわりの挙動を直しました。直してみると「ありがちなのに見落としやすい」3つの設計ミスが揃って出てきたので、整理して書き残しておきます。
3つとも、React + TypeScript で日付や曜日を扱うアプリには高確率で出てくる類のものです。コードを直すこと自体は数行で済むのですが、原因を遡ると「型と意味の対応をどうつけるか」「コンポーネントの責務をどう分けるか」「演算子の優先順をどう読むか」という設計の話になります。
## 落とし穴 1:曜日ターゲットを
### 何が起きたか
「曜日ごとに目標回数を設定する」という機能で、最初は次のような型にしていました。
``
- [ ] タイトルが SEO 的に適切か(「React 日付 useMemo」等の検索意図を意識)
- [ ] 「CTA 指示」目印が「まとめ」末尾に配置されているか確認
- [ ] アイキャッチ画像を作成(16:9)
- [ ] 採用後は元の auto-snap ファイル(pr441)を削除する
## 導入
日々の活動量を週次目標に対して可視化する Web アプリで、週次ペースラインとカレンダーまわりの挙動を直しました。直してみると「ありがちなのに見落としやすい」3つの設計ミスが揃って出てきたので、整理して書き残しておきます。
3つとも、React + TypeScript で日付や曜日を扱うアプリには高確率で出てくる類のものです。コードを直すこと自体は数行で済むのですが、原因を遡ると「型と意味の対応をどうつけるか」「コンポーネントの責務をどう分けるか」「演算子の優先順をどう読むか」という設計の話になります。
## 落とし穴 1:曜日ターゲットを
0 | number で表現してしまう### 何が起きたか
「曜日ごとに目標回数を設定する」という機能で、最初は次のような型にしていました。
``
typescript
type WeekdayTarget = {
weekday: number // 0=日, 1=月, ...
targetCount: number // 0 = 設定なし、>0 = 目標回数
}
`
直感的に書いてしまいがちな形ですが、ここに「0 = 未設定」「0 = 休養日」の2つの意味が同居していたのが事故のもとでした。週次ペースラインを計算するときに、未設定の日と休養日が同じ「0回」として扱われてしまい、「週合計の目標に達しないペースライン」というバグになっていました。
### どう直したか
null をセンチネル値として導入し、3値設計に組み替えました。
`typescript
const dailyTargets: Array<number | null> = weekDates.map((date) => {
const weekday = new Date(${date}T00:00:00Z).getUTCDay()
if (restWeekdays.has(weekday)) return 0 // 休養日 → 固定ゼロ
return fixedTargetsByWeekday.get(weekday) ?? null // 未設定 → null(フレキシブル枠)
})
`
- number > 0:固定ターゲット
- 0:休養日(明示的にゼロを置く)
- null:未設定(残りのバジェットを均等分配する対象)
「未設定」と「ゼロ」を別物として表現しただけですが、これで「週合計に届かないペースライン」が解消され、「曜日に固定値を入れていない日は柔軟に分配される」という直感的な挙動が成立しました。
### 学び
直感に寄せて number 1つで済ませると、後から「0 にも複数の意味が乗る」事故が起きやすいです。「未設定」「ゼロ」「正の値」のどれかで挙動が変わる場面では、最初から number | null か、状態を表す union 型に分けるのが安全です。
## 落とし穴 2:カレンダーが today 1つで「強調」と「表示月」の責務を兼ねる
### 何が起きたか
月次カレンダーコンポーネントで、こんな props にしていました。
`typescript
interface MonthLogCalendarProps {
today: string // 「今日のセルを強調する」のに使い、
// ついでに「どの月を表示するか」も決める
weekStartDay: 0 | 1
logs: LogEntry[]
}
`
「今日」を渡せば、その月のカレンダーが描画される作りです。最初はシンプルで気持ちよかったのですが、「翌月/前月へナビゲートしたい」という要件が来た瞬間に詰みました。today をいじると今日の強調も動いてしまうし、コンポーネント側に状態を持たせると外から制御できません。
### どう直したか
責務を 2 つに分けました。
`typescript
interface MonthLogCalendarProps {
today: string // 今日の強調表示に使用(読み取りのみ)
monthDate?: string // 表示月の制御(省略時は today を使う)
weekStartDay: 0 | 1
logs: LogEntry[]
}
export function MonthLogCalendar({ today, monthDate, weekStartDay, logs }: MonthLogCalendarProps) {
const calendarMonthDate = monthDate ?? today
const calendar = useMemo(
() => buildMonthCalendar(calendarMonthDate, weekStartDay, logs),
[calendarMonthDate, weekStartDay, logs],
)
// ...
}
`
呼び出し元は useState と shiftMonthAnchor のようなユーティリティで monthDate の遷移を管理し、コンポーネントには表示月のステートを持たせません。コントローラブルコンポーネント化と呼ばれるパターンです。
### 学び
「今日を強調する」と「どの月を表示するか」は、見た目こそ近いものの、別の責務です。同じ props に乗せた瞬間、外部から表示月だけを制御する手段が無くなります。コンポーネントを設計するとき、「読み取り専用の参照」と「外部から制御したい状態」は最初から別 props に分けるのが安全です。
## 落とし穴 3:?? の左右で「どちらを優先するか」を取り違える
### 何が起きたか
複数ゴールを切り替える週次グラフで、こんな1行を書いていました。
`typescript
const activeGoalPaceCounts = primarySeries?.paceCounts ?? goalPaceCounts
`
意図は「外から goalPaceCounts を渡したらそれを使う、無ければシリーズ側のものを使う」でしたが、実際の挙動は逆。「シリーズ側があればそれを優先、無ければ外部値」になっていました。シングルゴール表示時に、外で計算した goalPaceCounts がどうしても表示に反映されない、というバグです。
### どう直したか
左右をひっくり返すだけです。
`typescript
const activeGoalPaceCounts = goalPaceCounts ?? primarySeries?.paceCounts
`
?? 演算子は「左がデフォルト、右がフォールバック」と読みます。「優先したい方を左に置く」という、シンプルなルールを徹底するだけで防げました。
### 学び
?? は「左が null | undefined のときだけ右を使う」演算子なので、「左 = 第一候補、右 = 第二候補」と覚えておくのが安全です。コードレビューで ?? の左右が直感と合っているかは、目視で1秒見るだけで確認できます。テストが薄い箇所では、ここがレビューの目玉になります。
## 実用アドバイス(明日から試せる内容)
1. **「0 の意味は1つか?」を関数ごとに点検する**:未設定・休養・ゼロ件の区別が必要な箇所では、number | null か union 型に組み替えます。
2. **カレンダー系は最初からコントローラブルに作る**:内部 state を持つ useState パターンは、後から外部制御を足しにくいです。最初から monthDate? 的な制御プロップを置きます。
3. **?? の左右をコードレビューでチェックする**:「優先したいものが左に書かれているか」は目視 1 秒で済みます。テストが薄い箇所ほど効きます。
4. **useMemo の宣言順を依存グラフ順に揃える**:依存先の useMemo を後ろに書いてしまうと、古いクロージャを参照するバグが入り得ます。宣言順は機械的にチェックできるので、ファイル単位で並べ直す癖をつけると安全です。
## まとめ
- 「未設定」と「ゼロ」を number 1つに乗せると、後から事故が起きます。null センチネルか union 型で分けます
- カレンダー系コンポーネントは「今日の強調」と「表示月の制御」を別 props に分けるとコントローラブルになります
- ?? 演算子は「左が第一候補、右が第二候補」。優先したいものを左に置くだけです
> 💡 **CTA 指示**: ここに **D1「開発壁打ち(¥12,000)」** の CTA を WordPress 側で設定してください。
---
## 編集メモ
- 元素材: 40_Outputs/01_Drafts/auto-snap/2026-05-03-suburi-quest-pr441-fix-weekly-pace-and-calendar-display.md(auto-snap 自動生成)
- 元 PR: https://github.com/wakalab/suburi-quest/pull/441
- SEO キーワード候補: React 日付処理, useMemo 依存順, ?? 演算子 優先順, コントローラブルコンポーネント, null センチネル
- 想定読了時間: 7-9 分(約2,800字)
- 公開チャネル: WordPress(原本)+ X / LinkedIn で要約リンク
### To Do(公開前チェック)
- [ ] コードブロックの整形・シンタックス確認
- [ ] ですます調で統一されているか /polish` で検証- [ ] タイトルが SEO 的に適切か(「React 日付 useMemo」等の検索意図を意識)
- [ ] 「CTA 指示」目印が「まとめ」末尾に配置されているか確認
- [ ] アイキャッチ画像を作成(16:9)
- [ ] 採用後は元の auto-snap ファイル(pr441)を削除する

