RecoilをIndexdDBに永続化する

# 容量問題の解消のためIndexedDBへ

現在個人開発で短編小説を書いて保存しておくためのWEBアプリを開発しています。
短い小説を繰り返し書くことは、小説を書くための練習になるので、そのためのアプリです。

**Next.js**の**SSG**＋**Recoil**の**localstorage**への永続化によって、自動保存＋高速動作できるようにを心掛けています。（高速動作がどれだけ実現できてるかは別問題）

ですが、短編とはいえ練習のために沢山小説を書くとなると**localstorage**の容量では心許ないと思っていました。

最大限の文字数で書くと、短編4～5本くらいで容量が足りなくなりそうです。

ただ外部DBへの保存だと自動保存の動作は重くなるかと思います。

そこで**localStorage**以外にlocalに保存できる場所はないか調べてみると、**IndexedDB**とWebSQLがあることを知りました。**WebSQL**は現在更新がないそうなので、**IndexedDB**の方が推奨されています。

そのまま**Recoil**の永続化を**localstorage**から**IndexedDB**へ変更したかったのですが、サクッとそのまま変更といかなかったのと、情報が少なくて苦戦を強いられましたので記事にして共有します。

# IndexdDBとは

秘書のGPTさんによると

> IndexedDBは、Webブラウザ内で動作するクライアントサイドのデータベースであり、JavaScript APIを通じてアクセスできます。オブジェクトストアにデータを保存し、インデックスを使用して検索やフィルタリングができます。IndexedDBは非同期的に動作し、オフライン動作を可能にし、Webストレージよりも高度な検索とデータ構造をサポートします。しかし、学習コストが高く、データベースの管理とアップグレードにも注意が必要です。
> 

とのことです。

## localForageライブラリでIndexdDBを利用する

ちょっと調べてみても素の**IndexdDB**を操作して永続化するのはしんどそうだったので、ライブラリを探してみると**localForage**ライブラリを見つけました。

**localForage**は**localstorageAPI**と同じような操作で**IndexdDB**や**WebSQL**などのWEBストレージを利用できます。

また[こちらの記事](https://qiita.com/dende-h/items/16d8d65d52361000cef7)で紹介しているように、わたしは**Recoil**の永続化に**Recoil-persist**というモジュールを利用しているのですが、その[GitHubのissue](https://github.com/polemius/recoil-persist/issues/53)に**localForage**を使って**WebSQL**に永続化する例を見つけたので、**IndexdDB**でも利用できるのではと思いやってみることにしました。

## atomの変更

さっそく**Recoil**で永続化している**`atom`**を変更してみます。

```jsx
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { atom } from "recoil";
import { recoilPersist } from "recoil-persist";
import { draftObject } from "../selector/editorState";
import localforage from "localforage";
import { PersistStorage } from "recoil-persist";

export type draftObjectArray = draftObject[];

//IndexdDBの初期化
localforage.config({
	driver: localforage.INDEXEDDB,
	name: "drafts",
	version: 2,
	storeName: "draftObject"
});

const customStorage = (): PersistStorage => {
	return {
		setItem: async (key: string, value: string) => {
			
			await localforage.setItem(key, value);

		},
		getItem: async (key: string): Promise<string> => {
			
			const value: string = await localforage.getItem(key);
			
			return value === null ? undefined : value;
		}
	};
};

const { persistAtom } = recoilPersist({
	key: "recoil-persist",

	storage: typeof window === "undefined" ? undefined : customStorage()
});

export const drafts = atom({
	key: "drafts",
	default: [],
	effects_UNSTABLE: [persistAtom]
});
```

`localforage.config`でIndexdDBを利用できるようにします。`name`プロパティが`key`となってテーブルが作成されます。

 Recoil-persistは非同期処理に対応していないため`customStorage()`で対応しています。isuueにあったコードまんまだと型エラー等がでてしまったので、いろいろ修正して動いた形です。

## 発生した問題

動作させてみて、DBに保存更新ができており、アプリの動作も異常がなさそうだったのですが色々試してみるといきなりデータが全て消えてしまいました。

再現手順を確認するとどうやらアプリ上で何も情報更新せずにリロードをするとデータが消えます。

`cosole.log()`を仕込んで処理の流れを追うと、初期表示→DBからデータを取得して`atom`に設定をしたのち今度は`atom`のdefault値（今回の場合は［］）がDBに保存されていました。

初期表示時にDBのデータを**`atom`**にセットできているのと、アプリデータを更新するとそのデータが再度DBに上書きされるので一見普通に動いているように見えましたが、実は初期表示直後にdefault値が上書きされているため`atom`の値とDBのデータに相違ができていました。

もちろんそのままリロード、もしくはブラウザを消去すると、次回起動時には持ってくるDB上のデータが空っぽなのでデータが全て消え失せていました。

どうやらDB→atomへのセットと、atom(default値)→DBへのセットが非同期で同時に処理されているようで弱弱エンジニアのわたしは頭を抱えました。

# customRecoilPersistの作成

幸いRecoil-Persistモジュール自体は単体のコードで動いており、そのまま自分のソースに移植しても問題なく動作するようだたったので、そこから調査しようと思いました。

また[mergeされていないPullRequest](https://github.com/polemius/recoil-persist/pull/55)にlocalfoageとの互換性を追加したコードがありましたのでそれを使うことにしました。

作成したのが下記になります。

```jsx
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable prefer-const */
/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-empty-interface */
import { AtomEffect } from "recoil";

type PersistItemValue = string | undefined | null;

export interface PersistStorage {
	setItem(key: string, value: string): any;
	mergeItem?(key: string, value: string): any;
	getItem(key: string): PersistItemValue | Promise<PersistItemValue>;
}

export interface PersistState {}

export interface PersistConfiguration {
	key: string;
	storage: PersistStorage;
}

interface PendingChanges {
	queue: Promise<any> | null;
	updates: Partial<PersistState>;
	reset: Record<string, boolean>;
}

/**
 * Recoil module to persist state to storage
 *
 * @param config Optional configuration object
 * @param config.key Used as key in local storage, defaults to `recoil-persist`
 * @param config.storage Local storage to use, defaults to `localStorage`
 */
export const recoilPersist = (config: Partial<PersistConfiguration> = {}): { persistAtom: AtomEffect<any> } => {
	if (typeof window === "undefined") {
		return {
			persistAtom: () => {}
		};
	}
	const {
		key = "recoil-persist" as PersistConfiguration["key"],
		storage = localStorage as PersistConfiguration["storage"]
	} = config;

	const pendingChanges: PendingChanges = {
		queue: null,
		updates: {},
		reset: {}
	};

	**let firstFlug = false;　//追加**

	const persistAtom: AtomEffect<any> = ({ onSet, node, trigger, setSelf }) => {
		if (trigger === "get") {
			getState().then((s) => {
				if (s.hasOwnProperty(node.key)) {
					setSelf(s[node.key]);

					**firstFlug = true;　//追加**

				} else {

					**if (!firstFlug) firstFlug = true;　//追加**

				}
			});
		}

		onSet((newValue, _, isReset) => {
		
　　	**if (firstFlug) {　　//追加**
		
　　		if (isReset) {
					pendingChanges.reset[node.key] = true;
					delete pendingChanges.updates[node.key];
				} else {
					pendingChanges.updates[node.key] = newValue;
				}
				if (!pendingChanges.queue) {
					pendingChanges.queue = getState().then((state) => {
						if (JSON.stringify(state[node.key]) !== JSON.stringify(newValue)) {
							updateState(state, pendingChanges);
						}
						pendingChanges.queue = null;
						pendingChanges.reset = {};
						pendingChanges.updates = {};
					});
				}
			}
		});
	};

	const updateState = (state: PersistState, changes: PendingChanges) => {
		Object.keys(changes.reset).forEach((key) => {
			delete state[key];
		});
		Object.keys(changes.updates).forEach((key) => {
			state[key] = changes.updates[key];
		});
		setState(state);
	};

	const parseState = (toParse: PersistItemValue): PersistState => {
		if (toParse === null || toParse === undefined) {
			return {};
		}
		try {
			return JSON.parse(toParse);
		} catch (e) {
			console.error(e);
			return {};
		}
	};

	const getState = (): Promise<PersistState> => Promise.resolve(storage.getItem(key)).then(parseState);
	const setState = (state: PersistState): void => {
		try {
			if (typeof storage.mergeItem === "function") {
				storage.mergeItem(key, JSON.stringify(state));
			} else {
				storage.setItem(key, JSON.stringify(state));
			}
		} catch (e) {
			console.error(e);
		}
	};
	return { persistAtom };
};
```

持ってきたコードほぼまんまですが、初期値の上書き問題は解決できていないため、いくつかコードを追加してみました。

色々試したのですがうまくいかず、自分でも微妙だなと思いつつも、唯一想定通りの動作になったものです。

今回の場合初回読み込みの`getState()`関数と`onSet()`関数が同時動いているのが問題でした。

`if (s.hasOwnProperty(node.key))` は初回読み込み時かつ、DBにデータが存在する場合に動くので、それが終わった後に`firstFlug`を変更しています。

また初回にDBにデータがない場合は`firstFlug`が`false`だった時に`firstFlug`を`ture`にしています。

`onSet`内部を`firstFlug`が`true`の時のみ動かすようにしたので、同時に動く初回のみ`onSet`をスキップします。これによって初期値上書きを回避できました。

ブラウザごとのテストはしていないのでもしかしたらうまく動かないブラウザがあるかもしれません。

コードの問題や、もっとスマートに解決できる方法があったらぜひコメントで教えてください。

## localforageとの互換性

もってきたコードには**localforage**との互換性が追加されてますので、`atom`のコードもすっきり書けます。
**IndexdDB**の初期化以外は**、localstorage**の時と一緒。

これは素晴らしいですね( *´艸｀)

```jsx
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { atom } from "recoil";
import { recoilPersist } from "../../components/util/customRecoilPersist";
import { draftObject } from "../selector/editorState";
import localforage from "localforage";

export type draftObjectArray = draftObject[];

//IndexdDBの初期化
localforage.config({
	driver: localforage.INDEXEDDB,
	name: "drafts",
	version: 2,
	storeName: "draftObject"
});

const { persistAtom } = recoilPersist({
	key: "recoil-indexeddb",

	storage: typeof window === "undefined" ? undefined : localforage
});

export const drafts = atom({
	key: "drafts",
	default: [],
	effects_UNSTABLE: [persistAtom]
});
```

# おわりに

今回参考にさせて頂いたのは、[Recoil-persistモジュールのGitHub](https://github.com/polemius/recoil-persist)でした。

いつも利用しており、非常に便利なものを作っていただいてありがとうございます。

この実装によって、おおよそ初めに入れたい機能を入れたアプリとなりました。

この記事以外にも、コミットを遡って振り返り記事をちょこちょこ書いていこうかと思います。

良かったらアプリも触ってみて、問題を見つけたらコメントにて教えていただけると助かります。

[短編小説ライティングアプリ【WriteNovelNow(仮)】](https://next-novel-editor.vercel.app/)