I’ve only been working with React Native for a few weeks. I thought persisted state would be a solved problem. Store some JSON, read it back, done.
That assumption lasted exactly one day.
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 then, as I progressed through my project implementation:
- I added a new field
- I renamed another one
- I needed defaults for existing users
- I wanted TypeScript to actually help me
- I really didn’t want to silently wipe user data
So I went 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 this 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.
The core principles I stuck to
When I built this, I had a few "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
This last point matters more than people admit.
A minimal example
Here’s what persisted state looks like with this approach.
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();
At this point, data is loaded, validated, defaults are applied, and migrations (if any) are run
3. Use it (fully typed)
const theme = storage.get("preferences").colorScheme;
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<PersistedState> = {
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 an in-memory adapter turned out to be way nicer than mocking AsyncStorage.
React integration (optional)
I didn’t want this tied to React, but it is convenient to avoid prop drilling. So there’s an optional context + hook:
<StorageProvider storage={storage}>
<App />
</StorageProvider>
...
const storage = useStorage<PersistedState>();
No magic. Just a thin wrapper around the same storage instance.
The CLI exists purely to remove friction
I also added a small CLI because I got tired of writing boilerplate:
- 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 footguns.
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 in the places that matter
And for me, that’s a win.
If this helps you, great — if not, steal the ideas :-)
I’m sharing this mostly because I couldn’t find something that matched this mental model when I started.
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:
👉 https://github.com/SebastianThiebaud/schema-versioned-storage
Happy to hear how other people are handling persisted state evolution in React Native — I’m still learning too.
Top comments (0)