Let's learn how to use Evolu in a few steps.

Define Data

First, we define a database schema: tables, columns, and their types.


Evolu uses Schema (opens in a new tab) for data modeling. Instead of plain JavaScript types like String or Number, we recommend branded types (opens in a new tab). With branded types, we can define and enforce domain rules like NonEmptyString1000 or PositiveInt.

import * as S from "@effect/schema/Schema";
import {
} from "@evolu/react";
const TodoId = id("Todo");
type TodoId = typeof TodoId.Type;
const TodoTable = table({
  id: TodoId,
  title: NonEmptyString1000,
  isCompleted: SqliteBoolean,
type TodoTable = typeof TodoTable.Type;
const Database = database({
  todo: TodoTable,
type Database = typeof Database.Type;
const evolu = createEvolu(Database);

TypeScript compiler ensures that the title can't be an arbitrary string. It has to be parsed with the NonEmptyString1000 schema. Isn't that beautiful?

Parse Data

import * as S from "@effect/schema/Schema";
import { NonEmptyString1000 } from "@evolu/react";

Learn more about Schema (opens in a new tab). It's like Zod (opens in a new tab), but faster and with better design.

Mutate Data

While Evolu provides the full SQL for queries, the mutation API is tailored for local-first apps to ensure changes can be merged without conflicts and to mitigate the possibility that a developer accidentally makes unwanted changes—for example, an update of all rows in a table. That would generate a lot of CRDT messages that would have to be propagated to all other devices. It's not bad per se; it's just something that shouldn't be necessary with proper database schema design.

// Without React
const { id } = evolu.create("todo", { title, isCompleted: false });
evolu.update("todo", { id, isCompleted: true }, () => {
  // done
// With React
const { create, update } = useEvolu<Database>();

Note there is no error handling because there is no reason why a mutation should fail. Types ensure correctness, and the local SQLite database is always available. For rare cases where Evolu can fail, use global evolu.subscribeError or useEvoluError React Hook.

To delete a row, set isDeleted to true and filter "deleted" rows in queries.


CRDT without an authoritative server doesn't delete data; it just marks them as deleted. This is not a bug; it's a feature that allows data to be merged without conflicts (overridden data can be restored).

Query Data

Evolu uses type-safe TypeScript SQL query builder Kysely (opens in a new tab), so autocompletion works out-of-the-box. Let's start with a simple Query.

const allTodos = evolu.createQuery((db) => db.selectFrom("todo").selectAll());

Once we have a query, we can load or subscribe to it.

const promise = evolu.loadQuery(allTodos);
const unsubscribe = evolu.subscribeQuery(allTodos)(callback);

Evolu provides React Hooks useQuery and useQueries with the full React Suspense support. For example, we can load a query in the root and use the returned promise elsewhere.

Protect Data

Privacy is essential for Evolu, so all data are encrypted with an encryption key derived from a safely generated cryptographically strong password called mnemonic (opens in a new tab).

// evolu.subscribeOwner
const owner = evolu.getOwner();
if (owner) owner.mnemonic;

Delete Data

Leave no traces on a device.

if (confirm("Are you sure? It will delete all your local data."))

Restore Data

Synced Evolu data can be restored with mnemonic on any device.


And that's all we need to know to work with Evolu. The minimal API is the key to good DX.