Skip to content
ultrastorageultrastorage
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Was this page helpful?