この記事は約 4 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
「保存したのに、リスト画面で古い値が一瞬見える」 — 編集ダイアログの 保存 → 閉じる順序ミス で起きる UX 事故です。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で確立した save → refresh → close の正しい順序を整理します (KDD §5.11)。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
どう間違えるか
最初の実装でハマったケース:
// ✗ NG: 閉じるのが早すぎる
async function handleSave() {
await saveKnowledge(data); // ① 保存
onClose(); // ② ダイアログを閉じる
router.refresh(); // ③ ページ再評価
}
一見、自然な順序に見えます。
しかし:
② でダイアログが閉じる
③ で router.refresh() が走るが、Server Component の再評価には時間がかかる
ユーザが見ているリスト画面で、まだ古いデータが表示されている
ユーザ体験: 「保存したのに、リストに反映されない」(数秒後に反映)。
1. 正しい順序
// ✓ OK: refresh を待ってから閉じる
async function handleSave() {
await saveKnowledge(data); // ① 保存 (server action)
router.refresh(); // ② Server Component を再評価
onClose(); // ③ ダイアログを閉じる
}
router.refresh() を onClose() の 前 に呼ぶ。
① DB に保存完了
② Server Component が再評価され、最新データを取得
③ ダイアログを閉じる (リストにはすでに最新データが表示)
ユーザ体験: 「保存 → 即座にリストが更新 → ダイアログ閉じる」。
2. なぜ refresh を待つ必要があるか
router.refresh() は 非同期。内部的には:
1. Next.js が Server Component を再評価
2. 新しい HTML / RSC payload をクライアントに送る
3. React がコンポーネントを再レンダリング
これに 100ms〜数秒 かかります。
onClose() を refresh の前に呼ぶと:
| シナリオ | 結果 |
|---|---|
| ダイアログ閉じる | 古いリストが一瞬見える |
| その後 refresh 完了 | 新しいリストに置き換わる |
「チラッと古いデータが見える」 UI 体験になります。
3. Optimistic Update のパターン
UI 体験をさらに改善するなら、Optimistic Update:
async function handleSave() {
// 1. UI を即座に更新 (Optimistic)
setLocalKnowledge(updatedData);
// 2. ダイアログを即座に閉じる
onClose();
// 3. 実際の保存 (バックグラウンド)
try {
await saveKnowledge(updatedData);
router.refresh();
} catch (err) {
setLocalKnowledge(previousData); // ロールバック
toast.error('保存に失敗しました');
}
}
ただし、ロールバック処理が複雑 になるため、たすきばは基本的に Optimistic を使わず、シンプルな順序を採用しています。
4. 部分更新による表示漏れ
KDD §5.11 では、別の罠を記録:
編集ダイアログで一部のフィールドだけ更新したとき、リスト列で その他のフィールドが表示されない ことがある。
| 現象 | 原因 |
|---|---|
| 一部の列だけ更新反映 | ダイアログ内で更新された値だけが props |
| その他の列が古い | リスト側は refresh 前のデータ |
| 対策 | 内容 |
|---|---|
router.refresh() を必ず呼ぶ |
Server Component 全体を再評価 |
| 部分的な local state 更新は避ける | 全画面リフレッシュを徹底 |
5. 失敗時の挙動
保存が失敗したら:
async function handleSave() {
setSaving(true);
try {
await saveKnowledge(data);
router.refresh();
onClose();
} catch (err) {
toast.error(`保存に失敗しました: ${err.message}`);
// ダイアログは閉じない、入力値は残る
} finally {
setSaving(false);
}
}
| ルール | 内容 |
|---|---|
| ダイアログを 閉じない | ユーザが値を失わない |
| エラーメッセージ表示 | ユーザが原因を理解 |
| 「もう一度試す」 | 再試行可能 |
ユーザの入力を絶対に失わない。UX の基本ですが、最初の実装ではしばしば見落とされます。
6. 保存中の UI
保存中はユーザに状態を伝えます。
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
| 効果 | 内容 |
|---|---|
| 重複保存リクエスト防止 |
disabled で二重クリック防止 |
| 処理中の可視化 | ユーザに「処理中」と分かる |
7. Server Action + revalidatePath
// src/services/knowledge.actions.ts
'use server';
export async function saveKnowledgeAction(data: KnowledgeUpdateInput) {
await updateKnowledge(data);
revalidatePath('/projects/X/knowledge'); // サーバ側で revalidate
}
Server Action 内で revalidatePath() を呼ぶことで、サーバ側でキャッシュが無効化。クライアントの router.refresh() と組み合わせて、確実に最新化。
8. 汎化ルール
KDD §5.11 で汎化ルール化:
編集ダイアログの保存は、必ず以下の順序で行う:
- Server Action で DB 保存
router.refresh()を awaitonClose()を呼ぶ
このルールを CONTRIBUTING.md にも書き、新規ダイアログ実装時の参照 にします。
9. E2E でのテスト
test('編集ダイアログの保存後、リストに即時反映される', async ({ page }) => {
await page.click('button:has-text("編集")');
await page.fill('input[name="title"]', '新しいタイトル');
await page.click('button:has-text("保存")');
await page.waitForSelector('[role="dialog"]', { state: 'hidden' });
await expect(page.locator('text=新しいタイトル')).toBeVisible();
});
このテストが green であれば、save → refresh → close の順序が正しい。
おわりに
| ルール | 効果 |
|---|---|
| save → refresh → close の順序 | 表示漏れを防ぐ |
| 保存中は disabled | 二重クリック防止 |
| 失敗時にダイアログを閉じない | 入力値を失わない |
| Server Action + revalidatePath | サーバキャッシュ無効化 |
| E2E でリスト反映を verify | リグレッション防止 |
ダイアログ実装の 小さな順序ミスが、ユーザ体験を大きく左右 します。KDD §5.11 で構造化したルールが、今のたすきばを支えています。
本記事のルールは、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ