DEV Community

Sebastian Thiebaud
Sebastian Thiebaud

Posted on

A simple pattern for versioned persisted state in React Native

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));
Enter fullscreen mode Exit fullscreen mode

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:

  1. Define a schema (with defaults)
  2. Store a version alongside the data
  3. When the schema changes, migrate old data forward
  4. 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>;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)