A simple pattern for versioned persisted state in React Native
Source: Dev.to
The problem I ran into (very quickly)
In my first React Native project I needed to persist user preferences using AsyncStorage. At first it was trivial:
await AsyncStorage.setItem("state", JSON.stringify(data));
But as the project grew I ran into several issues:
- Added a new field
- Renamed another one
- Needed defaults for existing users
- Wanted TypeScript to actually help me
- Didn’t want to silently wipe user data
So I started looking for a solution…
What I found
- State libraries that serialize state but don’t help you evolve it
- Migration systems that are either tightly coupled to a framework or runtime‑only and loosely typed
What I wanted was boring, explicit, and safe.
So I wrote my own version.
Today I’m sharing it so others can use it directly or borrow whatever ideas are useful for their own codebases.
The idea: treat persisted state like a schema, not a blob
The mental shift that helped was this:
Persisted state is not “just JSON” — it’s data with a versioned schema.
Once you accept that, the rest becomes fairly mechanical:
- Define a schema (with defaults)
- Store a version alongside the data
- When the schema changes, migrate old data forward
- Validate everything
That’s it.
Core principles (my non‑negotiables)
- Type safety end‑to‑end
- Explicit migrations (no magic inference)
- Deterministic upgrades (no “best effort”)
- Storage‑agnostic (AsyncStorage, localStorage, memory)
- Easy to delete later if I decide I don’t like it
That last point matters more than people admit.
A minimal example
1️⃣ Define a schema (Zod)
import { z } from "zod";
export const persistedSchema = z.object({
_version: z.number(),
preferences: z.object({
colorScheme: z.enum(["system", "light", "dark"]).default("system"),
}).default({ colorScheme: "system" }),
});
export type PersistedState = z.infer<typeof persistedSchema>;
Zod gives me:
- Runtime validation
- Compile‑time types
- Defaults for free
2️⃣ Create storage (AsyncStorage, localStorage, or memory)
import { createPersistedState } from "@sebastianthiebaud/schema-versioned-storage";
import { createAsyncStorageAdapter } from
"@sebastianthiebaud/schema-versioned-storage/adapters/async-storage";
const storage = createPersistedState({
schema: persistedSchema,
storageKey: "APP_STATE",
storage: createAsyncStorageAdapter(),
migrations: [],
getCurrentVersion: () => 1,
});
await storage.init(); // loads, validates, applies defaults, runs migrations
3️⃣ Use it (fully typed)
// Read
const theme = storage.get("preferences").colorScheme;
// Write
await storage.set("preferences", {
colorScheme: "dark",
});
If I mistype a key or value, TypeScript yells at me before runtime.
Migrations are explicit and boring (by design)
When the schema changes, I add a migration:
import type { Migration } from "@sebastianthiebaud/schema-versioned-storage";
const migration: Migration = {
metadata: {
version: 2,
description: "Add language preference",
},
migrate: (state: unknown) => {
// `as any` required here since the old schema is no more :-(
const old = state as any;
return {
...old,
_version: 2,
preferences: {
...old.preferences,
language: "en",
},
};
},
};
export default migration;
No inference. No guessing. No “maybe it works”.
If a migration is missing or invalid, initialization fails loudly.
Adapters
Storage APIs differ just enough to be annoying, so I standardized on a tiny adapter interface:
interface StorageAdapter {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
That gives me:
- AsyncStorage for React Native
- localStorage for web
- An in‑memory adapter for tests
Testing migrations with the in‑memory adapter turned out to be way nicer than mocking AsyncStorage.
React integration (optional)
A thin context + hook avoids prop‑drilling:
const storage = useStorage();
No magic—just a thin wrapper around the same storage instance.
CLI – remove friction
A small CLI generates boilerplate for me:
- Generate migration files
- Generate migration indexes
- Hash schemas to detect changes
Example:
npx svs generate-migration --name add-field --from 1 --to 2
Nothing fancy—just fewer foot‑guns.
Is this “the best” solution?
Probably not.
But it is:
- Understandable in one sitting
- Easy to delete if you hate it
- Explicit about how data evolves
- Type‑safe where it matters
If this helps you, great — if not, feel free to steal the ideas.
I’m sharing this mostly because I couldn’t find something that matched this mental model when I started. Happy coding!
If you use it directly, copy parts of it, or just steal the migration pattern, then that’s a success in my book.
The code is here:
👉 schema-versioned-storage on GitHub
Happy to hear how other people are handling persisted state evolution in React Native — I’m still learning too.