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-hooks
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 ofdb
- Execute the query inside try/catch, returning the result
- Returns an
Error
when 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
.
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
insidecatch
useActionState
accepts 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).
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
withFormData
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 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>
.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-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: