入力途中の内容が消える問題
フォームに入力している途中で、ページを閉じたり、戻るボタンを押したりして、入力内容が消えてしまったことはないでしょうか。
たとえば、次のようなフォームがあるとします。
- 名前
- 今住んでいる場所
- 仕事
- 近況コメント
- SNS URL
スマホで長めのコメントを書いている途中に、別のアプリを開いて戻ってきたら入力内容が消えていた。
これはユーザーにとってかなりつらい体験です。
このような「まだ送信していないけれど、途中まで入力した内容」をブラウザに残しておきたいときに使えるのが localStorage です。
この記事では、Reactアプリのフォーム下書き保存をサンプルにしながら、localStorage の基本を説明します。
localStorageとは
localStorage とは、ブラウザにデータを保存できる仕組みです。
ざっくり言うと、ブラウザの中にある小さな保存箱のようなものです。
JavaScriptから次のように使えます。
localStorage.setItem('name', 'Taro')
保存したデータは、ページをリロードしても残りますし、ブラウザを閉じて、もう一度同じサイトを開いたときにも残っています。
ただし、保存される場所はサーバーではありません。ユーザーが使っているブラウザの中です。
そのため、次のような特徴があります。
- 同じブラウザで同じサイトを開くと、あとから取り出せる
- ページを閉じてもデータが残る
- 保存できるのは文字列だけ
- サーバーに送らなくても保存できる
- 別の端末や別のブラウザには自動では共有されない
たとえば、PCのChromeで保存した localStorage のデータは、スマホのSafariには自動では出てきません。
localStorageは「そのブラウザの中だけに残る保存場所」と考えると理解しやすいです。
まずは保存・読み出し・削除を試す
localStorageでまず覚えるメソッドは次の3つです。
setItemgetItemremoveItem
setItem(保存する)
データを保存するときは setItem を使います。
localStorage.setItem('name', 'Taro')
1つ目の引数が保存する名前です。これをキーと呼びます。
2つ目の引数が保存する値です。
この例では、name というキーに Taro という文字列を保存しています。
getItem(取得する)
保存したデータを取り出すときは getItem を使います。
const name = localStorage.getItem('name')
console.log(name)
先ほど保存した値が残っていれば、Taro が取り出せます。
もし何も保存されていなければ、null が返ります。
そのため、実際に使うときは「値がない場合」も考えておく必要があります。
const name = localStorage.getItem('name')
if (name) {
console.log(`保存されていた名前: ${name}`)
} else {
console.log('名前はまだ保存されていません')
}
removeItem(削除する)
保存したデータを消すときは removeItem を使います。
localStorage.removeItem('name')
これで name というキーに保存されていた値が削除されます。
localStorageは保存するだけでなく、「いつ消すか」も大事です。
たとえばフォームの下書きなら、投稿が成功したあとには下書きを消した方が自然です。
オブジェクトを保存するときはJSONにする
localStorageで注意したいのは、保存できるのが文字列だけという点です。
文字列ならそのまま保存できます。
localStorage.setItem('message', 'こんにちは')
しかし、フォームの内容はたいてい1つの文字列ではありません。
たとえば、次のように複数の値をまとめたオブジェクトとして扱いたくなります。
const draft = {
name: '三木 道三',
currentLocation: 'ジャマイカ',
job: 'レゲエミュージシャン・シンガー',
comment: 'Respect for all life time',
}
このようなオブジェクトをlocalStorageに保存したいときは、JSON.stringify を使って文字列に変換します。
localStorage.setItem('draft', JSON.stringify(draft))
取り出すときは、保存されている文字列を JSON.parse でオブジェクトに戻します。
const rawValue = localStorage.getItem('draft')
if (rawValue) {
const draft = JSON.parse(rawValue)
console.log(draft.name)
}
つまり、オブジェクトをlocalStorageに保存するときは、次の流れになります。
const draft = {
name: '三木 道三',
comment: 'Respect for all life time',
}
localStorage.setItem('draft', JSON.stringify(draft))
const rawValue = localStorage.getItem('draft')
if (rawValue) {
const draft = JSON.parse(rawValue)
console.log(draft.comment)
}
JSON.stringify と JSON.parse は、localStorageを使うときによく出てくる組み合わせです。
フォームの下書きを保存してみる
ここからは、実際のReactアプリで使っているコードをサンプルにして見ていきます。
サンプルにするのは「近況ノート」というアプリです。
このアプリには、同級生が近況を入力するフォームがあります。
入力項目は次のようなものです。
- 名前
- 当時の呼び名
- 今住んでいる地域
- 今の仕事・活動
- 近況コメント
- SNS URL
- 公開範囲
入力途中でページを離れても内容が消えないように、フォームの下書きをlocalStorageに保存しています。
下書き保存の処理は、主に src/lib/draft.ts にまとめています。
保存キーを決める
localStorageに保存するときは、データに名前をつける必要があります。
(先ほど「キー」として解説したやつ)
サンプルアプリでは、次のようにキーを作っています。
const draftPrefix = 'kinkyo-note:classmate-draft:v1:'
function getDraftKey(slug: string) {
return `${draftPrefix}${slug}`
}
たとえば slug が oita-2016 の場合、キーは次のようになります。
kinkyo-note:classmate-draft:v1:oita-2016
少し長いですが、意味が分かる名前になっています。
分解するとこうです。
-
kinkyo-note- アプリ名
-
classmate-draft- 同級生入力フォームの下書き
-
v1- 保存形式のバージョン
-
oita-2016- どのグループの下書きか
localStorageは同じサイト内で使う保存場所なので、キー名が雑だと後から分かりにくくなります。
雑なキー名でよければ、例えば draft だけでも動きます。
localStorage.setItem('draft', '...')
しかし、アプリが大きくなって他の下書きも保存したくなったとき、何の下書きなのか分かりづらくなります。
最初から意味のあるキー名にしておくと、あとで見返したときにも理解しやすいです。
下書きを保存する
下書きを保存する関数はこんな感じです。
export function saveClassmateDraft(slug: string, draft: ClassmateFormValues) {
localStorage.setItem(getDraftKey(slug), JSON.stringify(draft))
}
やっていることは2つです。
1つ目は、getDraftKey(slug) で保存するキーを作ること。
2つ目は、JSON.stringify(draft) でフォームの値を文字列にすること。
localStorageは文字列しか保存できないので、フォームの値をそのまま保存するのではなく、JSON文字列に変換しています。
イメージとしては、フォームの中身を文字列に変換して、ブラウザの保存箱に入れている感じです。
下書きを取得する
次に、保存した下書きを取得する処理です。
const emptyDraft: ClassmateFormValues = {
name: '',
nickname: '',
currentLocation: '',
job: '',
comment: '',
snsUrl: '',
visibility: 'public',
}
まず、何も保存されていないときの空データを用意しています。
フォームを最初に開いたときや、まだ下書きがないときは、この emptyDraft を使います。
取得する関数は次のようになっています。
export function getClassmateDraft(slug: string): ClassmateFormValues {
try {
const rawValue = localStorage.getItem(getDraftKey(slug))
if (!rawValue) return emptyDraft
const parsedValue = JSON.parse(rawValue) as Partial<ClassmateFormValues>
return {
...emptyDraft,
...parsedValue,
visibility:
parsedValue.visibility === 'organizer_only'
? 'organizer_only'
: 'public',
}
} catch {
return emptyDraft
}
}
少し長いので、分けて見ていきます。
まず、localStorageから値を取り出します。
const rawValue = localStorage.getItem(getDraftKey(slug))
ここで取れる値は文字列です。
まだ何も保存されていなければ null になります。
その場合は、空の下書きを返します。
if (!rawValue) return emptyDraft
保存されている値がある場合は、JSON文字列をオブジェクトに戻します。
const parsedValue = JSON.parse(rawValue) as Partial<ClassmateFormValues>
そのあと、空の下書きと保存されていた値を合体させています。
return {
...emptyDraft,
...parsedValue,
}
こうしておくと、保存されているデータに一部の項目が足りなくても、空文字などの初期値で補えます。
また、この関数全体は try/catch で囲まれています。
try {
// 読み出し処理
} catch {
return emptyDraft
}
これは、localStorageの中身が必ず正しいとは限らないからです。
たとえば、開発中にDevToolsから手で値を書き換えたり、古い形式のデータが残っていたりすると、JSON.parse が失敗することがあります。
そのときにアプリが止まらないように、失敗したら空の下書きを返すようにしています。
localStorageからデータを取得する処理では、失敗に備えておくのが大切です。
Reactのフォームに下書きを戻す
保存した下書きは、フォームを開いたときに初期値として使います。
サンプルアプリではReact Hook Formを使っていて、次のように defaultValues に渡しています。
const defaultValues = useMemo(() => getClassmateDraft(slug), [slug])
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<ClassmateFormValues, unknown, ClassmateInput>({
resolver: zodResolver(classmateInputSchema),
defaultValues,
})
流れはこうです。
- 画面を開く
-
getClassmateDraft(slug)でlocalStorageから下書きを読む - 読み出した値を
defaultValuesに入れる - フォームに前回の入力内容が入った状態で表示される
もし下書きがなければ、先ほどの emptyDraft が返るので、空のフォームが表示されます。
つまり、フォームを開いた瞬間に「前に入力していた内容があるかな?」と確認して、あればフォームに戻しているということです。
入力が変わるたびに保存する
次は、入力内容をlocalStorageに保存するタイミングです。
サンプルアプリでは、フォーム全体に onChange を設定しています。
<form
className="space-y-7 pb-24 pt-2"
onChange={handleDraftChange}
onSubmit={handleSubmit(onSubmit)}
>
フォーム内のどこかの入力が変わると、handleDraftChange が呼ばれます。
function handleDraftChange(event: React.FormEvent<HTMLFormElement>) {
const formData = new FormData(event.currentTarget)
const comment = String(formData.get('comment') ?? '')
setCommentLength(comment.length)
saveClassmateDraft(slug, {
name: String(formData.get('name') ?? ''),
nickname: String(formData.get('nickname') ?? ''),
currentLocation: String(formData.get('currentLocation') ?? ''),
job: String(formData.get('job') ?? ''),
comment,
snsUrl: String(formData.get('snsUrl') ?? ''),
visibility:
formData.get('visibility') === 'organizer_only'
? 'organizer_only'
: 'public',
})
}
ここでは FormData を使って、フォーム全体の現在の値を集めています。
const formData = new FormData(event.currentTarget)
そして、各入力項目の値を取り出して、saveClassmateDraft に渡しています。
saveClassmateDraft(slug, {
name: String(formData.get('name') ?? ''),
nickname: String(formData.get('nickname') ?? ''),
currentLocation: String(formData.get('currentLocation') ?? ''),
job: String(formData.get('job') ?? ''),
comment,
snsUrl: String(formData.get('snsUrl') ?? ''),
visibility:
formData.get('visibility') === 'organizer_only'
? 'organizer_only'
: 'public',
})
これで、ユーザーが入力するたびに下書きが保存されます。
イメージとしては、入力するたびに自動でメモを取っているような状態です。
小さなフォームであれば、このようにシンプルに保存しても分かりやすいです。
もっと大きなフォームでは、毎回保存するのではなく、少し待ってから保存するdebounceのような工夫を入れることもあります。
投稿できたら下書きを消す
下書きは保存するだけではなく、不要になったら消す必要があります。
サンプルアプリでは、下書きを消す関数も用意しています。
export function clearClassmateDraft(slug: string) {
localStorage.removeItem(getDraftKey(slug))
}
投稿が成功したあとに、この関数を呼びます。
clearClassmateDraft(slug)
もし投稿に成功したあとも下書きが残っていると、次にフォームを開いたときに、前回投稿した内容がまた表示されてしまい、UXがあまり良くないと感じました。
そのため、投稿が成功したタイミングで下書きを削除します。
一方で、投稿に失敗したときは下書きを消さない方がよいです。
通信エラーなどで投稿できなかったときに下書きまで消えてしまうと、ユーザーはもう一度入力し直すことになります。
なので、考え方は次のようになります。
- 投稿に成功したら下書きを消す
- 投稿に失敗したら下書きを残す
localStorageは、保存するタイミングだけでなく、削除するタイミングもセットで考えると使いやすくなります。
TTLを設定して古い下書きを削除する
また、もう少し丁寧に作るならTTLを持たせる方法もあります。
TTLは Time To Live の略で、「このデータをどれくらいの期間残すか」という意味です。
たとえば、下書きを保存するときに入力内容だけでなく、保存した時刻も一緒に入れておきます。
const storedDraft = {
savedAt: new Date().toISOString(),
values: draft,
}
localStorage.setItem(getDraftKey(slug), JSON.stringify(storedDraft))
読み出すときに savedAt を見て、古すぎる下書きなら使わずに削除します。
const maxAgeMs = 1000 * 60 * 60 * 24 * 7
const savedAtTime = new Date(storedDraft.savedAt).getTime()
const isExpired = Date.now() - savedAtTime > maxAgeMs
if (isExpired) {
localStorage.removeItem(getDraftKey(slug))
return emptyDraft
}
この例では、7日より古い下書きは期限切れとして扱っています。
TTLを入れると、かなり前に入力した古い下書きが突然フォームに出てくるのを防げます。
下書きを長期間残したくない場合や、古い入力内容が出てくると困る場合にTTLを検討するとよいです。
localStorageに保存してよいもの・よくないもの
localStorageは便利ですが、何でも保存してよいわけではありません。
まず、保存してもよいものの例です。
- フォームの下書き
- 画面の表示設定
- 最後に開いていたタブ
- 一時的なUIの状態
- チュートリアルを見たかどうか
共通しているのは、消えても致命的ではなく、そのブラウザだけに残ればよいデータです。
逆に、localStorageに保存しない方がよいものもあります。
- パスワード
- クレジットカード情報
- アクセストークンなどの重要な認証情報
- 他人に見られると困る個人情報
- 大量の画像や動画
- 複数端末で同期したいデータ
localStorageは、便利な保存場所ではありますが、安全な金庫ではありません。
ブラウザのDevToolsから中身を見ることもできます。
そのため、見られて困る情報や、漏れると問題になる情報は入れないようにします。
フォームの下書きに使う場合も、保存する内容が本当にlocalStorage向きかは考えた方がよいです。
補足:画像保存には注意
この記事ではフォームの下書き保存を中心に説明しました。
補足として、画像をlocalStorageに保存する例にも触れておきます。
サンプルアプリでは、投稿に紐づく写真をData URLとして保存しています。
const classmateImagesKey = 'kinkyo-note:classmate-images:v1'
export function saveClassmateImageDataUrl(classmateId: number, dataUrl: string) {
const imageMap = getClassmateImageMap()
imageMap[String(classmateId)] = dataUrl
localStorage.setItem(classmateImagesKey, JSON.stringify(imageMap))
}
これまで解説してきたように、localStorageには文字列しか保存できないため、画像ファイルをそのまま入れることはできません。
画像を保存したい場合は、Data URLのような文字列の形に変換する必要があります。
ただし、画像はテキストよりもかなりサイズが大きくなりやすく、localStorageには容量の制限もあります。
そのため、本格的に画像を保存したい場合は、localStorageではなく、サーバー側のストレージやIndexedDBなどを検討した方がよいです。
画像のような大きいデータは、使いどころを慎重に考えましょう。
まとめ
localStorageは、ブラウザにデータを保存できる仕組みです。
ページを閉じてもデータが残るので、フォームの下書き保存のような用途に向いています。
基本の使い方は次の3つです。
localStorage.setItem('key', 'value')
localStorage.getItem('key')
localStorage.removeItem('key')
オブジェクトを保存したいときは、JSON.stringify で文字列に変換します。
取り出すときは、JSON.parse でオブジェクトに戻します。
Reactのフォームでは、次のような流れで下書き保存を作れます。
- フォームを開いたときに
localStorageから下書きを読む - 下書きがあればフォームの初期値にする
- 入力が変わるたびに
localStorageへ保存する - 投稿に成功したら下書きを削除する
localStorageは手軽ですが、秘密の情報や大きなデータを入れる場所ではありません。
フォームの下書きや画面設定のような「そのブラウザだけに残ればよい小さなデータ」で使ってみましょう。