A simple pattern for versioned persisted state in React Native

Published: (January 15, 2026 at 06:40 PM EST)
4 min read
Source: Dev.to

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:

  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.

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.

Back to Blog

Related posts

Read more »