保存した壁紙を一覧から消そうとしたら「本当に削除しますか?」が出て、考えるより先に「OK」を押していた——自分のアプリを操作していて、そんな自分に気づいたことがあります。確認ダイアログは安全装置のつもりで置いたのに、毎回出てくるせいで、ユーザーは中身を読まずにボタンを押す癖がつきます。これでは安全装置として機能していません。
リストから項目を1つ消すような、頻繁で、かつ取り返しのつく操作には、確認ダイアログよりも「いったん消して、数秒だけ取り消せるようにする」ほうが合っています。個人開発で壁紙や癒し系のアプリを長く運用してきた私自身、確認を減らしてこの方式に寄せてから、削除まわりの戸惑いの声が目に見えて減りました。以下では、Rork(Expo)で作ったアプリにこの「取り消せる削除」を組み込む具体的な手順と、確認ダイアログを残すべき境界線を整理します。
確認ダイアログが向かない操作、向く操作
まず線引きをはっきりさせます。判断軸は「その操作はやり直せるか」と「どれくらいの頻度で起きるか」の2つです。
| 操作の例 | 頻度 | 取り返し | 向く手段 |
|---|---|---|---|
| お気に入りから1枚外す | 高い | つく | 取り消せる削除 |
| メモ・履歴を1件消す | 高い | つく | 取り消せる削除 |
| アカウントを削除する | ごく稀 | つかない | 確認ダイアログ(+再入力) |
| 課金・送信を確定する | 稀 | つかない | 確認ダイアログ |
| 全データを初期化する | ごく稀 | つかない | 確認ダイアログ(+文言入力) |
頻繁に起きて、やり直せる操作に確認を出すほど、ユーザーは「OKを押す」動作を学習します。逆に、取り返しのつかない操作にこそ、立ち止まらせる確認を集中させるべきです。確認を減らすことは、残した確認の重みを上げることでもあります。
「取り消せる削除」の仕組み
実装の肝は、画面から消すタイミングと、データを本当に消すタイミングをずらすことです。
- ユーザーが削除する → その項目を画面の一覧から即座に隠す(消えたように見える)
- 同時に「元に戻す」付きのトーストを数秒表示する
- 制限時間内に「元に戻す」が押されたら、隠していた項目を戻す
- 押されなければ、そこで初めてストレージから本当に削除する
つまり、削除は「保留中の削除」というバッファにいったん入り、時間切れで確定します。ユーザーから見れば即座に消え、しかも数秒の猶予がある、という二つの良さが両立します。
Expo での実装
保留中の削除を管理するカスタムフックを作ります。react-native 標準だけで動き、追加ライブラリは要りません。
// useUndoableDelete.ts
import { useCallback, useEffect, useRef, useState } from "react";
type PendingMap = Record<string, ReturnType<typeof setTimeout>>;
export function useUndoableDelete<T extends { id: string }>(
items: T[],
commitDelete: (id: string) => Promise<void> | void,
windowMs = 5000
) {
// 保留中のID(画面からは隠すが、まだ消していない)
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
const timers = useRef<PendingMap>({});
// 実際に確定削除する
const commit = useCallback(
(id: string) => {
delete timers.current[id];
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
commitDelete(id);
},
[commitDelete]
);
// 削除を予約(画面からは即座に消える)
const remove = useCallback(
(id: string) => {
setPendingIds((prev) => new Set(prev).add(id));
timers.current[id] = setTimeout(() => commit(id), windowMs);
},
[commit, windowMs]
);
// 取り消し(タイマーを止めて戻す)
const undo = useCallback((id: string) => {
const t = timers.current[id];
if (t) clearTimeout(t);
delete timers.current[id];
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
// 画面に出すのは「保留中でない」項目だけ
const visibleItems = items.filter((it) => !pendingIds.has(it.id));
// アンマウント時、保留中はすべて確定してから消える
useEffect(() => {
return () => {
Object.keys(timers.current).forEach((id) => {
clearTimeout(timers.current[id]);
commitDelete(id);
});
};
}, [commitDelete]);
return { visibleItems, remove, undo, pendingIds };
}ポイントは、画面に表示する visibleItems を「保留中でないものだけ」に絞っていることです。状態は1か所(pendingIds)に集約しているので、リストとトーストが食い違う心配がありません。
呼び出し側はこうなります。
// FavoritesScreen.tsx
import { useState } from "react";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
import { useUndoableDelete } from "./useUndoableDelete";
export function FavoritesScreen({ data, onDeleteFromStorage }) {
const [lastDeleted, setLastDeleted] = useState<string | null>(null);
const { visibleItems, remove, undo } = useUndoableDelete(
data,
onDeleteFromStorage, // ここで AsyncStorage 等から実削除
5000
);
const handleDelete = (id: string) => {
remove(id);
setLastDeleted(id);
};
const handleUndo = () => {
if (lastDeleted) undo(lastDeleted);
setLastDeleted(null);
};
return (
<View style={{ flex: 1 }}>
<FlatList
data={visibleItems}
keyExtractor={(it) => it.id}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => handleDelete(item.id)}>
<Text style={{ padding: 16 }}>{item.title}</Text>
</TouchableOpacity>
)}
/>
{lastDeleted && (
<View
style={{
position: "absolute",
bottom: 24,
left: 16,
right: 16,
flexDirection: "row",
justifyContent: "space-between",
backgroundColor: "#222",
padding: 14,
borderRadius: 12,
}}
>
<Text style={{ color: "#fff" }}>1件削除しました</Text>
<TouchableOpacity onPress={handleUndo}>
<Text style={{ color: "#4da3ff", fontWeight: "600" }}>元に戻す</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}トーストの自動消滅は、削除確定の windowMs と揃えておくと挙動が読みやすくなります。ここでは簡単のため lastDeleted を直接管理していますが、トーストにも同じ秒数のタイマーを持たせて、時間切れで setLastDeleted(null) するときれいです。
見落としやすい「アプリが裏に回る」問題
ここがいちばんつまずきやすいところです。削除を予約した直後にユーザーがホーム画面に戻ると、setTimeout はOSの都合で止まったり遅れたりします。すると「消えたはずの項目が次の起動で生き返る」という、いちばん不信感を生む挙動になります。
対策は、アプリが裏に回る瞬間に、保留中の削除をすべて確定してしまうことです。猶予はフォアグラウンドにいる間だけ、と割り切ります。
import { AppState } from "react-native";
// useUndoableDelete 内に追記
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state !== "active") {
// 裏に回るときは保留分をすべて確定
Object.keys(timers.current).forEach((id) => {
clearTimeout(timers.current[id]);
commit(id);
});
}
});
return () => sub.remove();
}, [commit]);これで「取り消せるのはアプリを見ている間だけ」という一貫したルールになり、復活バグも防げます。上のアンマウント時の確定処理と合わせて、保留が宙に浮く経路をすべて塞いでおきます。
Rork に作らせるときのプロンプトのコツ
Rork に最初から作らせる場合、「削除ボタンに確認ダイアログを付けて」と頼むと、たいてい Alert.alert の確認が入ります。そうではなく、挙動を言葉で指定します。たとえば「リストの項目を削除したら、すぐ一覧から消して、画面下に『元に戻す』ボタン付きの通知を5秒出して。5秒以内に押さなければ確定削除、押したら戻す」のように、確認ではなく取り消しで設計してほしいと伝えると、意図した形に近づきます。生成されたコードでも、上で挙げた「裏に回ったとき」の確定処理は抜けがちなので、そこだけは自分で足すつもりでいるとよいです。
確認ダイアログは万能の安全装置ではなく、出しすぎると効き目が落ちる道具です。次に削除ボタンを設計するときは、「これは取り返しがつくか」を一度自分に問いかけてみてください。つくのなら、確認で止めるより、消してから戻せるようにするほうが、たいていの場合ユーザーに優しい設計になります。
関連して、削除後に一覧が空になったときの見せ方はRork アプリの空状態を設計しきるが、削除ボタンを含む画面設計の引き出しはRork アプリの UX デザインパターンが参考になります。