diff --git a/.changeset/real-database-hooks.md b/.changeset/real-database-hooks.md
new file mode 100644
index 00000000..0aa62161
--- /dev/null
+++ b/.changeset/real-database-hooks.md
@@ -0,0 +1,10 @@
+---
+"@tanstack-query-firebase/react": minor
+---
+
+Add Firebase Realtime Database hooks via `@tanstack-query-firebase/react/database`.
+
+- Query hooks: `useGetQuery`, `useOnValueQuery`, and child event listeners (`useOnChildAddedQuery`, `useOnChildChangedQuery`, `useOnChildMovedQuery`, `useOnChildRemovedQuery`)
+- Mutation hooks: `useSetMutation`, `useUpdateMutation`, `useRemoveMutation`, `usePushMutation`, `useRunTransactionMutation`, and priority helpers
+- Connection hooks: `useGoOnlineMutation`, `useGoOfflineMutation`
+- OnDisconnect hooks: `useOnDisconnectSetMutation`, `useOnDisconnectUpdateMutation`, `useOnDisconnectRemoveMutation`, `useOnDisconnectCancelMutation`, and priority variants
diff --git a/.github/workflows/test-react-firebase-v12.yml b/.github/workflows/test-react-firebase-v12.yml
index 610e74dc..2c34ad7b 100644
--- a/.github/workflows/test-react-firebase-v12.yml
+++ b/.github/workflows/test-react-firebase-v12.yml
@@ -36,7 +36,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: "temurin"
- java-version: "17"
+ java-version: "21"
- name: Install Firebase CLI
uses: nick-invision/retry@v3
@@ -44,7 +44,7 @@ jobs:
timeout_minutes: 10
retry_wait_seconds: 60
max_attempts: 3
- command: npm i -g firebase-tools@latest
+ command: pnpm add -g firebase-tools@14
- name: Update to Firebase v12
run: |
diff --git a/docs.json b/docs.json
index 35baeb36..09eba7c4 100644
--- a/docs.json
+++ b/docs.json
@@ -164,6 +164,101 @@
}
]
},
+ {
+ "tab": "react",
+ "group": "Realtime Database",
+ "pages": [
+ {
+ "title": "Getting Started",
+ "href": "/react/database"
+ },
+ {
+ "group": "Hooks",
+ "pages": [
+ {
+ "href": "/react/database/hooks/useGetQuery",
+ "title": "useGetQuery"
+ },
+ {
+ "href": "/react/database/hooks/useGoOfflineMutation",
+ "title": "useGoOfflineMutation"
+ },
+ {
+ "href": "/react/database/hooks/useGoOnlineMutation",
+ "title": "useGoOnlineMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnChildAddedQuery",
+ "title": "useOnChildAddedQuery"
+ },
+ {
+ "href": "/react/database/hooks/useOnChildChangedQuery",
+ "title": "useOnChildChangedQuery"
+ },
+ {
+ "href": "/react/database/hooks/useOnChildMovedQuery",
+ "title": "useOnChildMovedQuery"
+ },
+ {
+ "href": "/react/database/hooks/useOnChildRemovedQuery",
+ "title": "useOnChildRemovedQuery"
+ },
+ {
+ "href": "/react/database/hooks/useOnDisconnectCancelMutation",
+ "title": "useOnDisconnectCancelMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnDisconnectRemoveMutation",
+ "title": "useOnDisconnectRemoveMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnDisconnectSetMutation",
+ "title": "useOnDisconnectSetMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnDisconnectSetWithPriorityMutation",
+ "title": "useOnDisconnectSetWithPriorityMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnDisconnectUpdateMutation",
+ "title": "useOnDisconnectUpdateMutation"
+ },
+ {
+ "href": "/react/database/hooks/useOnValueQuery",
+ "title": "useOnValueQuery"
+ },
+ {
+ "href": "/react/database/hooks/usePushMutation",
+ "title": "usePushMutation"
+ },
+ {
+ "href": "/react/database/hooks/useRemoveMutation",
+ "title": "useRemoveMutation"
+ },
+ {
+ "href": "/react/database/hooks/useRunTransactionMutation",
+ "title": "useRunTransactionMutation"
+ },
+ {
+ "href": "/react/database/hooks/useSetMutation",
+ "title": "useSetMutation"
+ },
+ {
+ "href": "/react/database/hooks/useSetPriorityMutation",
+ "title": "useSetPriorityMutation"
+ },
+ {
+ "href": "/react/database/hooks/useSetWithPriorityMutation",
+ "title": "useSetWithPriorityMutation"
+ },
+ {
+ "href": "/react/database/hooks/useUpdateMutation",
+ "title": "useUpdateMutation"
+ }
+ ]
+ }
+ ]
+ },
{
"tab": "react",
"group": "Firestore",
diff --git a/docs/react-query-firebase.mdx b/docs/react-query-firebase.mdx
index 7623a25f..a88d2fb0 100644
--- a/docs/react-query-firebase.mdx
+++ b/docs/react-query-firebase.mdx
@@ -39,16 +39,13 @@ although initially only React is supported.
Altough still in development, the API is designed to work with the newer object-based API of TanStack Query,
and also supports newer Firebase services such as Data Connect.
-### Realtime Subscription Issues
+### Realtime Subscription Hooks
-Firebase supports realtime event subscriptions for many of its services, such as Firestore, Realtime Database and
-Authentication.
+Firebase supports realtime event subscriptions for many of its services, such as Firestore, Realtime Database, and Authentication.
-The `react-query-firebase` package had a [limitation](https://github.com/invertase/tanstack-query-firebase/issues/25) whereby the hooks
-would not resubscribe whenever a component re-mounted.
+The `react-query-firebase` package had a [limitation](https://github.com/invertase/tanstack-query-firebase/issues/25) whereby the hooks would not resubscribe whenever a component re-mounted.
-The initial version of `tanstack-query-firebase` currently opts-out of any realtime subscription hooks. This issue will be re-addressed
-once the core API is stable supporting all Firebase services.
+TanStack Query Firebase now provides Realtime Database listener hooks (such as `useOnValueQuery` and the `useOnChild*` hooks) via `@tanstack-query-firebase/react/database`. Other services may add subscription hooks over time as the core API stabilizes.
## Migration Steps
diff --git a/docs/react/database/hooks/useGetQuery.mdx b/docs/react/database/hooks/useGetQuery.mdx
new file mode 100644
index 00000000..bda10517
--- /dev/null
+++ b/docs/react/database/hooks/useGetQuery.mdx
@@ -0,0 +1,18 @@
+---
+title: useGetQuery
+---
+
+Read data once from a Realtime Database location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useGetQuery } from "@tanstack-query-firebase/react/database";
+
+const userRef = ref(database, `users/${uid}`);
+const { data: snapshot, isLoading } = useGetQuery(userRef, {
+ queryKey: ["database", "users", uid],
+});
+const value = snapshot?.val();
+```
diff --git a/docs/react/database/hooks/useGoOfflineMutation.mdx b/docs/react/database/hooks/useGoOfflineMutation.mdx
new file mode 100644
index 00000000..aa483cb2
--- /dev/null
+++ b/docs/react/database/hooks/useGoOfflineMutation.mdx
@@ -0,0 +1,14 @@
+---
+title: useGoOfflineMutation
+---
+
+Disconnect the Realtime Database client from the server.
+
+## Usage
+
+```tsx
+import { useGoOfflineMutation } from "@tanstack-query-firebase/react/database";
+
+const { mutate } = useGoOfflineMutation(database);
+mutate();
+```
diff --git a/docs/react/database/hooks/useGoOnlineMutation.mdx b/docs/react/database/hooks/useGoOnlineMutation.mdx
new file mode 100644
index 00000000..e310da19
--- /dev/null
+++ b/docs/react/database/hooks/useGoOnlineMutation.mdx
@@ -0,0 +1,14 @@
+---
+title: useGoOnlineMutation
+---
+
+Reconnect the Realtime Database client to the server.
+
+## Usage
+
+```tsx
+import { useGoOnlineMutation } from "@tanstack-query-firebase/react/database";
+
+const { mutate } = useGoOnlineMutation(database);
+mutate();
+```
diff --git a/docs/react/database/hooks/useOnChildAddedQuery.mdx b/docs/react/database/hooks/useOnChildAddedQuery.mdx
new file mode 100644
index 00000000..1ba42acf
--- /dev/null
+++ b/docs/react/database/hooks/useOnChildAddedQuery.mdx
@@ -0,0 +1,17 @@
+---
+title: useOnChildAddedQuery
+---
+
+Subscribe to `child_added` events on a Realtime Database query.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnChildAddedQuery } from "@tanstack-query-firebase/react/database";
+
+const messagesRef = ref(database, "messages");
+const { data: snapshot } = useOnChildAddedQuery(messagesRef, {
+ queryKey: ["database", "childAdded", "messages"],
+});
+```
diff --git a/docs/react/database/hooks/useOnChildChangedQuery.mdx b/docs/react/database/hooks/useOnChildChangedQuery.mdx
new file mode 100644
index 00000000..b98d4acc
--- /dev/null
+++ b/docs/react/database/hooks/useOnChildChangedQuery.mdx
@@ -0,0 +1,17 @@
+---
+title: useOnChildChangedQuery
+---
+
+Subscribe to `child_changed` events on a Realtime Database query.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnChildChangedQuery } from "@tanstack-query-firebase/react/database";
+
+const messagesRef = ref(database, "messages");
+const { data: snapshot } = useOnChildChangedQuery(messagesRef, {
+ queryKey: ["database", "childChanged", "messages"],
+});
+```
diff --git a/docs/react/database/hooks/useOnChildMovedQuery.mdx b/docs/react/database/hooks/useOnChildMovedQuery.mdx
new file mode 100644
index 00000000..17d999c9
--- /dev/null
+++ b/docs/react/database/hooks/useOnChildMovedQuery.mdx
@@ -0,0 +1,17 @@
+---
+title: useOnChildMovedQuery
+---
+
+Subscribe to `child_moved` events. Requires a query ordered with `orderByPriority()`.
+
+## Usage
+
+```tsx
+import { orderByPriority, query, ref } from "firebase/database";
+import { useOnChildMovedQuery } from "@tanstack-query-firebase/react/database";
+
+const tasksRef = query(ref(database, "tasks"), orderByPriority());
+const { data: snapshot } = useOnChildMovedQuery(tasksRef, {
+ queryKey: ["database", "childMoved", "tasks"],
+});
+```
diff --git a/docs/react/database/hooks/useOnChildRemovedQuery.mdx b/docs/react/database/hooks/useOnChildRemovedQuery.mdx
new file mode 100644
index 00000000..8f30c4ce
--- /dev/null
+++ b/docs/react/database/hooks/useOnChildRemovedQuery.mdx
@@ -0,0 +1,17 @@
+---
+title: useOnChildRemovedQuery
+---
+
+Subscribe to `child_removed` events on a Realtime Database query.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnChildRemovedQuery } from "@tanstack-query-firebase/react/database";
+
+const messagesRef = ref(database, "messages");
+const { data: snapshot } = useOnChildRemovedQuery(messagesRef, {
+ queryKey: ["database", "childRemoved", "messages"],
+});
+```
diff --git a/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx b/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx
new file mode 100644
index 00000000..15335948
--- /dev/null
+++ b/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useOnDisconnectCancelMutation
+---
+
+Cancel all pending on-disconnect operations at a location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnDisconnectCancelMutation } from "@tanstack-query-firebase/react/database";
+
+const statusRef = ref(database, `users/${uid}/status`);
+const { mutate } = useOnDisconnectCancelMutation(statusRef);
+mutate();
+```
diff --git a/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx b/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx
new file mode 100644
index 00000000..0cb3f1ff
--- /dev/null
+++ b/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useOnDisconnectRemoveMutation
+---
+
+Queue removal of data when the client disconnects.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnDisconnectRemoveMutation } from "@tanstack-query-firebase/react/database";
+
+const sessionRef = ref(database, `sessions/${sessionId}`);
+const { mutate } = useOnDisconnectRemoveMutation(sessionRef);
+mutate();
+```
diff --git a/docs/react/database/hooks/useOnDisconnectSetMutation.mdx b/docs/react/database/hooks/useOnDisconnectSetMutation.mdx
new file mode 100644
index 00000000..f3273146
--- /dev/null
+++ b/docs/react/database/hooks/useOnDisconnectSetMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useOnDisconnectSetMutation
+---
+
+Queue a `set` operation to run when the client disconnects.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnDisconnectSetMutation } from "@tanstack-query-firebase/react/database";
+
+const statusRef = ref(database, `users/${uid}/status`);
+const { mutate } = useOnDisconnectSetMutation(statusRef);
+mutate("offline");
+```
diff --git a/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx b/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx
new file mode 100644
index 00000000..1a70cf0c
--- /dev/null
+++ b/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useOnDisconnectSetWithPriorityMutation
+---
+
+Queue a `setWithPriority` operation to run when the client disconnects.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnDisconnectSetWithPriorityMutation } from "@tanstack-query-firebase/react/database";
+
+const taskRef = ref(database, "tasks/task-1");
+const { mutate } = useOnDisconnectSetWithPriorityMutation(taskRef);
+mutate({ value: { title: "Cleanup" }, priority: 0 });
+```
diff --git a/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx b/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx
new file mode 100644
index 00000000..5a79c9b0
--- /dev/null
+++ b/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useOnDisconnectUpdateMutation
+---
+
+Queue a multi-path `update` to run when the client disconnects.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnDisconnectUpdateMutation } from "@tanstack-query-firebase/react/database";
+
+const userRef = ref(database, `users/${uid}`);
+const { mutate } = useOnDisconnectUpdateMutation(userRef);
+mutate({ status: "offline", lastSeen: Date.now() });
+```
diff --git a/docs/react/database/hooks/useOnValueQuery.mdx b/docs/react/database/hooks/useOnValueQuery.mdx
new file mode 100644
index 00000000..93f5b132
--- /dev/null
+++ b/docs/react/database/hooks/useOnValueQuery.mdx
@@ -0,0 +1,17 @@
+---
+title: useOnValueQuery
+---
+
+Subscribe to value events at a Realtime Database location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnValueQuery } from "@tanstack-query-firebase/react/database";
+
+const userRef = ref(database, `users/${uid}`);
+const { data: snapshot } = useOnValueQuery(userRef, {
+ queryKey: ["database", "onValue", "users", uid],
+});
+```
diff --git a/docs/react/database/hooks/usePushMutation.mdx b/docs/react/database/hooks/usePushMutation.mdx
new file mode 100644
index 00000000..751c5dd2
--- /dev/null
+++ b/docs/react/database/hooks/usePushMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: usePushMutation
+---
+
+Append a child with an auto-generated key to a list.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { usePushMutation } from "@tanstack-query-firebase/react/database";
+
+const messagesRef = ref(database, "messages");
+const { mutate, data: newRef } = usePushMutation(messagesRef);
+mutate({ text: "Hello" });
+```
diff --git a/docs/react/database/hooks/useRemoveMutation.mdx b/docs/react/database/hooks/useRemoveMutation.mdx
new file mode 100644
index 00000000..1c7c768b
--- /dev/null
+++ b/docs/react/database/hooks/useRemoveMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useRemoveMutation
+---
+
+Delete data at a Realtime Database location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useRemoveMutation } from "@tanstack-query-firebase/react/database";
+
+const userRef = ref(database, `users/${uid}`);
+const { mutate } = useRemoveMutation(userRef);
+mutate();
+```
diff --git a/docs/react/database/hooks/useRunTransactionMutation.mdx b/docs/react/database/hooks/useRunTransactionMutation.mdx
new file mode 100644
index 00000000..b721d1d6
--- /dev/null
+++ b/docs/react/database/hooks/useRunTransactionMutation.mdx
@@ -0,0 +1,19 @@
+---
+title: useRunTransactionMutation
+---
+
+Run an atomic read-modify-write transaction on a location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useRunTransactionMutation } from "@tanstack-query-firebase/react/database";
+
+const counterRef = ref(database, "counters/views");
+const { mutate } = useRunTransactionMutation(counterRef, (current) => {
+ const count = (current as number | null) ?? 0;
+ return count + 1;
+});
+mutate();
+```
diff --git a/docs/react/database/hooks/useSetMutation.mdx b/docs/react/database/hooks/useSetMutation.mdx
new file mode 100644
index 00000000..e4e6d7a7
--- /dev/null
+++ b/docs/react/database/hooks/useSetMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useSetMutation
+---
+
+Write data to a location, replacing any existing data.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useSetMutation } from "@tanstack-query-firebase/react/database";
+
+const userRef = ref(database, `users/${uid}`);
+const { mutate } = useSetMutation(userRef);
+mutate({ name: "Ada", score: 1 });
+```
diff --git a/docs/react/database/hooks/useSetPriorityMutation.mdx b/docs/react/database/hooks/useSetPriorityMutation.mdx
new file mode 100644
index 00000000..0aeb46d3
--- /dev/null
+++ b/docs/react/database/hooks/useSetPriorityMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useSetPriorityMutation
+---
+
+Set the priority of a Realtime Database location.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useSetPriorityMutation } from "@tanstack-query-firebase/react/database";
+
+const taskRef = ref(database, "tasks/task-1");
+const { mutate } = useSetPriorityMutation(taskRef);
+mutate(1);
+```
diff --git a/docs/react/database/hooks/useSetWithPriorityMutation.mdx b/docs/react/database/hooks/useSetWithPriorityMutation.mdx
new file mode 100644
index 00000000..b6fd7c66
--- /dev/null
+++ b/docs/react/database/hooks/useSetWithPriorityMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useSetWithPriorityMutation
+---
+
+Write data and priority to a location in one operation.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useSetWithPriorityMutation } from "@tanstack-query-firebase/react/database";
+
+const taskRef = ref(database, "tasks/task-1");
+const { mutate } = useSetWithPriorityMutation(taskRef);
+mutate({ value: { title: "Ship docs" }, priority: 1 });
+```
diff --git a/docs/react/database/hooks/useUpdateMutation.mdx b/docs/react/database/hooks/useUpdateMutation.mdx
new file mode 100644
index 00000000..b042235d
--- /dev/null
+++ b/docs/react/database/hooks/useUpdateMutation.mdx
@@ -0,0 +1,16 @@
+---
+title: useUpdateMutation
+---
+
+Perform a multi-path update without replacing data at the parent reference.
+
+## Usage
+
+```tsx
+import { ref } from "firebase/database";
+import { useUpdateMutation } from "@tanstack-query-firebase/react/database";
+
+const rootRef = ref(database);
+const { mutate } = useUpdateMutation(rootRef);
+mutate({ "users/ada/score": 10, "users/ada/updatedAt": Date.now() });
+```
diff --git a/docs/react/database/index.mdx b/docs/react/database/index.mdx
new file mode 100644
index 00000000..e015811f
--- /dev/null
+++ b/docs/react/database/index.mdx
@@ -0,0 +1,138 @@
+---
+title: Firebase Realtime Database
+---
+
+Firebase Realtime Database is a cloud-hosted NoSQL database that lets you store and sync data between your users in realtime.
+
+Before using these hooks, ensure your Firebase project has Realtime Database enabled and your app is initialized with a `Database` instance.
+
+## Setup
+
+```ts
+import { initializeApp } from "firebase/app";
+import { getDatabase } from "firebase/database";
+
+// Initialize your Firebase app
+initializeApp({ ... });
+
+// Get the Realtime Database instance
+const database = getDatabase(app);
+```
+
+## Importing
+
+Hooks are exported from the `database` namespace:
+
+```ts
+import { useOnValueQuery } from "@tanstack-query-firebase/react/database";
+```
+
+Hooks accept `DatabaseReference` / `Query` values from `ref()` / `query()` and the `Database` instance from `getDatabase()` (obtained at app init, not wrapped by these hooks).
+
+## One-time reads
+
+Use `useGetQuery` when you need a single read without a realtime listener:
+
+```tsx
+import { ref } from "firebase/database";
+import { useGetQuery } from "@tanstack-query-firebase/react/database";
+
+function UserProfile({ uid }: { uid: string }) {
+ const userRef = ref(database, `users/${uid}`);
+ const { data: snapshot, isLoading, isError, error } = useGetQuery(userRef, {
+ queryKey: ["database", "users", uid],
+ });
+
+ if (isLoading) return
Loading...
;
+ if (isError) return Error: {error.message}
;
+
+ return {snapshot?.val()?.name}
;
+}
+```
+
+## Realtime listeners
+
+Use listener hooks such as `useOnValueQuery` and the `useOnChild*` hooks to subscribe to Realtime Database events. The first snapshot resolves the query; later snapshots update the TanStack Query cache.
+
+```tsx
+import { ref } from "firebase/database";
+import { useOnValueQuery } from "@tanstack-query-firebase/react/database";
+
+function LiveScoreboard({ gameId }: { gameId: string }) {
+ const scoreRef = ref(database, `games/${gameId}/score`);
+ const { data: snapshot } = useOnValueQuery(scoreRef, {
+ queryKey: ["database", "onValue", "games", gameId, "score"],
+ });
+
+ return Score: {snapshot?.val() ?? 0}
;
+}
+```
+
+Pass Firebase [`ListenOptions`](https://firebase.google.com/docs/reference/js/database.listoptions) via the `database` option (for example `{ onlyOnce: true }`):
+
+```tsx
+useOnValueQuery(scoreRef, {
+ queryKey: ["database", "onValue", "games", gameId, "score"],
+ database: { onlyOnce: true },
+});
+```
+
+
+Components sharing the same `queryKey` share one TanStack Query cache entry. Use a stable, unique `queryKey` per path to avoid duplicate Firebase listeners.
+
+
+## Mutations
+
+Write hooks follow the same pattern as other TanStack Query Firebase modules. Pass a `DatabaseReference` when creating the hook, then call `mutate` with the payload:
+
+```tsx
+import { ref } from "firebase/database";
+import { useSetMutation } from "@tanstack-query-firebase/react/database";
+
+function UpdateUser({ uid }: { uid: string }) {
+ const userRef = ref(database, `users/${uid}`);
+ const { mutate, isPending } = useSetMutation(userRef);
+
+ return (
+
+ );
+}
+```
+
+## Connection and on-disconnect hooks
+
+- `useGoOnlineMutation` / `useGoOfflineMutation` accept a `Database` instance to control client connectivity.
+- `useOnDisconnect*` hooks queue writes that run when the client disconnects from the Realtime Database server.
+
+## Available hooks
+
+### Queries
+
+- [`useGetQuery`](/react/database/hooks/useGetQuery) — one-time read via `get`
+- [`useOnValueQuery`](/react/database/hooks/useOnValueQuery) — subscribe to value changes
+- [`useOnChildAddedQuery`](/react/database/hooks/useOnChildAddedQuery) — subscribe to `child_added`
+- [`useOnChildChangedQuery`](/react/database/hooks/useOnChildChangedQuery) — subscribe to `child_changed`
+- [`useOnChildMovedQuery`](/react/database/hooks/useOnChildMovedQuery) — subscribe to `child_moved`
+- [`useOnChildRemovedQuery`](/react/database/hooks/useOnChildRemovedQuery) — subscribe to `child_removed`
+
+### Mutations
+
+- [`useSetMutation`](/react/database/hooks/useSetMutation) — replace data at a location
+- [`useUpdateMutation`](/react/database/hooks/useUpdateMutation) — multi-path update
+- [`useRemoveMutation`](/react/database/hooks/useRemoveMutation) — delete data
+- [`usePushMutation`](/react/database/hooks/usePushMutation) — append with an auto-generated key
+- [`useRunTransactionMutation`](/react/database/hooks/useRunTransactionMutation) — atomic read-modify-write
+- [`useSetPriorityMutation`](/react/database/hooks/useSetPriorityMutation) — set node priority
+- [`useSetWithPriorityMutation`](/react/database/hooks/useSetWithPriorityMutation) — write value and priority together
+- [`useGoOnlineMutation`](/react/database/hooks/useGoOnlineMutation) — reconnect to the server
+- [`useGoOfflineMutation`](/react/database/hooks/useGoOfflineMutation) — disconnect from the server
+- [`useOnDisconnectSetMutation`](/react/database/hooks/useOnDisconnectSetMutation) — queue a disconnect `set`
+- [`useOnDisconnectUpdateMutation`](/react/database/hooks/useOnDisconnectUpdateMutation) — queue a disconnect `update`
+- [`useOnDisconnectRemoveMutation`](/react/database/hooks/useOnDisconnectRemoveMutation) — queue a disconnect `remove`
+- [`useOnDisconnectCancelMutation`](/react/database/hooks/useOnDisconnectCancelMutation) — cancel queued disconnect operations
+- [`useOnDisconnectSetWithPriorityMutation`](/react/database/hooks/useOnDisconnectSetWithPriorityMutation) — queue a disconnect `setWithPriority`
diff --git a/docs/react/index.mdx b/docs/react/index.mdx
index df819387..ce906320 100644
--- a/docs/react/index.mdx
+++ b/docs/react/index.mdx
@@ -89,4 +89,6 @@ function MyApplication() {
}
```
-TanStack Query Firebase provides hooks for all Firebase services, supporting both mutations and queries.
+TanStack Query Firebase provides hooks for Firebase services including Authentication, Firestore, Realtime Database, and Data Connect, supporting both mutations and queries.
+
+See the [Realtime Database documentation](/react/database) for listener and write hooks via `@tanstack-query-firebase/react/database`.
diff --git a/packages/react/package.json b/packages/react/package.json
index cbfbbccb..b97b925e 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -29,6 +29,10 @@
"./data-connect": {
"import": "./dist/data-connect/index.js",
"types": "./dist/data-connect/index.d.ts"
+ },
+ "./database": {
+ "import": "./dist/database/index.js",
+ "types": "./dist/database/index.d.ts"
}
},
"author": {
diff --git a/packages/react/src/database/createSubscriptionQueryFn.test.ts b/packages/react/src/database/createSubscriptionQueryFn.test.ts
new file mode 100644
index 00000000..3bf3ac59
--- /dev/null
+++ b/packages/react/src/database/createSubscriptionQueryFn.test.ts
@@ -0,0 +1,89 @@
+import { QueryClient } from "@tanstack/react-query";
+import { describe, expect, test, vi } from "vitest";
+import { createDatabaseSubscriptionQueryFn } from "./createSubscriptionQueryFn";
+
+describe("createDatabaseSubscriptionQueryFn", () => {
+ test("resolves on first value and updates cache on subsequent values", async () => {
+ const client = new QueryClient();
+ const queryKey = ["database", "subscription-test"];
+ let onNext: ((value: number) => void) | undefined;
+ let onError: ((error: Error) => void) | undefined;
+
+ const subscribe = vi.fn((handlers) => {
+ onNext = handlers.onNext;
+ onError = handlers.onError;
+ return vi.fn();
+ });
+
+ const queryFn = createDatabaseSubscriptionQueryFn(subscribe);
+ const controller = new AbortController();
+
+ const promise = queryFn({
+ client,
+ queryKey,
+ signal: controller.signal,
+ meta: undefined,
+ });
+
+ onNext?.(1);
+ await expect(promise).resolves.toBe(1);
+
+ onNext?.(2);
+ expect(client.getQueryData(queryKey)).toBe(2);
+
+ onError?.(new Error("listener failed"));
+ expect(onError).toBeDefined();
+ });
+
+ test("rejects on first listener error and invalidates on later errors", async () => {
+ const client = new QueryClient();
+ const queryKey = ["database", "subscription-error"];
+ let onNext: ((value: number) => void) | undefined;
+ let onError: ((error: Error) => void) | undefined;
+
+ const subscribe = vi.fn((handlers) => {
+ onNext = handlers.onNext;
+ onError = handlers.onError;
+ return vi.fn();
+ });
+
+ const queryFn = createDatabaseSubscriptionQueryFn(subscribe);
+ const invalidateSpy = vi.spyOn(client, "invalidateQueries");
+
+ const promise = queryFn({
+ client,
+ queryKey,
+ signal: new AbortController().signal,
+ meta: undefined,
+ });
+
+ onError?.(new Error("first failure"));
+
+ await expect(promise).rejects.toThrow("first failure");
+
+ onNext?.(1);
+ onError?.(new Error("second failure"));
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey });
+ });
+
+ test("unsubscribes when the fetch is aborted", async () => {
+ const client = new QueryClient();
+ const unsubscribe = vi.fn();
+ const subscribe = vi.fn(() => unsubscribe);
+
+ const queryFn = createDatabaseSubscriptionQueryFn(subscribe);
+ const controller = new AbortController();
+
+ queryFn({
+ client,
+ queryKey: ["database", "abort-test"],
+ signal: controller.signal,
+ meta: undefined,
+ });
+
+ controller.abort();
+
+ expect(unsubscribe).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react/src/database/createSubscriptionQueryFn.ts b/packages/react/src/database/createSubscriptionQueryFn.ts
new file mode 100644
index 00000000..507b22ce
--- /dev/null
+++ b/packages/react/src/database/createSubscriptionQueryFn.ts
@@ -0,0 +1,60 @@
+import type {
+ QueryFunction,
+ QueryFunctionContext,
+} from "@tanstack/react-query";
+import type { Unsubscribe } from "firebase/database";
+
+type ListenerHandlers = {
+ onNext: (data: TData) => void;
+ onError: (error: Error) => void;
+};
+
+/**
+ * Wraps a Firebase Realtime Database listener in a TanStack Query `queryFn`.
+ *
+ * - Resolves the initial fetch promise on the first snapshot.
+ * - Pushes subsequent snapshots into the cache via `setQueryData`.
+ * - Unsubscribes when the query fetch is aborted (unmount / cancel).
+ */
+export function createDatabaseSubscriptionQueryFn(
+ subscribe: (handlers: ListenerHandlers) => Unsubscribe,
+): QueryFunction {
+ return (context: QueryFunctionContext) => {
+ const { client, queryKey, signal } = context;
+ let firstRun = true;
+
+ return new Promise((resolve, reject) => {
+ const unsubscribe = subscribe({
+ onNext: (data) => {
+ if (firstRun) {
+ firstRun = false;
+ resolve(data);
+ return;
+ }
+ client.setQueryData(queryKey, data);
+ },
+ onError: (error) => {
+ if (firstRun) {
+ firstRun = false;
+ reject(error);
+ return;
+ }
+ client.invalidateQueries({ queryKey });
+ },
+ });
+
+ signal.addEventListener(
+ "abort",
+ () => {
+ unsubscribe();
+ },
+ { once: true },
+ );
+ });
+ };
+}
+
+export const databaseSubscriptionQueryDefaults = {
+ staleTime: Number.POSITIVE_INFINITY,
+ refetchOnMount: "always" as const,
+};
diff --git a/packages/react/src/database/index.ts b/packages/react/src/database/index.ts
index b655792b..57018df0 100644
--- a/packages/react/src/database/index.ts
+++ b/packages/react/src/database/index.ts
@@ -1,5 +1,28 @@
-// useRefQuery
-// useRefSetMutation
-// useRefSetPriorityMutation
-// useRefSetWithPriorityMutation
-// useRefUpdateMutation
+// Firebase Realtime Database (`firebase/database`)
+// Reference: https://firebase.google.com/docs/reference/js/database
+// OnDisconnect methods: https://firebase.google.com/docs/reference/js/database.ondisconnect
+//
+// Hooks accept `DatabaseReference` / `Query` from `ref()` / `query()` and a `Database`
+// instance from `getDatabase()` (obtained at app init, not wrapped).
+
+export type { DatabaseMutationOptions, DatabaseUseQueryOptions } from "./types";
+export { useGetQuery } from "./useGetQuery";
+export { useGoOfflineMutation } from "./useGoOfflineMutation";
+export { useGoOnlineMutation } from "./useGoOnlineMutation";
+export { useOnChildAddedQuery } from "./useOnChildAddedQuery";
+export { useOnChildChangedQuery } from "./useOnChildChangedQuery";
+export { useOnChildMovedQuery } from "./useOnChildMovedQuery";
+export { useOnChildRemovedQuery } from "./useOnChildRemovedQuery";
+export { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation";
+export { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation";
+export { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation";
+export { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation";
+export { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation";
+export { useOnValueQuery } from "./useOnValueQuery";
+export { usePushMutation } from "./usePushMutation";
+export { useRemoveMutation } from "./useRemoveMutation";
+export { useRunTransactionMutation } from "./useRunTransactionMutation";
+export { useSetMutation } from "./useSetMutation";
+export { useSetPriorityMutation } from "./useSetPriorityMutation";
+export { useSetWithPriorityMutation } from "./useSetWithPriorityMutation";
+export { useUpdateMutation } from "./useUpdateMutation";
diff --git a/packages/react/src/database/types.ts b/packages/react/src/database/types.ts
new file mode 100644
index 00000000..5071a5a6
--- /dev/null
+++ b/packages/react/src/database/types.ts
@@ -0,0 +1,32 @@
+import type {
+ UseMutationOptions,
+ UseQueryOptions,
+} from "@tanstack/react-query";
+import type { ListenOptions } from "firebase/database";
+
+/**
+ * TanStack Query options for Realtime Database read hooks.
+ *
+ * @remarks
+ * `queryKey` is required (same as Firestore hooks). For listener hooks (`useOnValueQuery`, etc.),
+ * pass Firebase {@link https://firebase.google.com/docs/reference/js/database.listoptions | ListenOptions}
+ * via `database` (e.g. `{ onlyOnce: true }`).
+ */
+export type DatabaseUseQueryOptions = Omit<
+ UseQueryOptions,
+ "queryFn"
+> & {
+ database?: ListenOptions;
+};
+
+/**
+ * TanStack Query options for Realtime Database mutation hooks.
+ *
+ * @remarks
+ * Omits `mutationFn` because the hook wires the Firebase SDK call.
+ */
+export type DatabaseMutationOptions<
+ TData = unknown,
+ TError = Error,
+ TVariables = void,
+> = Omit, "mutationFn">;
diff --git a/packages/react/src/database/useDatabaseSubscriptionQuery.ts b/packages/react/src/database/useDatabaseSubscriptionQuery.ts
new file mode 100644
index 00000000..5e46a409
--- /dev/null
+++ b/packages/react/src/database/useDatabaseSubscriptionQuery.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import type {
+ DataSnapshot,
+ ListenOptions,
+ Query,
+ Unsubscribe,
+} from "firebase/database";
+import {
+ createDatabaseSubscriptionQueryFn,
+ databaseSubscriptionQueryDefaults,
+} from "./createSubscriptionQueryFn";
+import type { DatabaseUseQueryOptions } from "./types";
+
+type SubscribeToQuery = (
+ query: Query,
+ onNext: (snapshot: DataSnapshot) => void,
+ onError: (error: Error) => void,
+ options?: ListenOptions,
+) => Unsubscribe;
+
+export function useDatabaseSubscriptionQuery(
+ query: Query,
+ subscribeToQuery: SubscribeToQuery,
+ options: DatabaseUseQueryOptions,
+) {
+ const { database: listenOptions, ...queryOptions } = options;
+
+ return useQuery({
+ ...databaseSubscriptionQueryDefaults,
+ ...queryOptions,
+ queryFn: createDatabaseSubscriptionQueryFn((handlers) =>
+ subscribeToQuery(query, handlers.onNext, handlers.onError, listenOptions),
+ ),
+ });
+}
diff --git a/packages/react/src/database/useGetQuery.test.tsx b/packages/react/src/database/useGetQuery.test.tsx
new file mode 100644
index 00000000..341b53de
--- /dev/null
+++ b/packages/react/src/database/useGetQuery.test.tsx
@@ -0,0 +1,49 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { get, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useGetQuery } from "./useGetQuery";
+
+describe("useGetQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("fetches data at a reference", async () => {
+ const dbRef = ref(database, "tests/useGetQuery");
+ await set(dbRef, { foo: "bar" });
+
+ const { result } = renderHook(
+ () =>
+ useGetQuery(dbRef, {
+ queryKey: ["database", "get", "tests/useGetQuery"],
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.val()).toEqual({ foo: "bar" });
+ });
+
+ test("returns an error when the read fails", async () => {
+ const dbRef = ref(database, "tests/useGetQuery-missing");
+
+ const { result } = renderHook(
+ () =>
+ useGetQuery(dbRef, {
+ queryKey: ["database", "get", "tests/useGetQuery-missing"],
+ retry: false,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.exists()).toBe(false);
+ expect(result.current.data?.exists()).toBe(false);
+ });
+});
diff --git a/packages/react/src/database/useGetQuery.ts b/packages/react/src/database/useGetQuery.ts
new file mode 100644
index 00000000..f0488130
--- /dev/null
+++ b/packages/react/src/database/useGetQuery.ts
@@ -0,0 +1,34 @@
+import { useQuery } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DataSnapshot, get, type Query } from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+
+/**
+ * Hook to read data once from a Realtime Database location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#get | get}.
+ *
+ * @param query - A `DatabaseReference` or `Query` (from `ref()` / `query()`).
+ * @param options - TanStack Query options; `queryKey` is required.
+ * @returns TanStack Query result with a `DataSnapshot`.
+ *
+ * @example
+ * ```tsx
+ * const userRef = ref(database, `users/${uid}`);
+ * const { data: snapshot, isLoading } = useGetQuery(userRef, {
+ * queryKey: ["database", "users", uid],
+ * });
+ * const value = snapshot?.val();
+ * ```
+ */
+export function useGetQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ const { database: _listenOptions, ...queryOptions } = options;
+
+ return useQuery({
+ ...queryOptions,
+ queryFn: () => get(query),
+ });
+}
diff --git a/packages/react/src/database/useGoOfflineMutation.test.tsx b/packages/react/src/database/useGoOfflineMutation.test.tsx
new file mode 100644
index 00000000..10d540be
--- /dev/null
+++ b/packages/react/src/database/useGoOfflineMutation.test.tsx
@@ -0,0 +1,24 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useGoOfflineMutation } from "./useGoOfflineMutation";
+
+describe("useGoOfflineMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("calls goOffline without throwing", async () => {
+ const { result } = renderHook(() => useGoOfflineMutation(database), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useGoOfflineMutation.ts b/packages/react/src/database/useGoOfflineMutation.ts
new file mode 100644
index 00000000..03c06f97
--- /dev/null
+++ b/packages/react/src/database/useGoOfflineMutation.ts
@@ -0,0 +1,25 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type Database, goOffline } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to disconnect the Realtime Database client from the server.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#gooffline | goOffline}.
+ *
+ * @param database - The `Database` instance from `getDatabase()`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate()` to go offline.
+ */
+export function useGoOfflineMutation(
+ database: Database,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: async () => {
+ goOffline(database);
+ },
+ });
+}
diff --git a/packages/react/src/database/useGoOnlineMutation.test.tsx b/packages/react/src/database/useGoOnlineMutation.test.tsx
new file mode 100644
index 00000000..da6c922f
--- /dev/null
+++ b/packages/react/src/database/useGoOnlineMutation.test.tsx
@@ -0,0 +1,24 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useGoOnlineMutation } from "./useGoOnlineMutation";
+
+describe("useGoOnlineMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("calls goOnline without throwing", async () => {
+ const { result } = renderHook(() => useGoOnlineMutation(database), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useGoOnlineMutation.ts b/packages/react/src/database/useGoOnlineMutation.ts
new file mode 100644
index 00000000..2fb57f8f
--- /dev/null
+++ b/packages/react/src/database/useGoOnlineMutation.ts
@@ -0,0 +1,25 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type Database, goOnline } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to reconnect the Realtime Database client to the server.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#goonline | goOnline}.
+ *
+ * @param database - The `Database` instance from `getDatabase()`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate()` to go online.
+ */
+export function useGoOnlineMutation(
+ database: Database,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: async () => {
+ goOnline(database);
+ },
+ });
+}
diff --git a/packages/react/src/database/useOnChildAddedQuery.test.tsx b/packages/react/src/database/useOnChildAddedQuery.test.tsx
new file mode 100644
index 00000000..3c204b22
--- /dev/null
+++ b/packages/react/src/database/useOnChildAddedQuery.test.tsx
@@ -0,0 +1,30 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { child, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnChildAddedQuery } from "./useOnChildAddedQuery";
+
+describe("useOnChildAddedQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("receives a child_added snapshot", async () => {
+ const listRef = ref(database, "tests/useOnChildAddedQuery");
+ await set(child(listRef, "a"), { name: "alpha" });
+
+ const { result } = renderHook(
+ () =>
+ useOnChildAddedQuery(listRef, {
+ queryKey: ["database", "onChildAdded", "tests/useOnChildAddedQuery"],
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.key).toBe("a");
+ expect(result.current.data?.val()).toEqual({ name: "alpha" });
+ });
+});
diff --git a/packages/react/src/database/useOnChildAddedQuery.ts b/packages/react/src/database/useOnChildAddedQuery.ts
new file mode 100644
index 00000000..a682bbb4
--- /dev/null
+++ b/packages/react/src/database/useOnChildAddedQuery.ts
@@ -0,0 +1,24 @@
+import type { FirebaseError } from "firebase/app";
+import { type DataSnapshot, onChildAdded, type Query } from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery";
+
+/**
+ * Hook to subscribe to `child_added` events on a Realtime Database query.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildadded | onChildAdded}.
+ *
+ * @param query - A `DatabaseReference` or `Query` (typically a list path).
+ * @param options - TanStack Query options; `queryKey` is required.
+ * @returns TanStack Query result with the latest child `DataSnapshot`.
+ *
+ * @remarks
+ * Fires when a child is added. Does not replay existing children unless you attach before
+ * writes occur. See {@link useOnValueQuery} to observe the full list.
+ */
+export function useOnChildAddedQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ return useDatabaseSubscriptionQuery(query, onChildAdded, options);
+}
diff --git a/packages/react/src/database/useOnChildChangedQuery.test.tsx b/packages/react/src/database/useOnChildChangedQuery.test.tsx
new file mode 100644
index 00000000..0f455de3
--- /dev/null
+++ b/packages/react/src/database/useOnChildChangedQuery.test.tsx
@@ -0,0 +1,36 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { child, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnChildChangedQuery } from "./useOnChildChangedQuery";
+
+describe("useOnChildChangedQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("receives child_changed updates", async () => {
+ const listRef = ref(database, "tests/useOnChildChangedQuery");
+ const childRef = child(listRef, "a");
+
+ const { result } = renderHook(
+ () =>
+ useOnChildChangedQuery(listRef, {
+ queryKey: [
+ "database",
+ "onChildChanged",
+ "tests/useOnChildChangedQuery",
+ ],
+ }),
+ { wrapper },
+ );
+
+ await set(childRef, { name: "alpha" });
+ await set(childRef, { name: "beta" });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.val()).toEqual({ name: "beta" });
+ });
+});
diff --git a/packages/react/src/database/useOnChildChangedQuery.ts b/packages/react/src/database/useOnChildChangedQuery.ts
new file mode 100644
index 00000000..f7b411a6
--- /dev/null
+++ b/packages/react/src/database/useOnChildChangedQuery.ts
@@ -0,0 +1,27 @@
+import type { FirebaseError } from "firebase/app";
+import {
+ type DataSnapshot,
+ onChildChanged,
+ type Query,
+} from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery";
+
+/**
+ * Hook to subscribe to `child_changed` events on a Realtime Database query.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildchanged | onChildChanged}.
+ *
+ * @param query - A `DatabaseReference` or `Query`.
+ * @param options - TanStack Query options; `queryKey` is required.
+ * @returns TanStack Query result with the latest changed child `DataSnapshot`.
+ *
+ * @remarks
+ * Fires when an existing child is modified, not when a child is first created.
+ */
+export function useOnChildChangedQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ return useDatabaseSubscriptionQuery(query, onChildChanged, options);
+}
diff --git a/packages/react/src/database/useOnChildMovedQuery.test.tsx b/packages/react/src/database/useOnChildMovedQuery.test.tsx
new file mode 100644
index 00000000..3cd08131
--- /dev/null
+++ b/packages/react/src/database/useOnChildMovedQuery.test.tsx
@@ -0,0 +1,44 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import {
+ child,
+ orderByPriority,
+ query,
+ ref,
+ set,
+ setPriority,
+} from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnChildMovedQuery } from "./useOnChildMovedQuery";
+
+describe("useOnChildMovedQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("receives child_moved snapshots", async () => {
+ const listRef = ref(database, "tests/useOnChildMovedQuery");
+ const listQuery = query(listRef, orderByPriority());
+ const childA = child(listRef, "a");
+ const childB = child(listRef, "b");
+
+ const { result } = renderHook(
+ () =>
+ useOnChildMovedQuery(listQuery, {
+ queryKey: ["database", "onChildMoved", "tests/useOnChildMovedQuery"],
+ }),
+ { wrapper },
+ );
+
+ await set(childA, { name: "alpha" });
+ await setPriority(childA, 1);
+ await set(childB, { name: "beta" });
+ await setPriority(childB, 2);
+ await setPriority(childA, 3);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.key).toBe("a");
+ });
+});
diff --git a/packages/react/src/database/useOnChildMovedQuery.ts b/packages/react/src/database/useOnChildMovedQuery.ts
new file mode 100644
index 00000000..f7196d06
--- /dev/null
+++ b/packages/react/src/database/useOnChildMovedQuery.ts
@@ -0,0 +1,31 @@
+import type { FirebaseError } from "firebase/app";
+import { type DataSnapshot, onChildMoved, type Query } from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery";
+
+/**
+ * Hook to subscribe to `child_moved` events on a Realtime Database query.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildmoved | onChildMoved}.
+ *
+ * @param query - A `Query` ordered with `orderByPriority()` (required for move events).
+ * @param options - TanStack Query options; `queryKey` is required.
+ * @returns TanStack Query result with the latest moved child `DataSnapshot`.
+ *
+ * @remarks
+ * `child_moved` is only raised for queries that use {@link https://firebase.google.com/docs/reference/js/database.md#orderbypriority | orderByPriority}.
+ *
+ * @example
+ * ```tsx
+ * const listQuery = query(ref(database, "items"), orderByPriority());
+ * const { data: snapshot } = useOnChildMovedQuery(listQuery, {
+ * queryKey: ["database", "items", "moved"],
+ * });
+ * ```
+ */
+export function useOnChildMovedQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ return useDatabaseSubscriptionQuery(query, onChildMoved, options);
+}
diff --git a/packages/react/src/database/useOnChildRemovedQuery.test.tsx b/packages/react/src/database/useOnChildRemovedQuery.test.tsx
new file mode 100644
index 00000000..6f3182f8
--- /dev/null
+++ b/packages/react/src/database/useOnChildRemovedQuery.test.tsx
@@ -0,0 +1,36 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { child, ref, remove, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnChildRemovedQuery } from "./useOnChildRemovedQuery";
+
+describe("useOnChildRemovedQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("receives child_removed snapshots", async () => {
+ const listRef = ref(database, "tests/useOnChildRemovedQuery");
+ const childRef = child(listRef, "a");
+
+ const { result } = renderHook(
+ () =>
+ useOnChildRemovedQuery(listRef, {
+ queryKey: [
+ "database",
+ "onChildRemoved",
+ "tests/useOnChildRemovedQuery",
+ ],
+ }),
+ { wrapper },
+ );
+
+ await set(childRef, { name: "alpha" });
+ await remove(childRef);
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.key).toBe("a");
+ });
+});
diff --git a/packages/react/src/database/useOnChildRemovedQuery.ts b/packages/react/src/database/useOnChildRemovedQuery.ts
new file mode 100644
index 00000000..669c34ff
--- /dev/null
+++ b/packages/react/src/database/useOnChildRemovedQuery.ts
@@ -0,0 +1,27 @@
+import type { FirebaseError } from "firebase/app";
+import {
+ type DataSnapshot,
+ onChildRemoved,
+ type Query,
+} from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery";
+
+/**
+ * Hook to subscribe to `child_removed` events on a Realtime Database query.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildremoved | onChildRemoved}.
+ *
+ * @param query - A `DatabaseReference` or `Query`.
+ * @param options - TanStack Query options; `queryKey` is required.
+ * @returns TanStack Query result with the removed child `DataSnapshot` (may have null value).
+ *
+ * @remarks
+ * Fires when a child is deleted from the query result.
+ */
+export function useOnChildRemovedQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ return useDatabaseSubscriptionQuery(query, onChildRemoved, options);
+}
diff --git a/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx b/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx
new file mode 100644
index 00000000..5ddfd1e6
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx
@@ -0,0 +1,29 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { onDisconnect, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation";
+
+describe("useOnDisconnectCancelMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("cancels a pending onDisconnect operation", async () => {
+ const dbRef = ref(database, "tests/useOnDisconnectCancelMutation");
+ await set(dbRef, { status: "online" });
+ await onDisconnect(dbRef).set({ status: "offline" });
+
+ const { result } = renderHook(() => useOnDisconnectCancelMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useOnDisconnectCancelMutation.ts b/packages/react/src/database/useOnDisconnectCancelMutation.ts
new file mode 100644
index 00000000..11c988d1
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectCancelMutation.ts
@@ -0,0 +1,23 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, onDisconnect } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to cancel all pending on-disconnect operations at a location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#cancel | OnDisconnect.cancel}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate()` to cancel queued disconnect writes.
+ */
+export function useOnDisconnectCancelMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: () => onDisconnect(ref).cancel(),
+ });
+}
diff --git a/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx b/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx
new file mode 100644
index 00000000..4e6a05a9
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx
@@ -0,0 +1,28 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation";
+
+describe("useOnDisconnectRemoveMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("registers an onDisconnect remove operation", async () => {
+ const dbRef = ref(database, "tests/useOnDisconnectRemoveMutation");
+ await set(dbRef, { status: "online" });
+
+ const { result } = renderHook(() => useOnDisconnectRemoveMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useOnDisconnectRemoveMutation.ts b/packages/react/src/database/useOnDisconnectRemoveMutation.ts
new file mode 100644
index 00000000..62103026
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectRemoveMutation.ts
@@ -0,0 +1,23 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, onDisconnect } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to queue removal of data at a location when the client disconnects.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#remove | OnDisconnect.remove}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate()` to register the operation.
+ */
+export function useOnDisconnectRemoveMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: () => onDisconnect(ref).remove(),
+ });
+}
diff --git a/packages/react/src/database/useOnDisconnectSetMutation.test.tsx b/packages/react/src/database/useOnDisconnectSetMutation.test.tsx
new file mode 100644
index 00000000..8cb332f8
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectSetMutation.test.tsx
@@ -0,0 +1,28 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation";
+
+describe("useOnDisconnectSetMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("registers an onDisconnect set operation", async () => {
+ const dbRef = ref(database, "tests/useOnDisconnectSetMutation");
+ await set(dbRef, { status: "online" });
+
+ const { result } = renderHook(() => useOnDisconnectSetMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate({ status: "offline" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useOnDisconnectSetMutation.ts b/packages/react/src/database/useOnDisconnectSetMutation.ts
new file mode 100644
index 00000000..c7093ab9
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectSetMutation.ts
@@ -0,0 +1,23 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, onDisconnect } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to queue a `set` at a location when the client disconnects.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#set | OnDisconnect.set}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate(value)` to register the disconnect write.
+ */
+export function useOnDisconnectSetMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: (value) => onDisconnect(ref).set(value),
+ });
+}
diff --git a/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx
new file mode 100644
index 00000000..d448dac3
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx
@@ -0,0 +1,29 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation";
+
+describe("useOnDisconnectSetWithPriorityMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("registers an onDisconnect setWithPriority operation", async () => {
+ const dbRef = ref(database, "tests/useOnDisconnectSetWithPriorityMutation");
+ await set(dbRef, { status: "online" });
+
+ const { result } = renderHook(
+ () => useOnDisconnectSetWithPriorityMutation(dbRef),
+ { wrapper },
+ );
+
+ await act(async () => {
+ result.current.mutate({ value: { status: "offline" }, priority: 1 });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts
new file mode 100644
index 00000000..ec20d73d
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts
@@ -0,0 +1,35 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, onDisconnect } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+type OnDisconnectSetWithPriorityVariables = {
+ value: unknown;
+ priority: string | number | null;
+};
+
+/**
+ * Hook to queue a `setWithPriority` at a location when the client disconnects.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#setwithpriority | OnDisconnect.setWithPriority}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate({ value, priority })`.
+ */
+export function useOnDisconnectSetWithPriorityMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ void,
+ FirebaseError,
+ OnDisconnectSetWithPriorityVariables
+ >,
+) {
+ return useMutation(
+ {
+ ...options,
+ mutationFn: ({ value, priority }) =>
+ onDisconnect(ref).setWithPriority(value, priority),
+ },
+ );
+}
diff --git a/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx b/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx
new file mode 100644
index 00000000..37736637
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx
@@ -0,0 +1,28 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation";
+
+describe("useOnDisconnectUpdateMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("registers an onDisconnect update operation", async () => {
+ const dbRef = ref(database, "tests/useOnDisconnectUpdateMutation");
+ await set(dbRef, { status: "online", retries: 0 });
+
+ const { result } = renderHook(() => useOnDisconnectUpdateMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate({ status: "offline" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+});
diff --git a/packages/react/src/database/useOnDisconnectUpdateMutation.ts b/packages/react/src/database/useOnDisconnectUpdateMutation.ts
new file mode 100644
index 00000000..969706c9
--- /dev/null
+++ b/packages/react/src/database/useOnDisconnectUpdateMutation.ts
@@ -0,0 +1,27 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, onDisconnect } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to queue a multi-path `update` at a location when the client disconnects.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#update | OnDisconnect.update}.
+ *
+ * @param ref - The parent `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate(values)` with a partial update object.
+ */
+export function useOnDisconnectUpdateMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ void,
+ FirebaseError,
+ Record
+ >,
+) {
+ return useMutation>({
+ ...options,
+ mutationFn: (values) => onDisconnect(ref).update(values),
+ });
+}
diff --git a/packages/react/src/database/useOnValueQuery.test.tsx b/packages/react/src/database/useOnValueQuery.test.tsx
new file mode 100644
index 00000000..5e15ebb1
--- /dev/null
+++ b/packages/react/src/database/useOnValueQuery.test.tsx
@@ -0,0 +1,65 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useOnValueQuery } from "./useOnValueQuery";
+
+describe("useOnValueQuery", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("subscribes and returns the initial snapshot", async () => {
+ const dbRef = ref(database, "tests/useOnValueQuery");
+ await set(dbRef, { count: 1 });
+
+ const { result } = renderHook(
+ () =>
+ useOnValueQuery(dbRef, {
+ queryKey: ["database", "onValue", "tests/useOnValueQuery"],
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.val()).toEqual({ count: 1 });
+ });
+
+ test("updates when the underlying value changes", async () => {
+ const dbRef = ref(database, "tests/useOnValueQuery-live");
+ await set(dbRef, { count: 1 });
+
+ const { result } = renderHook(
+ () =>
+ useOnValueQuery(dbRef, {
+ queryKey: ["database", "onValue", "tests/useOnValueQuery-live"],
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.data?.val()?.count).toBe(1));
+
+ await set(dbRef, { count: 2 });
+
+ await waitFor(() => expect(result.current.data?.val()?.count).toBe(2));
+ });
+
+ test("respects enabled: false", async () => {
+ const dbRef = ref(database, "tests/useOnValueQuery-disabled");
+ await set(dbRef, { count: 1 });
+
+ const { result } = renderHook(
+ () =>
+ useOnValueQuery(dbRef, {
+ queryKey: ["database", "onValue", "tests/useOnValueQuery-disabled"],
+ enabled: false,
+ }),
+ { wrapper },
+ );
+
+ expect(result.current.status).toBe("pending");
+ expect(result.current.data).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/database/useOnValueQuery.ts b/packages/react/src/database/useOnValueQuery.ts
new file mode 100644
index 00000000..ed179f0b
--- /dev/null
+++ b/packages/react/src/database/useOnValueQuery.ts
@@ -0,0 +1,34 @@
+import type { FirebaseError } from "firebase/app";
+import { type DataSnapshot, onValue, type Query } from "firebase/database";
+import type { DatabaseUseQueryOptions } from "./types";
+import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery";
+
+/**
+ * Hook to subscribe to value events at a Realtime Database location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onvalue | onValue}.
+ * The first snapshot resolves the query; later snapshots update the cache via `setQueryData`.
+ *
+ * @param query - A `DatabaseReference` or `Query`.
+ * @param options - TanStack Query options; `queryKey` is required. Use `database` for `ListenOptions`.
+ * @returns TanStack Query result with the latest `DataSnapshot`.
+ *
+ * @remarks
+ * Components sharing the same `queryKey` share one TanStack Query cache entry. Use a stable,
+ * unique `queryKey` per path to avoid duplicate Firebase listeners. For a one-time read, prefer
+ * {@link useGetQuery}.
+ *
+ * @example
+ * ```tsx
+ * const userRef = ref(database, `users/${uid}`);
+ * const { data: snapshot } = useOnValueQuery(userRef, {
+ * queryKey: ["database", "onValue", "users", uid],
+ * });
+ * ```
+ */
+export function useOnValueQuery(
+ query: Query,
+ options: DatabaseUseQueryOptions,
+) {
+ return useDatabaseSubscriptionQuery(query, onValue, options);
+}
diff --git a/packages/react/src/database/usePushMutation.test.tsx b/packages/react/src/database/usePushMutation.test.tsx
new file mode 100644
index 00000000..d304ea0e
--- /dev/null
+++ b/packages/react/src/database/usePushMutation.test.tsx
@@ -0,0 +1,31 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { usePushMutation } from "./usePushMutation";
+
+describe("usePushMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("pushes a new child with a value", async () => {
+ const listRef = ref(database, "tests/usePushMutation");
+
+ const { result } = renderHook(() => usePushMutation(listRef), { wrapper });
+
+ await act(async () => {
+ result.current.mutate({ name: "new-item" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const pushedRef = result.current.data;
+ expect(pushedRef?.key).toBeTruthy();
+
+ const snapshot = await get(pushedRef!);
+ expect(snapshot.val()).toEqual({ name: "new-item" });
+ });
+});
diff --git a/packages/react/src/database/usePushMutation.ts b/packages/react/src/database/usePushMutation.ts
new file mode 100644
index 00000000..8b8d5441
--- /dev/null
+++ b/packages/react/src/database/usePushMutation.ts
@@ -0,0 +1,35 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, push } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to append a child with an auto-generated key to a list.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#push | push}.
+ *
+ * @param ref - The list `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. `data` after success is the new child `DatabaseReference`.
+ * Call `mutate(value)` or `mutate(undefined)` to push without a value.
+ *
+ * @example
+ * ```tsx
+ * const messagesRef = ref(database, "rooms/room-1/messages");
+ * const { mutate, data: newRef } = usePushMutation(messagesRef);
+ * mutate({ text: "Hello", sentAt: Date.now() });
+ * ```
+ */
+export function usePushMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ DatabaseReference,
+ FirebaseError,
+ unknown | undefined
+ >,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: (value) => Promise.resolve(push(ref, value)),
+ });
+}
diff --git a/packages/react/src/database/useRemoveMutation.test.tsx b/packages/react/src/database/useRemoveMutation.test.tsx
new file mode 100644
index 00000000..bfc31635
--- /dev/null
+++ b/packages/react/src/database/useRemoveMutation.test.tsx
@@ -0,0 +1,29 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useRemoveMutation } from "./useRemoveMutation";
+
+describe("useRemoveMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("removes data at a reference", async () => {
+ const dbRef = ref(database, "tests/useRemoveMutation");
+ await set(dbRef, { hello: "world" });
+
+ const { result } = renderHook(() => useRemoveMutation(dbRef), { wrapper });
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.exists()).toBe(false);
+ });
+});
diff --git a/packages/react/src/database/useRemoveMutation.ts b/packages/react/src/database/useRemoveMutation.ts
new file mode 100644
index 00000000..96c0fc5b
--- /dev/null
+++ b/packages/react/src/database/useRemoveMutation.ts
@@ -0,0 +1,23 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, remove } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to delete data at a Realtime Database location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#remove | remove}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate()` with no arguments.
+ */
+export function useRemoveMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: () => remove(ref),
+ });
+}
diff --git a/packages/react/src/database/useRunTransactionMutation.test.tsx b/packages/react/src/database/useRunTransactionMutation.test.tsx
new file mode 100644
index 00000000..8cc32d5e
--- /dev/null
+++ b/packages/react/src/database/useRunTransactionMutation.test.tsx
@@ -0,0 +1,37 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useRunTransactionMutation } from "./useRunTransactionMutation";
+
+describe("useRunTransactionMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("runs a transaction and updates data", async () => {
+ const dbRef = ref(database, "tests/useRunTransactionMutation");
+ await set(dbRef, { count: 1 });
+
+ const { result } = renderHook(
+ () =>
+ useRunTransactionMutation(dbRef, (current) => {
+ const value = (current as { count?: number } | null)?.count ?? 0;
+ return { count: value + 1 };
+ }),
+ { wrapper },
+ );
+
+ await act(async () => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.val()).toEqual({ count: 2 });
+ expect(result.current.data?.committed).toBe(true);
+ });
+});
diff --git a/packages/react/src/database/useRunTransactionMutation.ts b/packages/react/src/database/useRunTransactionMutation.ts
new file mode 100644
index 00000000..5f071bda
--- /dev/null
+++ b/packages/react/src/database/useRunTransactionMutation.ts
@@ -0,0 +1,53 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import {
+ type DatabaseReference,
+ runTransaction,
+ type TransactionOptions,
+ type TransactionResult,
+} from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+type TransactionUpdate = (currentData: unknown) => unknown;
+
+type RunTransactionMutationOptions = DatabaseMutationOptions<
+ TransactionResult,
+ FirebaseError,
+ void
+> & {
+ database?: TransactionOptions;
+};
+
+/**
+ * Hook to run an atomic read-modify-write transaction on a Realtime Database location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#runtransaction | runTransaction}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param transactionUpdate - Function that receives current data and returns the new value (or `undefined` to abort).
+ * @param options - TanStack Mutation options. Pass `database` for {@link https://firebase.google.com/docs/reference/js/database.transactionoptions | TransactionOptions}.
+ * @returns TanStack Mutation result with `TransactionResult` on success. Call `mutate()` to run.
+ *
+ * @example
+ * ```tsx
+ * const counterRef = ref(database, "counters/views");
+ * const { mutate } = useRunTransactionMutation(counterRef, (current) => {
+ * const count = (current as number | null) ?? 0;
+ * return count + 1;
+ * });
+ * mutate();
+ * ```
+ */
+export function useRunTransactionMutation(
+ ref: DatabaseReference,
+ transactionUpdate: TransactionUpdate,
+ options?: RunTransactionMutationOptions,
+) {
+ const { database: transactionOptions, ...mutationOptions } = options ?? {};
+
+ return useMutation({
+ ...mutationOptions,
+ mutationFn: () =>
+ runTransaction(ref, transactionUpdate, transactionOptions),
+ });
+}
diff --git a/packages/react/src/database/useSetMutation.test.tsx b/packages/react/src/database/useSetMutation.test.tsx
new file mode 100644
index 00000000..4c1391f7
--- /dev/null
+++ b/packages/react/src/database/useSetMutation.test.tsx
@@ -0,0 +1,28 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useSetMutation } from "./useSetMutation";
+
+describe("useSetMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("writes a value to the database", async () => {
+ const dbRef = ref(database, "tests/useSetMutation");
+
+ const { result } = renderHook(() => useSetMutation(dbRef), { wrapper });
+
+ await act(async () => {
+ result.current.mutate({ hello: "world" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.val()).toEqual({ hello: "world" });
+ });
+});
diff --git a/packages/react/src/database/useSetMutation.ts b/packages/react/src/database/useSetMutation.ts
new file mode 100644
index 00000000..ef3f4a2e
--- /dev/null
+++ b/packages/react/src/database/useSetMutation.ts
@@ -0,0 +1,30 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, set } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to write data to a Realtime Database location, replacing any existing data.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#set | set}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate(value)` to write.
+ *
+ * @example
+ * ```tsx
+ * const userRef = ref(database, `users/${uid}`);
+ * const { mutate } = useSetMutation(userRef);
+ * mutate({ name: "Ada", score: 1 });
+ * ```
+ */
+export function useSetMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: (value) => set(ref, value),
+ });
+}
diff --git a/packages/react/src/database/useSetPriorityMutation.test.tsx b/packages/react/src/database/useSetPriorityMutation.test.tsx
new file mode 100644
index 00000000..6025594a
--- /dev/null
+++ b/packages/react/src/database/useSetPriorityMutation.test.tsx
@@ -0,0 +1,31 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useSetPriorityMutation } from "./useSetPriorityMutation";
+
+describe("useSetPriorityMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("sets priority on a reference", async () => {
+ const dbRef = ref(database, "tests/useSetPriorityMutation");
+ await set(dbRef, { name: "item" });
+
+ const { result } = renderHook(() => useSetPriorityMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate(10);
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.priority).toBe(10);
+ });
+});
diff --git a/packages/react/src/database/useSetPriorityMutation.ts b/packages/react/src/database/useSetPriorityMutation.ts
new file mode 100644
index 00000000..d1b51f15
--- /dev/null
+++ b/packages/react/src/database/useSetPriorityMutation.ts
@@ -0,0 +1,27 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, setPriority } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to set the priority of a Realtime Database location.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#setpriority | setPriority}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate(priority)` with a string, number, or `null`.
+ */
+export function useSetPriorityMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ void,
+ FirebaseError,
+ string | number | null
+ >,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: (priority) => setPriority(ref, priority),
+ });
+}
diff --git a/packages/react/src/database/useSetWithPriorityMutation.test.tsx b/packages/react/src/database/useSetWithPriorityMutation.test.tsx
new file mode 100644
index 00000000..313016a2
--- /dev/null
+++ b/packages/react/src/database/useSetWithPriorityMutation.test.tsx
@@ -0,0 +1,31 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useSetWithPriorityMutation } from "./useSetWithPriorityMutation";
+
+describe("useSetWithPriorityMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("writes a value with priority", async () => {
+ const dbRef = ref(database, "tests/useSetWithPriorityMutation");
+
+ const { result } = renderHook(() => useSetWithPriorityMutation(dbRef), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.mutate({ value: { name: "item" }, priority: 5 });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.val()).toEqual({ name: "item" });
+ expect(snapshot.priority).toBe(5);
+ });
+});
diff --git a/packages/react/src/database/useSetWithPriorityMutation.ts b/packages/react/src/database/useSetWithPriorityMutation.ts
new file mode 100644
index 00000000..7c8a5a7d
--- /dev/null
+++ b/packages/react/src/database/useSetWithPriorityMutation.ts
@@ -0,0 +1,38 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, setWithPriority } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+type SetWithPriorityVariables = {
+ value: unknown;
+ priority: string | number | null;
+};
+
+/**
+ * Hook to write data and priority to a Realtime Database location in one operation.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#setwithpriority | setWithPriority}.
+ *
+ * @param ref - The target `DatabaseReference`.
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate({ value, priority })`.
+ *
+ * @example
+ * ```tsx
+ * const { mutate } = useSetWithPriorityMutation(itemRef);
+ * mutate({ value: { name: "Ada" }, priority: 1 });
+ * ```
+ */
+export function useSetWithPriorityMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ void,
+ FirebaseError,
+ SetWithPriorityVariables
+ >,
+) {
+ return useMutation({
+ ...options,
+ mutationFn: ({ value, priority }) => setWithPriority(ref, value, priority),
+ });
+}
diff --git a/packages/react/src/database/useUpdateMutation.test.tsx b/packages/react/src/database/useUpdateMutation.test.tsx
new file mode 100644
index 00000000..9b411080
--- /dev/null
+++ b/packages/react/src/database/useUpdateMutation.test.tsx
@@ -0,0 +1,29 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { get, ref, set } from "firebase/database";
+import { beforeEach, describe, expect, test } from "vitest";
+import { database, wipeDatabase } from "~/testing-utils";
+import { queryClient, wrapper } from "../../utils";
+import { useUpdateMutation } from "./useUpdateMutation";
+
+describe("useUpdateMutation", () => {
+ beforeEach(async () => {
+ queryClient.clear();
+ await wipeDatabase();
+ });
+
+ test("updates child paths on a reference", async () => {
+ const dbRef = ref(database, "tests/useUpdateMutation");
+ await set(dbRef, { a: 1, b: 2 });
+
+ const { result } = renderHook(() => useUpdateMutation(dbRef), { wrapper });
+
+ await act(async () => {
+ result.current.mutate({ b: 3 });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const snapshot = await get(dbRef);
+ expect(snapshot.val()).toEqual({ a: 1, b: 3 });
+ });
+});
diff --git a/packages/react/src/database/useUpdateMutation.ts b/packages/react/src/database/useUpdateMutation.ts
new file mode 100644
index 00000000..0d4fbe78
--- /dev/null
+++ b/packages/react/src/database/useUpdateMutation.ts
@@ -0,0 +1,33 @@
+import { useMutation } from "@tanstack/react-query";
+import type { FirebaseError } from "firebase/app";
+import { type DatabaseReference, update } from "firebase/database";
+import type { DatabaseMutationOptions } from "./types";
+
+/**
+ * Hook to perform a multi-path update without replacing data at `ref`.
+ *
+ * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#update | update}.
+ *
+ * @param ref - The parent `DatabaseReference` (keys in `values` are relative child paths).
+ * @param options - TanStack Mutation options.
+ * @returns TanStack Mutation result. Call `mutate(values)` with a partial update object.
+ *
+ * @example
+ * ```tsx
+ * const { mutate } = useUpdateMutation(userRef);
+ * mutate({ "settings/theme": "dark", score: 10 });
+ * ```
+ */
+export function useUpdateMutation(
+ ref: DatabaseReference,
+ options?: DatabaseMutationOptions<
+ void,
+ FirebaseError,
+ Record
+ >,
+) {
+ return useMutation>({
+ ...options,
+ mutationFn: (values) => update(ref, values),
+ });
+}
diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts
index da3647bf..6a55ddd7 100644
--- a/packages/react/tsup.config.ts
+++ b/packages/react/tsup.config.ts
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { defineConfig } from "tsup";
-const supportedPackages = ["data-connect", "firestore", "auth"];
+const supportedPackages = ["data-connect", "firestore", "auth", "database"];
export default defineConfig({
entry: [`src/(${supportedPackages.join("|")})/index.ts`, "src/index.ts"],
format: ["esm"],
diff --git a/packages/react/vitest/utils.ts b/packages/react/vitest/utils.ts
index cdf1d667..42b8b22e 100644
--- a/packages/react/vitest/utils.ts
+++ b/packages/react/vitest/utils.ts
@@ -4,6 +4,11 @@ import {
connectDataConnectEmulator,
getDataConnect,
} from "firebase/data-connect";
+import {
+ connectDatabaseEmulator,
+ type Database,
+ getDatabase,
+} from "firebase/database";
import {
connectFirestoreEmulator,
type Firestore,
@@ -21,13 +26,16 @@ const firebaseTestingOptions = {
let firebaseApp: FirebaseApp | undefined;
let firestore: Firestore;
let auth: Auth;
+let database: Database;
if (!firebaseApp) {
firebaseApp = initializeApp(firebaseTestingOptions);
firestore = getFirestore(firebaseApp);
auth = getAuth(firebaseApp);
+ database = getDatabase(firebaseApp);
connectFirestoreEmulator(firestore, "localhost", 8080);
+ connectDatabaseEmulator(database, "localhost", 9000);
connectAuthEmulator(auth, "http://localhost:9099");
connectDataConnectEmulator(
getDataConnect(connectorConfig),
@@ -49,6 +57,19 @@ async function wipeFirestore() {
}
}
+async function wipeDatabase() {
+ const response = await fetch(
+ "http://localhost:9000/.json?ns=test-project-default-rtdb",
+ {
+ method: "DELETE",
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to wipe realtime database");
+ }
+}
+
async function wipeAuth() {
const response = await fetch(
"http://localhost:9099/emulator/v1/projects/test-project/accounts",
@@ -102,4 +123,6 @@ export {
wipeAuth,
firebaseApp,
expectFirebaseError,
+ database,
+ wipeDatabase,
};