Jotai provides multiple ways to persist atom state across sessions. This guide covers persistence patterns for localStorage, sessionStorage, AsyncStorage, and URLs.
atomWithStorage
The recommended way to persist atoms is using atomWithStorage from jotai/utils:
import { atomWithStorage } from 'jotai/utils'
const darkModeAtom = atomWithStorage('darkMode', false)
const userAtom = atomWithStorage('user', null)
See the atomWithStorage documentation for complete details.
localStorage Persistence
Simple Pattern
Basic localStorage persistence:
import { atom } from 'jotai'
const strAtom = atom(localStorage.getItem('myKey') ?? 'default')
const persistedAtom = atom(
(get) => get(strAtom),
(get, set, newValue) => {
set(strAtom, newValue)
localStorage.setItem('myKey', newValue)
}
)
With JSON Parsing
Create a helper for JSON serialization:
function atomWithLocalStorage<T>(key: string, initialValue: T) {
const getInitialValue = () => {
const item = localStorage.getItem(key)
if (item !== null) {
return JSON.parse(item)
}
return initialValue
}
const baseAtom = atom(getInitialValue())
const derivedAtom = atom(
(get) => get(baseAtom),
(get, set, update: T | ((prev: T) => T)) => {
const nextValue =
typeof update === 'function'
? (update as (prev: T) => T)(get(baseAtom))
: update
set(baseAtom, nextValue)
localStorage.setItem(key, JSON.stringify(nextValue))
}
)
return derivedAtom
}
// Usage
const userAtom = atomWithLocalStorage('user', { name: 'Guest' })
const settingsAtom = atomWithLocalStorage('settings', { theme: 'light' })
Add error handling for JSON.parse in production code to handle corrupted data.
sessionStorage Persistence
Use sessionStorage with atomWithStorage:
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
const storage = createJSONStorage(() => sessionStorage)
const sessionAtom = atomWithStorage(
'sessionData',
{ isLoggedIn: false },
storage
)
Data persists only for the browser session.
AsyncStorage (React Native)
Persist state in React Native using AsyncStorage:
Install AsyncStorage
npm install @react-native-async-storage/async-storage
Create persistent atom
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
import AsyncStorage from '@react-native-async-storage/async-storage'
const storage = createJSONStorage(() => AsyncStorage)
export const userAtom = atomWithStorage(
'user',
null,
storage
)
Use in components
import { useAtom } from 'jotai'
import { userAtom } from './atoms'
function Profile() {
const [user, setUser] = useAtom(userAtom)
// State persists across app restarts
}
Custom AsyncStorage Helper
Manual AsyncStorage implementation using onMount:
import { atom } from 'jotai'
import AsyncStorage from '@react-native-async-storage/async-storage'
function atomWithAsyncStorage<T>(key: string, initialValue: T) {
const baseAtom = atom(initialValue)
baseAtom.onMount = (setValue) => {
(async () => {
const item = await AsyncStorage.getItem(key)
if (item !== null) {
setValue(JSON.parse(item))
}
})()
}
const derivedAtom = atom(
(get) => get(baseAtom),
(get, set, update: T | ((prev: T) => T)) => {
const nextValue =
typeof update === 'function'
? (update as (prev: T) => T)(get(baseAtom))
: update
set(baseAtom, nextValue)
AsyncStorage.setItem(key, JSON.stringify(nextValue))
}
)
return derivedAtom
}
Learn more: Async documentation
URL Hash Persistence
Persist state in the URL hash:
import { atomWithHash } from 'jotai-location'
const pageAtom = atomWithHash('page', 1)
const filterAtom = atomWithHash('filter', 'all')
// URL: #page=1&filter=all
Serialize Multiple Atoms
Serialize and deserialize multiple atoms:
import { atom, useAtom } from 'jotai'
type Actions =
| { type: 'serialize'; callback: (value: string) => void }
| { type: 'deserialize'; value: string }
const serializeAtom = atom(null, (get, set, action: Actions) => {
if (action.type === 'serialize') {
const obj = {
todos: get(todosAtom),
filter: get(filterAtom),
user: get(userAtom),
}
action.callback(JSON.stringify(obj))
} else if (action.type === 'deserialize') {
const obj = JSON.parse(action.value)
// Add error handling and type checking
set(todosAtom, obj.todos)
set(filterAtom, obj.filter)
set(userAtom, obj.user)
}
})
function PersistControls() {
const [, dispatch] = useAtom(serializeAtom)
const save = () => {
dispatch({
type: 'serialize',
callback: (value) => {
localStorage.setItem('appState', value)
},
})
}
const load = () => {
const value = localStorage.getItem('appState')
if (value) {
dispatch({ type: 'deserialize', value })
}
}
return (
<div>
<button onClick={save}>Save</button>
<button onClick={load}>Load</button>
</div>
)
}
Atom Family Persistence
Serialize atoms created with atomFamily:
import { atomFamily } from 'jotai/utils'
const todoAtomFamily = atomFamily((id: string) =>
atom({ id, title: '', completed: false })
)
const todosAtom = atom<string[]>([])
const serializeAtom = atom(null, (get, set, action: Actions) => {
if (action.type === 'serialize') {
const todos = get(todosAtom)
const todoMap: Record<string, Todo> = {}
todos.forEach((id) => {
todoMap[id] = get(todoAtomFamily(id))
})
const obj = { todos, todoMap }
action.callback(JSON.stringify(obj))
} else if (action.type === 'deserialize') {
const obj = JSON.parse(action.value)
obj.todos.forEach((id: string) => {
const todo = obj.todoMap[id]
set(todoAtomFamily(id), todo)
})
set(todosAtom, obj.todos)
}
})
Version Migration
Handle storage version changes:
import { atomWithStorage } from 'jotai/utils'
interface Settings {
version: number
theme: string
language: string
}
const CURRENT_VERSION = 2
function migrateSettings(stored: any): Settings {
if (!stored || stored.version < 2) {
return {
version: CURRENT_VERSION,
theme: stored?.theme ?? 'light',
language: 'en', // New field in v2
}
}
return stored
}
const settingsAtom = atomWithStorage<Settings>(
'settings',
{
version: CURRENT_VERSION,
theme: 'light',
language: 'en',
},
{
getItem: (key) => {
const stored = localStorage.getItem(key)
if (!stored) return null
return migrateSettings(JSON.parse(stored))
},
setItem: (key, value) => {
localStorage.setItem(key, JSON.stringify(value))
},
removeItem: (key) => {
localStorage.removeItem(key)
},
}
)
Sync Across Tabs
Sync atoms across browser tabs:
import { atom } from 'jotai'
function atomWithBroadcast<T>(key: string, initialValue: T) {
const baseAtom = atom(initialValue)
baseAtom.onMount = (setValue) => {
const channel = new BroadcastChannel(key)
channel.onmessage = (event) => {
setValue(event.data)
}
return () => channel.close()
}
const derivedAtom = atom(
(get) => get(baseAtom),
(get, set, update: T) => {
set(baseAtom, update)
const channel = new BroadcastChannel(key)
channel.postMessage(update)
channel.close()
}
)
return derivedAtom
}
const syncedAtom = atomWithBroadcast('myKey', 0)
// Changes sync across all tabs
Tips
Use atomWithStorage for most persistence needs. It handles serialization, initialization, and edge cases.
Add version numbers to persisted data to handle schema migrations gracefully.
Wrap JSON.parse in try-catch to handle corrupted localStorage data without crashing.
In React Native, use AsyncStorage with atomWithStorage and createJSONStorage for seamless persistence.