DexieJs: Reactive local state in React

You may not need any complex form or state management library, not even useState or useReducer. DexieJs live queries and useActionState are the solutions to your state management problems.

Author Sandro Maglione

Sandro Maglione

Contact me

Gone are the days of buggy useState, manual loading states, and complex setups for a simple async query.

useActionState changes how you execute actions in React. Combine it with dexie's live queries makes both your User Experience and Developer Experience a joy again.

And it's easier than you think. All you need is React 19 and dexie ๐Ÿ‘‡

pnpm add dexie dexie-react-hooks
dexie-js-reactive-local-state-in-react

Dexie database setup

Initializing dexie requires creating a new Dexie instance:

  • Define types for each table (IndexedDB)
  • Call new Dexie with the key of the database
  • Execute .stores to define keys and indexes

This configuration is the general for any dexie setup, you can read more in the official documentation.

import Dexie, { type EntityTable } from "dexie";

interface EventTable {
  eventId: number;
  name: string;
}

const db = new Dexie("_db") as Dexie & {
  event: EntityTable<EventTable, "eventId">;
};

db.version(1).stores({
  event: "++eventId",
});

export { db };
export type { EventTable };

Exporting db gives access to a valid Dexie instance everywhere in the app.

Reactive/Live queries with Dexie

The key feature of dexie are live queries with the useLiveQuery hook.

useLiveQuery observes changes and automatically re-renders the components that use the data.

We create a wrapper for useLiveQuery that gives access to db and report errors and loading states:

  • Accepts any query given an instance of db
  • Execute the query inside try/catch, returning the result
  • Returns an Error when something goes wrong inside catch

Based on the result the hook returns data, error, and loading state:

use-dexie-query.ts
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../dexie";

export const useDexieQuery = <I>(
  query: (dexie: typeof db) => Promise<I[]>,
  deps: unknown[] = []
) => {
  const results = useLiveQuery(async () => {
    try {
      const result = await query(db);
      return { data: result, error: null };
    } catch (error) {
      return {
        data: null,
        error:
          error instanceof Error ? error : new Error(JSON.stringify(error)),
      };
    }
  }, deps);

  if (results === undefined) {
    return { data: null, error: null, loading: true as const };
  } else if (results.error !== null) {
    return { data: null, error: results.error, loading: false as const };
  }

  return { data: results.data, error: null, loading: false as const };
};

Using useDexieQuery we can write hooks to read any combination of data from dexie:

use-events.ts
import { useDexieQuery } from "./use-dexie-query";

export const useEvents = () => {
  return useDexieQuery((_) => _.event.toArray());
};

No need of global stores or state management libraries. Every component using useEvents will always have access to the latest data.

useLiveQuery makes sure to re-render all the components that access the data โœจ

Database queries with useActionState

useLiveQuery solves the problem of reading up-to-date data in components. What about writing data?

React 19 introduces a new hook called useActionState.

useActionState allows to execute any sync/async action while giving access to the action status (pending) and state (e.g. errors).

useActionState allows avoiding custom implementations of async functions and loading states (no more const [loading, setLoading] = useState(false); ๐Ÿ’๐Ÿผโ€โ™‚๏ธ).

We create a custom hook around useActionState that executes any generic async action while reporting errors in the state:

  • Execute the action inside try/catch, return null if successful (no error)
  • Return an Error inside catch

useActionState accepts any generic Payload. It can be used with any action (not just <form> actions).

use-custom-action.ts
import { startTransition, useActionState } from "react";

export const useCustomAction = <Payload, Result>(
  execute: (params: Payload) => Promise<Result>
) => {
  const [state, action, pending] = useActionState<Error | null, Payload>(
    async (_, params) => {
      try {
        await execute(params);
        return null;
      } catch (error) {
        return error instanceof Error
          ? error
          : new Error(JSON.stringify(error));
      }
    },
    null
  );

  return [
    state,
    (payload: Payload) =>
      startTransition(() => {
        action(payload);
      }),
    pending,
  ] as const;
};

We also need to wrap the action returned by useActionState with startTransition (another new function from React 19).

startTransition marks a state update as a non-blocking transition.

startTransition is not necessary when the action is attached to a <form>.

Execute Dexie query with custom hook

The combination of useActionState and dexie makes executing database queries simple and concise:

  • useCustomAction with FormData as payload
  • The action extracts the form data and executes a query using db

We then have access to the pending state and possible error. We only need to pass the action to <form>:

The state in uncontrolled, no need of storing values inside useState, useReducer, or any other state or form management library.

import { db } from "../lib/dexie";
import { useCustomAction } from "../lib/hooks/use-custom-action";

export default function AddEventForm() {
  const [error, action, pending] = useCustomAction((params: FormData) => {
    const name = params.get("name") as string;
    return db.event.add({ name });
  });

  return (
    <form action={action}>
      <input type="text" name="name" />
      <button type="submit" disabled={pending}>
        Add
      </button>
      {error !== null ? <p>Error: {error.message}</p> : null}
    </form>
  );
}

Live state updates

Combining useDexieQuery and useCustomAction allows to preserve any state change between reloads.

Since useCustomAction accepts any payload, we can use it to update any value inside dexie.

In the example below, useEvents extracts the live data from dexie.

We then render an uncontrolled <input> for each event that allows changing the value by calling action from useCustomAction:

Notice how we don't need a <form>. useCustomAction can be used with any update function (onChange in the example).

import { db } from "../lib/dexie";
import { useCustomAction } from "../lib/hooks/use-custom-action";
import { useEvents } from "../lib/hooks/use-events";

export default function ListEvents() {
  const events = useEvents();
  const [error, action, pending] = useCustomAction(
    (params: { name: string; eventId: number }) =>
      db.event.update(params.eventId, { name: params.name })
  );

  if (events.loading) {
    return <p>Loading...</p>;
  } else if (events.error !== null) {
    return <p>Error: {events.error.message}</p>;
  }

  return (
    <div>
      {events.data.map((event) => (
        <div key={event.eventId}>
          <input
            type="text"
            defaultValue={event.name}
            onChange={(e) =>
              action({ name: e.target.value, eventId: event.eventId })
            }
          />
          {error !== null ? <p>Error: {error.message}</p> : null}
        </div>
      ))}
    </div>
  );
}

The state is stored inside IndexedDB and preserved between reloads. useLiveQuery updates the component after every call to action, making sure we always display the latest data (defaultValue).

You can view the full example repository on Github ๐Ÿ‘‡

dexie-js-reactive-local-state-in-react

Extra: debug state from IndexedDB

Since dexie is a wrapper around IndexedDB, you can view all the data inside the browser console.

Open Application > IndexedDB and search for the database with the key passed when creating the new Dexie instance:

import Dexie, { type EntityTable } from "dexie";

interface EventTable {
  eventId: number;
  name: string;
}

const db = new Dexie("_db") as Dexie & {
  event: EntityTable<EventTable, "eventId">;
};

// ...

You can view and debug any data stored using Dexie from IndexedDB in your browser console.
You can view and debug any data stored using Dexie from IndexedDB in your browser console.


You can view an example of a complete application that uses this dexie setup while also adding schema validations and typed errors in the repository linked below:

genshin-impact-primogens-planner