diff --git a/.gitignore b/.gitignore
index b2ebc657..00f88779 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,9 @@ ui-debug.log
# npm pack artifacts
reactfire-*.tgz
reactfire.tgz
+# ...except the demo's pinned build, which is its reactfire dependency.
+# Regenerate with `npx tsc && npx vite build && npm pack` at the commit in the filename.
+!recipe-demo/reactfire-*.tgz
package/
publish.sh
unpack.sh
diff --git a/recipe-demo/package-lock.json b/recipe-demo/package-lock.json
index ddf07884..9b70d43d 100644
--- a/recipe-demo/package-lock.json
+++ b/recipe-demo/package-lock.json
@@ -12,7 +12,8 @@
"firebase": "^12.17.1",
"next": "^16.3.1",
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz"
},
"devDependencies": {
"@types/node": "^24.3.0",
@@ -1866,6 +1867,24 @@
"react": "^19.2.8"
}
},
+ "node_modules/reactfire": {
+ "version": "4.2.6",
+ "resolved": "file:reactfire-4.2.6-ac3ccf9.tgz",
+ "integrity": "sha512-bJkIcx9vIOI997X7MaJqSBkYcdiheIOrsez5392lpt2ukvwtYzvGhrwkCHAYJfQIPUqQPSSjOueYO8xpLnsSVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "rxfire": "^6.1.0",
+ "rxjs": "^6.6.3 || ^7.0.1",
+ "use-sync-external-store": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || next",
+ "react": ">=16 || experimental"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1875,6 +1894,25 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rxfire": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.2.0.tgz",
+ "integrity": "sha512-XSRdYjV6rZJUbUL2IpTqLtgnhNHDp9j2KSHZW04R+/ODKm8Ir2ag8I+kVZCq6j1NCclB4JMx60sgSDWcvDYR3g==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
+ "rxjs": "^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2048,6 +2086,15 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
diff --git a/recipe-demo/package.json b/recipe-demo/package.json
index af26e735..c0bfd66f 100644
--- a/recipe-demo/package.json
+++ b/recipe-demo/package.json
@@ -15,7 +15,8 @@
"firebase": "^12.17.1",
"next": "^16.3.1",
"react": "^19.2.8",
- "react-dom": "^19.2.8"
+ "react-dom": "^19.2.8",
+ "reactfire": "file:reactfire-4.2.6-ac3ccf9.tgz"
},
"devDependencies": {
"@types/node": "^24.3.0",
diff --git a/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz b/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz
new file mode 100644
index 00000000..69cce28e
Binary files /dev/null and b/recipe-demo/reactfire-4.2.6-ac3ccf9.tgz differ
diff --git a/recipe-demo/src/app/layout.tsx b/recipe-demo/src/app/layout.tsx
index de681f56..a1d1cde7 100644
--- a/recipe-demo/src/app/layout.tsx
+++ b/recipe-demo/src/app/layout.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import '@picocss/pico/css/pico.min.css';
-import { SessionProvider } from '@/lib/session-context';
+import { Providers } from '@/lib/providers';
import { SessionNav } from '@/components/SessionNav';
export const metadata: Metadata = {
@@ -13,7 +13,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
-
+
{children}
-
+
);
diff --git a/recipe-demo/src/components/RecipeBrowser.tsx b/recipe-demo/src/components/RecipeBrowser.tsx
index d35f2921..4f80b26d 100644
--- a/recipe-demo/src/components/RecipeBrowser.tsx
+++ b/recipe-demo/src/components/RecipeBrowser.tsx
@@ -1,13 +1,18 @@
'use client';
import { useState } from 'react';
+import { useFirestoreCollectionData } from 'reactfire';
import { RecipeList } from './RecipeList';
-import { useRecipes } from '@/lib/use-recipes';
+import { recipeQuery } from '@/lib/recipes';
import { CUISINES, type Cuisine, type Recipe } from '@/lib/types';
export function RecipeBrowser({ initialRecipes }: { initialRecipes: Recipe[] }) {
const [cuisine, setCuisine] = useState('all');
- const { recipes, status, error } = useRecipes(cuisine, initialRecipes);
+ const { data, status } = useFirestoreCollectionData(recipeQuery(cuisine), {
+ idField: 'id',
+ initialData: initialRecipes,
+ });
+ const recipes = data as Recipe[];
return (
<>
@@ -23,11 +28,7 @@ export function RecipeBrowser({ initialRecipes }: { initialRecipes: Recipe[] })
- {error ? (
- Could not load recipes: {error.message}
- ) : (
-
- )}
+
>
);
}
diff --git a/recipe-demo/src/components/RecipeCard.tsx b/recipe-demo/src/components/RecipeCard.tsx
index 0ca0931a..18912b74 100644
--- a/recipe-demo/src/components/RecipeCard.tsx
+++ b/recipe-demo/src/components/RecipeCard.tsx
@@ -1,12 +1,12 @@
'use client';
import { useState } from 'react';
+import { useUser } from 'reactfire';
import { toggleLike } from '@/lib/recipes';
-import { useSession } from '@/lib/session-context';
import type { Recipe } from '@/lib/types';
export function RecipeCard({ recipe }: { recipe: Recipe }) {
- const { user } = useSession();
+ const { data: user } = useUser();
const [pending, setPending] = useState(false);
const liked = user ? recipe.likedBy.includes(user.uid) : false;
diff --git a/recipe-demo/src/components/RequireAuth.tsx b/recipe-demo/src/components/RequireAuth.tsx
index 3170e13d..14f72e86 100644
--- a/recipe-demo/src/components/RequireAuth.tsx
+++ b/recipe-demo/src/components/RequireAuth.tsx
@@ -2,26 +2,27 @@
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, type ReactNode } from 'react';
-import { useSession } from '@/lib/session-context';
+import { useSigninCheck } from 'reactfire';
export function RequireAuth({ children }: { children: ReactNode }) {
- const { user, status } = useSession();
+ const { status, data: signinResult } = useSigninCheck();
+ const signedIn = signinResult?.signedIn ?? false;
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
- // Waiting for 'ready' is what stops a signed-in user being bounced to
- // /signin on every hard reload, before onAuthStateChanged has fired.
- if (status === 'ready' && !user) {
+ // Waiting for 'success' is what stops a signed-in user being bounced to
+ // /signin on every hard reload, before the auth state has resolved.
+ if (status === 'success' && !signedIn) {
router.replace(`/signin?next=${encodeURIComponent(pathname)}`);
}
- }, [status, user, router, pathname]);
+ }, [status, signedIn, router, pathname]);
if (status === 'loading') {
return Checking your session;
}
- if (!user) {
+ if (!signedIn) {
return null;
}
diff --git a/recipe-demo/src/components/SessionNav.tsx b/recipe-demo/src/components/SessionNav.tsx
index 0a3f31da..e2174ab3 100644
--- a/recipe-demo/src/components/SessionNav.tsx
+++ b/recipe-demo/src/components/SessionNav.tsx
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
+import { useUser } from 'reactfire';
import { logOut } from '@/lib/session';
-import { useSession } from '@/lib/session-context';
export function SessionNav() {
- const { user, status } = useSession();
+ const { data: user, status } = useUser();
if (status === 'loading') {
return (
diff --git a/recipe-demo/src/lib/providers.tsx b/recipe-demo/src/lib/providers.tsx
new file mode 100644
index 00000000..200c5f74
--- /dev/null
+++ b/recipe-demo/src/lib/providers.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { type ReactNode } from 'react';
+import { AuthProvider, FirebaseAppProvider, FirestoreProvider } from 'reactfire';
+import { app, firestore } from './firebase';
+import { auth } from './session';
+
+export function Providers({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/recipe-demo/src/lib/session-context.tsx b/recipe-demo/src/lib/session-context.tsx
deleted file mode 100644
index 408a48ce..00000000
--- a/recipe-demo/src/lib/session-context.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-'use client';
-
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
-import { subscribeToSession, type Session } from './session';
-
-const SessionContext = createContext({ user: null, status: 'loading' });
-
-export function SessionProvider({ children }: { children: ReactNode }) {
- const [session, setSession] = useState({ user: null, status: 'loading' });
-
- useEffect(() => subscribeToSession(setSession), []);
-
- return {children};
-}
-
-export function useSession() {
- return useContext(SessionContext);
-}
diff --git a/recipe-demo/src/lib/session.ts b/recipe-demo/src/lib/session.ts
index b98cbbb5..fb61ce29 100644
--- a/recipe-demo/src/lib/session.ts
+++ b/recipe-demo/src/lib/session.ts
@@ -1,6 +1,6 @@
'use client';
-import { getAuth, connectAuthEmulator, onAuthStateChanged, signInWithEmailAndPassword, signOut, type User } from 'firebase/auth';
+import { getAuth, connectAuthEmulator, signInWithEmailAndPassword, signOut } from 'firebase/auth';
import { app, useEmulators } from './firebase';
export const auth = getAuth(app);
@@ -12,15 +12,6 @@ if (useEmulators && !(EMULATOR_SENTINEL in globalThis)) {
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
}
-export interface Session {
- user: User | null;
- status: 'loading' | 'ready';
-}
-
-export function subscribeToSession(onChange: (session: Session) => void) {
- return onAuthStateChanged(auth, (user) => onChange({ user, status: 'ready' }));
-}
-
export function signIn(email: string, password: string) {
return signInWithEmailAndPassword(auth, email, password);
}
diff --git a/recipe-demo/src/lib/use-recipes.ts b/recipe-demo/src/lib/use-recipes.ts
deleted file mode 100644
index 0bea99ec..00000000
--- a/recipe-demo/src/lib/use-recipes.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-'use client';
-
-import { onSnapshot } from 'firebase/firestore';
-import { useEffect, useRef, useState } from 'react';
-import { recipeQuery, toRecipes } from './recipes';
-import type { Cuisine, Recipe } from './types';
-
-export type FeedStatus = 'loading' | 'ready' | 'error';
-
-/**
- * Takes over from the server-rendered list: seeds with what the server already
- * fetched, then switches to a live subscription without a loading flash.
- */
-export function useRecipes(cuisine: Cuisine | 'all', initialRecipes: Recipe[]) {
- // Which filter the current `recipes` describe. It starts as the cuisine the
- // server rendered and moves on with every snapshot, so switching away and
- // back still shows loading rather than the previous filter's list.
- const loadedCuisine = useRef(cuisine);
- const [recipes, setRecipes] = useState(initialRecipes);
- const [status, setStatus] = useState('ready');
- const [error, setError] = useState();
-
- useEffect(() => {
- if (cuisine !== loadedCuisine.current) {
- setStatus('loading');
- }
-
- return onSnapshot(
- recipeQuery(cuisine),
- (snapshot) => {
- setRecipes(toRecipes(snapshot));
- loadedCuisine.current = cuisine;
- setStatus('ready');
- setError(undefined);
- },
- (err) => {
- setError(err);
- setStatus('error');
- },
- );
- }, [cuisine]);
-
- return { recipes, status, error };
-}