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.

Sandro Maglione
Contact me610 words
・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-hooksDexie database setup
Initializing dexie requires creating a new Dexie instance:
- Define types for each table (IndexedDB)
- Call
new Dexiewith the key of the database - Execute
.storesto define keys and indexes
This configuration is the general for any
dexiesetup, 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.
useLiveQueryobserves 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
querygiven an instance ofdb - Execute the query inside try/catch, returning the result
- Returns an
Errorwhen something goes wrong insidecatch
Based on the result the hook returns data, error, and loading state:
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:
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.
useActionStateallows 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
nullif successful (no error) - Return an
Errorinsidecatch
useActionStateaccepts any genericPayload. It can be used with any action (not just<form>actions).
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).
startTransitionmarks a state update as a non-blocking transition.
startTransitionis 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:
useCustomActionwithFormDataas 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
useCustomActionaccepts any payload, we can use it to update any value insidedexie.
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>.useCustomActioncan be used with any update function (onChangein 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-reactExtra: 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 an example of a complete application that uses this dexie setup while also adding schema validations and typed errors in the repository linked below: