# Svelte 5 `$state` won't save to IndexedDB: fixing DataCloneError

Building Recipe Jar, I store every recipe in the browser's IndexedDB. No server, no account, the data just lives on your device. The first time I wired the save button in Svelte 5, it threw this:

```plaintext
DataCloneError: Failed to execute 'add' on 'IDBObjectStore':
#<Object> could not be cloned.
```

The object looked completely normal in the console. Plain title, an array of ingredients, some numbers. Nothing weird. But IndexedDB refused to store it.

Here is the code that broke:

```svelte
<script lang="ts">
  let recipe = $state<Recipe>({ title: '', ingredients: [] })

  async function save() {
    await db.recipes.add(recipe) // DataCloneError
  }
</script>
```

### **Why it happens**

Svelte 5 makes state reactive by wrapping it in a Proxy. When you write `$state({ ... })`, you don't get back a plain object. You get a proxy that intercepts reads and writes so the UI can update.

IndexedDB stores values using the structured clone algorithm. Structured clone knows how to copy plain objects, arrays, dates, and a fixed set of built-in types. It does not know how to copy a Proxy. So the moment that reactive object reaches the database boundary, the clone fails and you get `DataCloneError`.

It looks like plain data to you because the proxy is transparent when you read it. The clone algorithm sees the wrapper, not the data.

### **The fix**

My recipe data is plain JSON: strings, numbers, arrays, no dates or Maps or Sets. So the fix I shipped is a JSON round-trip that reads through the proxy and hands back a plain object:

```ts
// db.ts
function toPlainRecipe(recipe: Recipe): Recipe {
  return JSON.parse(JSON.stringify(recipe)) as Recipe
}
```

`JSON.stringify` walks the proxy and pulls the values out, and `JSON.parse` rebuilds a plain object with no proxy attached. Because a recipe is JSON-only data, nothing is lost.

If your state is not pure JSON, reach for Svelte's built-in instead:

```ts
await db.recipes.add($state.snapshot(recipe))
```

`$state.snapshot()` returns a deep, static copy with the proxies stripped, and unlike a JSON round-trip it keeps real `Date` objects and does not choke on `Map` or `Set`. Svelte added it for exactly this situation: handing state to something that does not expect a proxy, like `structuredClone`. Use the JSON trip when your data is plain and you want zero surprises about types. Use the snapshot when it is not.

### **Put it in one place**

The trap is that this only fires at the database boundary, so it is easy to fix one call site and get bitten by another later. Strip the proxy once, in the layer that owns the database:

```ts
// db.ts
export async function saveRecipe(recipe: Recipe): Promise<number> {
  const clean = toPlainRecipe(recipe)
  return db.recipes.add({ title: clean.title, recipe: clean /* ... */ } as SavedRecipe)
}
```

Every screen just calls `saveRecipe(recipe)` and no proxy ever reaches storage.

Reading is not a problem in the other direction. Dexie and IndexedDB hand you back plain objects. If you drop those into `$state`, Svelte re-wraps them in a fresh proxy, and the cycle stays clean.

### **The short version**

*   Svelte 5 `$state` gives you a Proxy, not a plain object.
    
*   IndexedDB uses structured clone, which cannot copy a Proxy, so you get `DataCloneError`.
    
*   Strip the proxy at the database boundary in one shared function: a JSON round-trip for plain data (what I did), or `$state.snapshot()` if your state has dates, Maps, or Sets.
    

This was one of a few gotchas I hit building a local-first app with no server behind it. If you want the reasoning for why the whole thing runs on your device in the first place, I wrote that up here: [Free forever is an architecture not a pricing decision](https://blog.budhathokisagar.com.np/free-forever-is-an-architecture-not-a-pricing-decision).
