Skip to main content
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:
1

Install AsyncStorage

npm install @react-native-async-storage/async-storage
2

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
)
3

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.

Build docs developers (and LLMs) love