Reading and writing values
Store rich JavaScript values, add TypeScript types, and initialize or update entries.
localStorage only stores strings, so saving objects means repeatedly serializing and parsing them.
Plain JSON also loses types such as Date, Map, and Set.
ultrastorage handles serialization for you, letting you read and write JavaScript values directly, with helpers for initializing and updating stored data.
import { createStorage } from 'ultrastorage';
export const appStorage = createStorage({ prefix: 'acme' });
Rich values
Uses devalue by default, but you can bring your own serializer.
import { appStorage } from './lib/app-storage';
appStorage.setItem('tags', new Set(['a', 'b', 'c']));
appStorage.getItem('tags'); // Set {'a', 'b', 'c'}
appStorage.setItem('metadata', new Map([['key', 'value']]));
appStorage.getItem('metadata'); // Map {'key' => 'value'}
appStorage.setItem('date', new Date('2025-01-01'));
appStorage.getItem('date'); // Date 2025-01-01T00:00:00.000Z
Supported values depend on the serializer; see serialization limits for unsupported values and differences when using plain JSON.
Import an existing value
Pass legacy to getItem() to import a value previously saved directly to the storage backend.
The destination uses your instance’s prefix; legacy.key is the exact old key.
const theme = appStorage.getItem<string>('theme', {
legacy: {
key: 'theme',
deserialize: (raw) => raw,
},
});
// Reads 'acme:theme' first. If missing, imports 'theme' and removes the old key.
The required deserialize callback runs synchronously. Use JSON.parse for JSON values, or a
custom callback to reshape old data. Add a schema to validate its output:
import { z } from 'zod';
const preferences = appStorage.getItem('preferences', {
legacy: {
key: 'old-preferences',
deserialize: JSON.parse,
},
schema: z.object({ theme: z.enum(['light', 'dark']) }),
});
Importing only runs when the destination is physically missing. A successful import stores the
decoded or schema-transformed value without expiration, then removes the legacy key. Decoder
exceptions or validation issues return null and preserve the source; backend failures throw.
An importing read can write and notify subscribers. See import behavior for existing and expired destinations, cleanup failures, and React usage.
Initialize a value
getOrInit() returns the existing value when present. Otherwise, it creates, stores, and returns a
new value.
const prefs = appStorage.getOrInit('prefs', () => ({
theme: 'light',
lang: 'en',
}));
The factory runs whenever the ordinary read returns null, including stored null, expired
entries, and unreadable data. If the factory returns null, it will run again on the next call.
Use has() alongside getItem() when stored null must be distinguished from a missing entry.
Expiration options apply only when the factory’s value is written. Returning an existing value does not refresh or replace its expiration.
Update a value
Read, modify, and write a value in one call.
appStorage.updateItem<number>('count', (current) => (current ?? 0) + 1);
Every update is a new write. Omitting expiration options preserves the current unexpired deadline; passing a TTL starts a new lifetime from the update:
appStorage.setItem('count', 1, { ttl: 60_000 });
appStorage.updateItem<number>('count', (current) => (current ?? 0) + 1);
// 'count' keeps its original expiration deadline.
appStorage.updateItem<number>('count', (current) => (current ?? 0) + 1, { ttl: 60_000 });
// 'count' now expires 60 seconds after this update.
To remove expiration during an update, pass { expiresAt: null }.
See preserving expiration.
Both helpers read without a schema; see presence and validation.
Array keys
Use an array of strings when several values belong to the same entity or scope:
const userId = '42';
appStorage.setItem(['users', userId, 'profile'], {
name: 'Alice',
});
appStorage.setItem(['users', userId, 'preferences'], {
theme: 'dark',
});
appStorage.getItem(['users', userId, 'profile']);
// { name: 'Alice' }
appStorage.getItem(['users', userId, 'preferences']);
// { theme: 'dark' }
Equivalent arrays address the same entry, so you do not need to reuse the same array instance:
appStorage.setItem(['documents', 'draft'], 'Hello');
appStorage.getItem(['documents', 'draft']); // 'Hello'
Array keys preserve segment boundaries, so separator characters inside a segment do not change the key’s structure:
appStorage.setItem(['users', 'a:b'], 'first');
appStorage.setItem(['users:a', 'b'], 'second');
appStorage.getItem(['users', 'a:b']); // 'first'
appStorage.getItem(['users:a', 'b']); // 'second'
See StorageKey for the complete key contract.
Typing your values
Pass a type argument to describe the expected shape of a stored value.
interface User {
name: string;
age: number;
}
const user = appStorage.getItem<User>('user');
// user is typed as User | null
appStorage.getOrInit<User>('user', () => ({ name: 'Alice', age: 30 }));
appStorage.updateItem<User>('user', (current) => ({
...current!,
age: current!.age + 1,
}));
Type arguments do not validate stored data at runtime. Use a schema during read when validation is required.
Check, remove, and clear
The usual housekeeping.
appStorage.has('user'); // true
appStorage.removeItem('user');
appStorage.has('user'); // false
appStorage.clear(); // remove recognized entries in this namespace
appStorage.clearExpired(); // remove only expired entries
Clearing skips unreadable entries; see clear() for migration and
removal behavior. Reads and writes can also throw; handle storage errors
where you call them.