0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptでアーカイブと復元を実装する

0
Last updated at Posted at 2026-10-21

個人用の記録アプリで、通常の一覧から項目を外す操作をアーカイブへ変更しました。一覧からは見えなくしても記録そのものは残し、別画面から戻せるようにしています。完全削除は、そのアーカイブ画面で確認してから行う操作に分けました。

実装で大事にしたのは、レコードの詳細と元の位置を一緒に残すこと、そして保存が成功するまで画面の状態を変えないことです。「戻すボタンを付ける」だけでは、復元後の内容や保存失敗時の扱いまでは決まりませんでした。

今回は、ブラウザー内の一覧を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書き出しへ含めるため、バックアップを扱う際には、その点を含めて区別する必要があります。

自分の用途では、普段の整理で記録を失わず、必要なら戻せることを優先しました。レコード全体と位置を残し、状態変更を先に計算して、保存できてから表示を変える。この順序まで決めておくことで、アーカイブを単なる表示フィルターではなく、復元できる保存状態として扱えました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?