IndexedDb in effect is here, and it's huge π
I have been working on this for a while, and now you can access it in effect beta at @effect/platform-browser.
No talk, all code today. Because this finally unlock IndexedDb for real on the web π
All type safe, all of it
The native IndexedDb API is (1) awful and (2) unsafe.
I set out to reverse the situation: all safe and effect-like ergonomics πͺ
As such, everything is type-checked. Like, everything (even parameters combinations).
You start from your Schema, of course.
export class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.String,
title: Schema.NonEmptyString,
completed: Schema.Boolean,
createdAt: Schema.DateTimeUtcFromMillis,
}) {}The schema defines the shape of a table, using IndexedDbTable.make, with all IndexedDb config (table name, keyPath, indexes, autoIncrement):
export class TodosTable extends IndexedDbTable.make({
name: "todos",
schema: Todo, // π Schema
keyPath: "id",
indexes: {
createdAt: "createdAt",
},
}) {}These two are all you need to define all your domain (multiple tables, all schema checked).
Versioning and migration, local-first certified
Another goal of mine was to enable real local-first stuff.
As such, before composing the final database from the tables, you first define a "version" with IndexedDbVersion:
export class TodosTable extends IndexedDbTable.make({
name: "todos",
schema: Todo,
keyPath: "id",
indexes: {
createdAt: "createdAt",
},
}) {}
export class AppVersion extends IndexedDbVersion.make(TodosTable) {}As you change tables, you will define a new version, and then compose migrations in the next step π
Full database, with migrations
Final step is composing your database from all the versions.
With just one version, you just call IndexedDbDatabase.make, passing the version and the migration code:
Since the migration is an effect function, you can do whatever: dependencies, async calls, seed data, anything πͺ
export class TodoDatabase extends IndexedDbDatabase.make(
AppVersion,
Effect.fn(function* (api) {
yield* api.createObjectStore("todos");
yield* api.createIndex("todos", "createdAt");
// π Seed data
yield* api.from("todos").upsertAll([
{
id: "todo-1",
title: "Read the todos table from IndexedDB",
completed: true,
createdAt: DateTime.makeUnsafe("2026-04-05T08:30:00.000Z"),
},
{
id: "todo-2",
title: "Add a new todo through an atom mutation",
completed: false,
createdAt: DateTime.makeUnsafe("2026-04-05T08:45:00.000Z"),
},
{
id: "todo-3",
title: "Toggle a todo and watch the atom refresh",
completed: false,
createdAt: DateTime.makeUnsafe("2026-04-05T09:00:00.000Z"),
},
]);
}),
) {}When you create a new version, just chain add calls with the same API, with access to both the previous and the new api:
export class TodoV1 extends Schema.Class<TodoV1>("TodoV1")({
id: Schema.String,
title: Schema.NonEmptyString,
completed: Schema.Boolean,
createdAt: Schema.DateTimeUtcFromMillis,
}) {}
export class TodosTableV1 extends IndexedDbTable.make({
name: "todos",
schema: TodoV1,
keyPath: "id",
indexes: {
createdAt: "createdAt",
},
}) {}
export class AppVersionV1 extends IndexedDbVersion.make(TodosTableV1) {}
export class TodoV2 extends Schema.Class<TodoV2>("TodoV2")({
id: Schema.String,
title: Schema.NonEmptyString,
completed: Schema.Boolean,
createdAt: Schema.DateTimeUtcFromMillis,
description: Schema.String,
}) {}
export class TodosTableV2 extends IndexedDbTable.make({
name: "todos",
schema: TodoV2,
keyPath: "id",
indexes: {
createdAt: "createdAt",
},
}) {}
export class AppVersionV2 extends IndexedDbVersion.make(TodosTableV2) {}
export class TodoDatabase extends IndexedDbDatabase.make(
AppVersionV1,
Effect.fn(function* (api) {
yield* api.createObjectStore("todos");
yield* api.createIndex("todos", "createdAt");
yield* api.from("todos").upsertAll([
{
id: "todo-1",
title: "Read the todos table from IndexedDB",
completed: true,
createdAt: DateTime.makeUnsafe("2026-04-05T08:30:00.000Z"),
},
{
id: "todo-2",
title: "Add a new todo through an atom mutation",
completed: false,
createdAt: DateTime.makeUnsafe("2026-04-05T08:45:00.000Z"),
},
{
id: "todo-3",
title: "Toggle a todo and watch the atom refresh",
completed: false,
createdAt: DateTime.makeUnsafe("2026-04-05T09:00:00.000Z"),
},
]);
}),
).add(AppVersionV2, (from, to) =>
Effect.gen(function* () {
const todos = yield* from.from("todos").select();
yield* from.deleteObjectStore("todos");
yield* to.createObjectStore("todos");
yield* to.from("todos").insertAll(
todos.map((todo) => ({
...todo,
description: "This is a description",
})),
);
}),
) {}IndexedDb does not allow changing the structure of an object store, you generally need to get all the previous data, delete the object store, and create it with a new shape ππΌββοΈ
The full set of migrations will be executed automatically.
You need to keep all the versions in the code π
Distributed migrations means that a user can be in any of the versions at any time, so you must guarantee all migrations always available all the times
Reactive state management in IndexedDb
Setup done, IndexedDb ready.
Now you can use Effect Atom to bring it all in your React components. And it's Reactive β‘οΈ
First, setup an AtomRuntime:
const browserDatabaseLayer = TodoDatabase.layer(databaseName).pipe(
Layer.provide(IndexedDb.layerWindow),
);
export const AtomRuntime = Atom.runtime(browserDatabaseLayer);Now you can read (and write) with the power of Effect Atoms:
export const todosAtom = AtomRuntime.atom(
Stream.unwrap(
Effect.gen(function* () {
const api = yield* TodoDatabase.getQueryBuilder;
return api.from("todos").select().reactive(todoKeys);
}),
),
{ initialValue: [] },
);
export function TodoList() {
// π Reactive: Db changes, React re-renders
const todos = useAtomSuspense(todosAtom).value;
// ...
}Done. All type safe, migrations checked, local-first, and with the full composition power of effect π«‘
A new set of apps are made possible with this new IndexedDb module, basically unlocking IndexedDb for real (previously it was all too awful to consider using it).
And more is coming: Machine?
See you next π
