個人用の記録アプリで、通常の一覧から項目を外す操作をアーカイブへ変更しました。一覧からは見えなくしても記録そのものは残し、別画面から戻せるようにしています。完全削除は、そのアーカイブ画面で確認してから行う操作に分けました。
実装で大事にしたのは、レコードの詳細と元の位置を一緒に残すこと、そして保存が成功するまで画面の状態を変えないことです。「戻すボタンを付ける」だけでは、復元後の内容や保存失敗時の扱いまでは決まりませんでした。
今回は、ブラウザー内の一覧をlocalStorageへ保存するアプリから、アーカイブと復元の状態変更を切り出して整理します。サーバー同期や複数端末の競合制御は含めません。
名前だけ残しても、元の記録には戻せない
記録には名前以外に、メモや評価などの詳細があります。アーカイブ側へIDと表示名だけを残すと、一覧に戻す際に、それ以外の値をどこから復元するかが問題になります。
今回の実装では、対象のレコード全体をアーカイブへ移します。表示に必要な項目を選び直して保存するのではなく、元のレコードをそのまま保持しました。
あわせて、一覧の何番目にあったかをindexとして残しています。毎回末尾へ戻すより、元の位置を復元の目安にできるためです。ただし、アーカイブ中に一覧が編集されることもあるので、過去の並びを完全に再現する仕組みとは分けて考えました。
状態の形を一般化すると、次のようになります。ID、タイトル、メモ、日時は記事用の架空データです。
const state = {
version: 1,
active: [{ id: 'item-a', title: 'サンプルA' }],
archive: [{
record: {
id: 'item-b', title: 'サンプルB',
details: { note: '残しておきたいメモ', rating: 3 },
},
index: 1,
archivedAt: '2026-10-19T00:00:00Z',
}],
};
通常の一覧とアーカイブを合わせて、アプリの保存状態として扱います。片方だけ保存して途中で失敗しないよう、localStorageへは両方を1つのJSONにして書き込みます。
状態変更と、保存・表示を分けた
元の実装では、アーカイブ、復元、完全削除の状態変更を、DOMやlocalStorageを直接触らない関数へ分けています。画面のクリックから関数を呼び、返ってきた候補を保存できた場合だけ再描画します。
以下は、そのアーカイブと復元を一般的な名前へ置き換えた最小例です。読み込み時にデータ構造を検証し、IDが一覧とアーカイブ全体で一意で、保存する値がJSONで表せることを前提にしています。
// archive.mjs
export function archiveRecord(state, id, archivedAt) {
const index = state.active.findIndex(record => record.id === id);
if (index < 0) return null;
const record = state.active[index];
return {
...state,
active: state.active.filter((_, i) => i !== index),
archive: [...state.archive, { record, index, archivedAt }],
};
}
export function restoreRecord(state, id) {
const item = state.archive.find(item => item.record.id === id);
if (!item || state.active.some(record => record.id === id)) return null;
const active = [...state.active];
const index = Math.max(0, Math.min(item.index, active.length));
active.splice(index, 0, item.record);
return {
...state,
active,
archive: state.archive.filter(item => item.record.id !== id),
};
}
アーカイブでは対象だけを通常の一覧から外し、レコード、元の位置、アーカイブ日時を一組で追加します。対象が見つからなければnullを返し、存在しない項目を処理したことにはしません。
復元では、一覧側に同じIDがあったら止めます。通常はIDの重複しない保存状態を使いますが、復元時にも確認を置いています。ここで一覧の内容を黙って上書きしたり、同じIDを2件に増やしたりはしません。元の画面でも、この場合は復元できなかったことを知らせ、再読み込みを案内する分岐になっています。
復元位置は、現在の一覧に収まる範囲へ戻す
元の位置が5番目でも、その後にほかの項目が減れば、現在の一覧が2件しかない場合があります。
そこで復元位置は、0から現在の配列の長さまでに収めています。元の位置が残っていればそこへ挿入し、一覧が短くなっていれば末尾へ戻します。indexは読み込み時に整数であることを確認します。
この方法が保持するのは、アーカイブ時の数値上の位置です。「以前は特定の項目の直後だった」という関係や、複数件を取り出した順序まで管理しているわけではありません。画面が名前順や評価順に並べ替えて表示する場合は、保存上の順序と画面上の順序も別です。
今回の用途では、複雑な並び順の履歴を増やすより、この範囲で復元できることを優先しました。
配列を変えないことと、深くコピーすることは違う
上の関数は元の配列へsplice()しません。コピーした配列へ挿入して、保存候補を作ります。保存前に、現在表示している配列を変更しないためです。
一方、レコード自体は同じオブジェクトを参照しています。スプレッド構文による配列のコピーは深いコピーではなく、detailsなどの入れ子も共有されます。
この最小例では、現在状態から取得したレコードや保存候補を直接書き換えず、状態変更関数が返す値を次の候補として扱います。候補のメモを直接変更してから保存に失敗すると、配列をコピーしていても現在状態へ変更が漏れます。
アーカイブ中の内容も自由に編集するなど、別々の変更を扱う設計なら、変更対象を必要な深さまでコピーする処理も必要になります。今回はレコードを移動する処理と、編集する処理を混ぜないことが前提です。
保存できたときだけ、現在の状態を切り替える
元の画面では、localStorage.setItem()が成功してから現在の一覧とアーカイブを更新し、その後に再描画しています。保存できなければ、変更していないことを画面へ表示します。
最小例の保存処理は次の形です。archive.mjsへ続けて置けます。
export function createStore(initial, storage) {
let state = initial;
return {
current: () => state,
commit(next) {
if (!next) return false;
try {
storage.setItem('record-archive-v1', JSON.stringify(next));
} catch {
return false;
}
state = next;
return true;
},
};
}
ブラウザーでは、検証済みの初期状態とlocalStorageを渡します。
const store = createStore(initialState, localStorage);
const next = archiveRecord(store.current(), targetId, new Date().toISOString());
if (store.commit(next)) {
render(store.current());
} else {
showError('保存できなかったか、対象が見つからないため変更していません。');
}
localStorageは容量制限などで書き込みに失敗することがあります。また、JSONへの変換自体が失敗する場合もあります。どちらも現在の状態を切り替える前に扱います。
1つのJSONへまとめるのは、この操作で一覧だけ保存され、アーカイブだけ未保存になるような分割を避けるためです。複数タブの同時更新を安全に統合するトランザクションや、永続保存の絶対的な保証を追加するものではありません。
古い削除履歴を、空のアーカイブに置き換えなかった
今回の変更前にも、ブラウザーには削除した記録と元の位置を持つ履歴が残っていました。新しくアーカイブ画面を作る際に、それを空配列で初期化せず、そのまま扱えるようにしました。
旧履歴にアーカイブ日時がなくても、レコードと位置があれば復元処理は動きます。上の例でも、復元にarchivedAtを必須としていません。日時の表示と、記録を戻せることを分けています。
ただし、読めないデータを無条件に受け入れるわけではありません。元の読み込み処理では、JSONの解析に加え、保存版、一覧と履歴が配列であること、ID、履歴の位置などを確認しています。読み込めない場合は編集・アーカイブ・削除を止め、保存済みデータを新しい初期値で上書きしない構成です。
旧履歴を引き継ぐことと、不正な構造まで正常として使うことは別でした。
架空データで、復元と保存失敗を確認する
試験では、画面から消えることだけでなく、元の詳細が戻ること、入力の配列が変わらないこと、保存に失敗しても現在状態が残ることを確認しました。
次は最小例のテストから、中心になる2件です。archive.mjsと同じフォルダーへ保存して実行できます。
// archive.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { archiveRecord, restoreRecord, createStore } from './archive.mjs';
const record = {
id: 'item-b', title: 'サンプルB',
details: { note: '残しておきたいメモ', rating: 3 },
};
const initial = () => ({
version: 1,
active: [{ id: 'item-a' }, record, { id: 'item-c' }],
archive: [],
});
test('詳細と位置を保って復元し、入力配列を変更しない', () => {
const before = initial();
const next = archiveRecord(before, 'item-b', '2026-10-19T00:00:00Z');
assert.equal(before.active.length, 3);
assert.equal(before.archive.length, 0);
assert.equal(next.archive[0].record, record);
assert.equal(next.archive[0].index, 1);
const restored = restoreRecord(next, 'item-b');
assert.equal(restored.active[1], record);
assert.deepEqual(restored.active, before.active);
assert.equal(restored.archive.length, 0);
});
test('保存失敗時は現在の状態を変更しない', () => {
const before = initial();
const store = createStore(before, {
setItem() { throw new Error('simulated storage failure'); },
});
const next = archiveRecord(store.current(), 'item-b', '2026-10-19T00:00:00Z');
assert.equal(store.commit(next), false);
assert.equal(store.current(), before);
assert.equal(store.current().archive.length, 0);
});
node --test archive.test.mjs
このほか、元の位置が現在の一覧に収まらない場合、同じIDがすでにある場合、対象がない場合、日時のない旧履歴、両配列を一度に保存できることを記事用の例で検証しました。Node.js v24.19.0で計7件が成功しています。
元の状態変更モジュールの3件のテストも実行し、詳細と位置の保持、旧履歴の復元、完全削除で対象外の記録を変えないことを確認しました。保存失敗の試験は例外を注入したもので、実ブラウザーの容量を埋めた試験ではありません。実際の個人記録を削除して検証したものでもありません。
完全削除は、戻せる操作から分けておく
元のアプリでは、通常の一覧に完全削除を置かず、アーカイブ画面で対象名を確認してから行う形にしています。削除候補の保存に失敗したら、確認画面を閉じず、アーカイブに残っていることを表示します。
この完全削除が指すのは、現在のアーカイブから取り除くことです。過去に書き出したJSONや外部保存先の履歴まで消去する処理ではありません。アーカイブもJSON書き出しへ含めるため、バックアップを扱う際には、その点を含めて区別する必要があります。
自分の用途では、普段の整理で記録を失わず、必要なら戻せることを優先しました。レコード全体と位置を残し、状態変更を先に計算して、保存できてから表示を変える。この順序まで決めておくことで、アーカイブを単なる表示フィルターではなく、復元できる保存状態として扱えました。