DexieJS type-safe live query with effect

Languages

typescript5.7.3

Libraries

effect3.12.4
react19.0.0
dexie-logo4.0.10
GithubCode

DexieJS is a wrapper over IndexedDB that supports live/reactive queries.

Live queries observe the result and re-render your component with the data in real time.

This snippet implements a complete setup of dexie with effect to execute type-safe queries in React.

Dexie service

dexie.ts is an effect service used to initialize the database and implement the queries.

formAction is a generic function used for all queries:

  • Extract data from FormData
  • Validate data using Schema
  • Execute database query
// 👇 Action specific to decode `FormData`
const formAction =
  <const R extends string, I, T>(
    source: Schema.Schema<I, Record<R, string>>,
    exec: (values: Readonly<I>) => Promise<T>
  ) =>
  (formData: FormData) =>
    // 1️⃣ Decode `FormData` to `Record<string, string>`
    Schema.decodeUnknown(source)(formDataToRecord(formData)).pipe(
      Effect.mapError((error) => new WriteApiError({ cause: error })),
      Effect.flatMap((values) =>
        Effect.tryPromise({
          // 2️⃣ Execute the query
          try: () => exec(values),
          catch: (error) => new WriteApiError({ cause: error }),
        })
      )
    );

While formAction is designed for forms and FormData (e.g. <form action={action}>), changeAction works for any Payload (e.g. <input onChange={(e) => action(event.target.value)}>).

Live queries with effect

useDexieQuery wraps useLiveQuery to execute live queries and provide type-safe error and loading states:

  1. Execute query using dexie instance
  2. If result is undefined, then loading = true
  3. If the query fails or the data is invalid, then set error
  4. Otherwise return the valid type-safe data

useLiveActivities shows an example of how to use useDexieQuery. The return value contains data, error, and loading.

data is defined when loading === false and error !== undefined.

import { ActivityTable } from "./schema";
import { useDexieQuery } from "./use-dexie-query";

export const useLiveActivities = () => {
  // 👇 Get data with validation with `Schema`
  const { data, error, loading } = useDexieQuery(
    (_) => _.activity.toArray(),
    ActivityTable
  );

  return { data, error, loading }; // 👈 Return data with validation
};

Insert query

useActionEffect is a wrapper on useActionState to extract any Payload and execute actions in React.

Check out the useActionEffect snippet to learn more about how it works.

useInsertActivity shows an example of using useActionEffect and Dexie to execute a query.

By enabling accessors inside Effect.Service in Dexie the API is reduced to a single function call, with all types inferred:

import { Dexie } from "./dexie";
import { useActionEffect } from "./use-action-effect";

export const useInsertActivity = () => {
  // 👇 Final API is as easy as it gets (all types inferred!)
  const [{ error, data }, action, pending] = useActionEffect(
    Dexie.insertActivity // ✨ Magic with accessors
  );
};

Check out Make FormData and input names type-safe in React for more details on how it's possible to make FormData type-safe inside any form.


These are the steps to use this setup in your own application:

  1. Define table schemas inside schema.ts (use Schema from effect for validation)
  2. Add schemas to dexie instance inside dexie.ts (when defining db)
  3. Make sure to define indexes with .stores inside dexie.ts
  4. Implement and export queries using formAction/changeAction in the return value of the Dexie service (dexie.ts)
  5. Define live queries using useDexieQuery
  6. Define insert/update/delete requests using useActionEffect

You can view an example of a complete application that uses this dexie setup in the repository linked below:

genshin-impact-primogens-planner
import { Layer, ManagedRuntime } from "effect";
import { Dexie } from "./dexie";

// 👇 Use `Layer.mergeAll` to add layers to `CustomRuntime`
const MainLayer = Layer.mergeAll(Dexie.Default);

export const CustomRuntime = ManagedRuntime.make(MainLayer);