diff --git a/examples/nextjs/.eslintrc.json b/examples/nextjs/.eslintrc.json
new file mode 100644
index 000000000..bffb357a7
--- /dev/null
+++ b/examples/nextjs/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore
new file mode 100644
index 000000000..fd3dbb571
--- /dev/null
+++ b/examples/nextjs/.gitignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 7ce1c4b9f..5550ab654 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -1,10 +1,17 @@
-# [Next.js](https://nextjs.org/)
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
+First, run the development server:
+
```bash
-npm install
npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
diff --git a/examples/nextjs/app/globals.css b/examples/nextjs/app/globals.css
new file mode 100644
index 000000000..cdb281842
--- /dev/null
+++ b/examples/nextjs/app/globals.css
@@ -0,0 +1,8 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ max-width: 500px;
+ margin: auto;
+}
diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx
index 3a0bbff27..02b0b29e0 100644
--- a/examples/nextjs/app/layout.tsx
+++ b/examples/nextjs/app/layout.tsx
@@ -1,12 +1,13 @@
-export default function RootLayout({ children }): JSX.Element {
+import "./globals.css";
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
return (
-
- {children}
-
+ {children}
);
}
diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx
index a96d4cd52..46b60198f 100644
--- a/examples/nextjs/app/page.tsx
+++ b/examples/nextjs/app/page.tsx
@@ -1,8 +1,9 @@
"use client";
+import { TreeFormatter } from "@effect/schema";
import * as S from "@effect/schema/Schema";
-import * as TreeFormatter from "@effect/schema/TreeFormatter";
import * as Evolu from "@evolu/react";
+import { Effect, Exit } from "effect";
import {
ChangeEvent,
FC,
@@ -22,30 +23,25 @@ type TodoCategoryId = S.Schema.To;
const NonEmptyString50 = Evolu.String.pipe(
S.minLength(1),
S.maxLength(50),
- S.brand("NonEmptyString50"),
+ S.brand("NonEmptyString50")
);
type NonEmptyString50 = S.Schema.To;
const TodoTable = S.struct({
id: TodoId,
title: Evolu.NonEmptyString1000,
- // We can't use JavaScript boolean in SQLite.
- isCompleted: Evolu.SqliteBoolean,
+ isCompleted: S.nullable(Evolu.SqliteBoolean),
categoryId: S.nullable(TodoCategoryId),
});
type TodoTable = S.Schema.To;
-const SomeJson = S.struct({
- foo: S.string,
- // We can use any JSON type in SQLite JSON.
- bar: S.boolean,
-});
+const SomeJson = S.struct({ foo: S.string, bar: S.boolean });
type SomeJson = S.Schema.To;
const TodoCategoryTable = S.struct({
id: TodoCategoryId,
name: NonEmptyString50,
- json: SomeJson,
+ json: S.nullable(SomeJson),
});
type TodoCategoryTable = S.Schema.To;
@@ -54,70 +50,136 @@ const Database = S.struct({
todoCategory: TodoCategoryTable,
});
-const { useQuery, useMutation, useEvoluError, useOwner, useOwnerActions } =
- Evolu.create(Database);
+const evolu = Evolu.create(Database);
-const prompt = (
- schema: S.Schema,
- message: string,
- onSuccess: (value: To) => void,
-): void => {
- const value = window.prompt(message);
- if (value == null) return; // on cancel
- const a = S.parseEither(schema)(value);
- if (a._tag === "Left") {
- alert(TreeFormatter.formatErrors(a.left.errors));
- return;
- }
- onSuccess(a.right);
+// React Hooks
+const { useEvolu, useEvoluError, useQuery, useOwner } = evolu;
+
+const createFixtures = (): Promise =>
+ Promise.all(
+ evolu.loadQueries([
+ evolu.createQuery((db) => db.selectFrom("todo").selectAll()),
+ evolu.createQuery((db) => db.selectFrom("todoCategory").selectAll()),
+ ])
+ ).then(([todos, categories]) => {
+ if (todos.row || categories.row) return;
+
+ const { id: notUrgentCategoryId } = evolu.create("todoCategory", {
+ name: S.parseSync(NonEmptyString50)("Not Urgent"),
+ });
+
+ evolu.create("todo", {
+ title: S.parseSync(Evolu.NonEmptyString1000)("Try React Suspense"),
+ categoryId: notUrgentCategoryId,
+ });
+ });
+
+const isRestoringOwner = (isRestoringOwner?: boolean): boolean => {
+ if (!Evolu.canUseDom) return false;
+ const key = 'evolu:isRestoringOwner"';
+ if (isRestoringOwner != null)
+ localStorage.setItem(key, String(isRestoringOwner));
+ return localStorage.getItem(key) === "true";
};
-const Button: FC<{
- title: string;
- onClick: () => void;
-}> = ({ title, onClick }) => {
+// Ensure fixtures are not added to the restored owner.
+if (!isRestoringOwner()) createFixtures();
+
+const NextJsExample = memo(function NextJsExample() {
+ const [currentTab, setCurrentTab] = useState<"todos" | "categories">("todos");
+
+ const handleTabClick = (): void =>
+ // https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router
+ startTransition(() => {
+ setCurrentTab(currentTab === "todos" ? "categories" : "todos");
+ });
+
return (
-
- {title}
-
+ <>
+
+
+
+ {currentTab === "todos" ? "Todos" : "Categories"}
+
+ {currentTab === "todos" ? : }
+
+
+ To try React Suspense, click the `Switch Tab` button and rename a
+ category. Then click the `Switch Tab` again to see the updated
+ category name without any loading state. React Suspense is excellent
+ for UX.
+
+
+ The data created in this example are stored locally in SQLite. Evolu
+ encrypts the data for backup and sync with a Mnemonic, a unique safe
+ password created on your device.
+
+
+
+ >
+ );
+});
+
+const NotificationBar: FC = () => {
+ const evoluError = useEvoluError();
+ const [showError, setShowError] = useState(false);
+
+ useEffect(() => {
+ if (evoluError) setShowError(true);
+ }, [evoluError]);
+
+ return (
+
+ {evoluError && !showError && (
+ <>
+
{`Error: ${JSON.stringify(evoluError)}`}
+
setShowError(false)} />
+ >
+ )}
+
);
};
-interface TodoCategoryForSelect {
- readonly id: TodoCategoryTable["id"];
- readonly name: TodoCategoryTable["name"] | null;
-}
+const todosWithCategories = evolu.createQuery((db) =>
+ db
+ .selectFrom("todo")
+ .select(["id", "title", "isCompleted", "categoryId"])
+ .where("isDeleted", "is not", Evolu.cast(true))
+ // Filter null value and ensure non-null type. Evolu will provide a helper.
+ .where("title", "is not", null)
+ .$narrowType<{ title: Evolu.NonEmptyString1000 }>()
+ .orderBy("createdAt")
+ // https://kysely.dev/docs/recipes/relations
+ .select((eb) => [
+ Evolu.jsonArrayFrom(
+ eb
+ .selectFrom("todoCategory")
+ .select(["todoCategory.id", "todoCategory.name"])
+ .where("isDeleted", "is not", Evolu.cast(true))
+ .orderBy("createdAt")
+ ).as("categories"),
+ ])
+);
-const TodoCategorySelect: FC<{
- categories: ReadonlyArray;
- selected: TodoCategoryId | null;
- onSelect: (_value: TodoCategoryId | null) => void;
-}> = ({ categories, selected, onSelect }) => {
- const nothingSelected = "";
- const value =
- selected && categories.find((row) => row.id === selected)
- ? selected
- : nothingSelected;
+const Todos: FC = () => {
+ const { create } = useEvolu();
+ const { rows } = useQuery(todosWithCategories);
+
+ const handleAddTodoClick = (): void => {
+ prompt(Evolu.NonEmptyString1000, "What needs to be done?", (title) => {
+ create("todo", { title });
+ });
+ };
return (
- ): void => {
- onSelect(value === nothingSelected ? null : (value as TodoCategoryId));
- }}
- >
- -- no category --
- {categories.map(({ id, name }) => (
-
- {name}
-
- ))}
-
+ <>
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ >
);
};
@@ -128,7 +190,21 @@ const TodoItem = memo<{
}>(function TodoItem({
row: { id, title, isCompleted, categoryId, categories },
}) {
- const { update } = useMutation();
+ const { update } = useEvolu();
+
+ const handleToggleCompletedClick = (): void => {
+ update("todo", { id, isCompleted: !isCompleted });
+ };
+
+ const handleRenameClick = (): void => {
+ prompt(Evolu.NonEmptyString1000, "New Name", (title) => {
+ update("todo", { id, title });
+ });
+ };
+
+ const handleDeleteClick = (): void => {
+ update("todo", { id, isDeleted: true });
+ };
return (
@@ -140,24 +216,10 @@ const TodoItem = memo<{
{
- update("todo", { id, isCompleted: !isCompleted });
- }}
- />
- {
- prompt(Evolu.NonEmptyString1000, "New Name", (title) => {
- update("todo", { id, title });
- });
- }}
- />
- {
- update("todo", { id, isDeleted: true });
- }}
+ onClick={handleToggleCompletedClick}
/>
+
+
{
- const { create } = useMutation();
-
- const { rows } = useQuery(
- (db) =>
- db
- .selectFrom("todo")
- .select(["id", "title", "isCompleted", "categoryId"])
- .where("isDeleted", "is not", Evolu.cast(true))
- .orderBy("createdAt")
- // https://kysely.dev/docs/recipes/relations
- .select((eb) => [
- Evolu.jsonArrayFrom(
- eb
- .selectFrom("todoCategory")
- .select(["todoCategory.id", "todoCategory.name"])
- .where("isDeleted", "is not", Evolu.cast(true))
- .orderBy("createdAt"),
- ).as("categories"),
- ]),
- ({ title, isCompleted, ...rest }) =>
- title != null && isCompleted != null && { title, isCompleted, ...rest },
+interface TodoCategoryForSelect {
+ readonly id: TodoCategoryTable["id"];
+ readonly name: TodoCategoryTable["name"] | null;
+}
+
+const TodoCategorySelect: FC<{
+ categories: ReadonlyArray;
+ selected: TodoCategoryId | null;
+ onSelect: (_value: TodoCategoryId | null) => void;
+}> = ({ categories, selected, onSelect }) => {
+ const nothingSelected = "";
+ const value =
+ selected && categories.find((row) => row.id === selected)
+ ? selected
+ : nothingSelected;
+
+ return (
+ ): void => {
+ onSelect(value === nothingSelected ? null : (value as TodoCategoryId));
+ }}
+ >
+ -- no category --
+ {categories.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
);
+};
+
+const todoCategories = evolu.createQuery((db) =>
+ db
+ .selectFrom("todoCategory")
+ .select(["id", "name", "json"])
+ .where("isDeleted", "is not", Evolu.cast(true))
+ // Filter null value and ensure non-null type. Evolu will provide a helper.
+ .where("name", "is not", null)
+ .$narrowType<{ name: NonEmptyString50 }>()
+ .orderBy("createdAt")
+);
+
+const TodoCategories: FC = () => {
+ const { create } = useEvolu();
+ const { rows } = useQuery(todoCategories);
+
+ // Evolu automatically parses JSONs into typed objects.
+ // if (rows[0]) console.log(rows[1].json?.foo);
+
+ const handleAddCategoryClick = (): void => {
+ prompt(NonEmptyString50, "Category Name", (name) => {
+ create("todoCategory", {
+ name,
+ json: { foo: "a", bar: false },
+ });
+ });
+ };
return (
<>
- Todos
{rows.map((row) => (
-
+
))}
- {
- prompt(
- Evolu.NonEmptyString1000,
- "What needs to be done?",
- (title) => {
- create("todo", { title, isCompleted: false });
- },
- );
- }}
- />
+
>
);
};
-const TodoCategories: FC = () => {
- const { create, update } = useMutation();
- const { rows } = useQuery(
- (db) =>
- db
- .selectFrom("todoCategory")
- .select(["id", "name", "json"])
- .where("isDeleted", "is not", Evolu.cast(true))
- .orderBy("createdAt"),
- ({ name, ...rest }) => name && { name, ...rest },
- );
+const TodoCategoryItem = memo<{
+ row: Pick;
+}>(function TodoItem({ row: { id, name } }) {
+ const { update } = useEvolu();
- // Evolu automatically parses JSONs into typed objects.
- // if (rows[0]) console.log(rows[0].json?.foo);
+ const handleRenameClick = (): void => {
+ prompt(NonEmptyString50, "Category Name", (name) => {
+ update("todoCategory", { id, name });
+ });
+ };
+
+ const handleDeleteClick = (): void => {
+ update("todoCategory", { id, isDeleted: true });
+ };
return (
<>
- Categories
-
- {rows.map(({ id, name }) => (
-
- {name}
- {
- prompt(NonEmptyString50, "Category Name", (name) => {
- update("todoCategory", { id, name });
- });
- }}
- />
- {
- update("todoCategory", { id, isDeleted: true });
- }}
- />
-
- ))}
-
- {
- prompt(NonEmptyString50, "Category Name", (name) => {
- create("todoCategory", {
- name,
- json: { foo: "a", bar: false },
- });
- });
- }}
- />
+
+ {name}
+
+
+
>
);
-};
+});
const OwnerActions: FC = () => {
- const [isShown, setIsShown] = useState(false);
+ const evolu = useEvolu();
const owner = useOwner();
- const ownerActions = useOwnerActions();
+ const [showMnemonic, setShowMnemonic] = useState(false);
+
+ const handleRestoreOwnerClick = (): void => {
+ prompt(Evolu.NonEmptyString1000, "Your Mnemonic", (mnemonic) => {
+ Evolu.parseMnemonic(mnemonic)
+ .pipe(Effect.runPromiseExit)
+ .then(
+ Exit.match({
+ onFailure: (error) => {
+ alert(JSON.stringify(error, null, 2));
+ },
+ onSuccess: (mnemonic) => {
+ isRestoringOwner(true);
+ evolu.restoreOwner(mnemonic);
+ },
+ })
+ );
+ });
+ };
+
+ const handleResetOwnerClick = (): void => {
+ if (confirm("Are you sure? It will delete all your local data.")) {
+ isRestoringOwner(false);
+ evolu.resetOwner();
+ }
+ };
return (
@@ -283,28 +368,12 @@ const OwnerActions: FC = () => {
your data.
setIsShown((value) => !value)}
- />
- {
- prompt(Evolu.NonEmptyString1000, "Your Mnemonic", (mnemonic) => {
- ownerActions.restore(mnemonic).then((either) => {
- if (either._tag === "Left")
- alert(JSON.stringify(either.left, null, 2));
- });
- });
- }}
- />
- {
- if (confirm("Are you sure? It will delete all your local data."))
- ownerActions.reset();
- }}
+ title={`${showMnemonic ? "Hide" : "Show"} Mnemonic`}
+ onClick={(): void => setShowMnemonic(!showMnemonic)}
/>
- {isShown && owner != null && (
+
+
+ {showMnemonic && owner != null && (