@romyapps/usefirestore
Headless Firestore hooks powered by TanStack Query for React 18/19. Fully configurable with built-in caching, logging, and debugging.
📚 View Full Documentation | 📦 npm | 🐛 Issues
Features
- 🔥 Type-safe Firestore hooks - Full TypeScript support
- ⚡ TanStack Query integration - Automatic caching, refetching, and state management
- 🎯 Flexible caching strategies - cache-first, cache-only, server-only, or default
- 📊 Read statistics tracking - Monitor Firestore document reads
- 📝 Optional audit logging - Track all CREATE, UPDATE, and DELETE operations
- 🐛 Debug mode - Built-in logging for development
- 🎨 Headless - No UI, just hooks
- 🔧 Fully configurable - Custom error handlers, loggers, and storage
Installation
npm install @romyapps/usefirestore
# or
yarn add @romyapps/usefirestore
# or
pnpm add @romyapps/usefirestore
# or
bun add @romyapps/usefirestorePeer Dependencies
npm install firebase @tanstack/react-query reactQuick Start
1. Setup Firebase Context
Wrap your app with FirebaseConfigProvider:
import { FirebaseConfigProvider } from "@romyapps/usefirestore";
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const firebaseConfig = {
apiKey: "your-api-key",
authDomain: "your-auth-domain",
projectId: "your-project-id",
// ... other config
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<FirebaseConfigProvider
config={{
db,
logger: console, // optional
onError: (error, context) => {
console.error(`Error in ${context}:`, error);
},
keyPrefix: "myapp", // optional, default: 'rf'
}}
>
<YourApp />
</FirebaseConfigProvider>
</QueryClientProvider>
);
}2. Use Hooks in Components
import {
useCollection,
useDocument,
useAddDocument,
} from "@romyapps/usefirestore";
function UsersList() {
// Fetch a collection
const { data: users, isLoading } = useCollection("users");
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}API Reference
Hooks
useCollection<T>(collectionName, options?, behavior?)
Fetch a collection of documents with optional filtering, sorting, and pagination.
Parameters:
collectionName(string) - Firestore collection nameoptions(CollectionOptions) - Query options (where, orderBy, limit, cursor)behavior(QueryBehavior & UseQueryOptions) - Cache behavior and React Query options
Returns: TanStack Query result with data: Array<WithId<T>>
Example:
type User = {
name: string;
email: string;
age: number;
createdAt: Date;
};
function ActiveUsers() {
const {
data: users,
isLoading,
error,
} = useCollection<User>(
"users",
{
where: [
{ field: "status", operator: "==", value: "active" },
{ field: "age", operator: ">=", value: 18 },
],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 10,
},
{
preferCache: "cache-first", // Try cache first, fallback to server
debug: true, // Enable console logging
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{users?.map((user) => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}With Metadata (Pagination):
function PaginatedUsers() {
const [cursor, setCursor] = useState(null);
const { data } = useCollection<User>(
"users",
{
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 10,
cursor: cursor ? { type: "after", doc: cursor } : undefined,
},
{
withMeta: true, // Returns { items, firstDoc, lastDoc, size, empty }
}
);
const handleNextPage = () => {
if (data?.lastDoc) {
setCursor(data.lastDoc);
}
};
return (
<div>
{data?.items.map((user) => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={handleNextPage} disabled={data?.empty}>
Next Page
</button>
</div>
);
}useDocument<T>(collectionName, documentId?, behavior?)
Fetch a single document by ID.
Parameters:
collectionName(string) - Firestore collection namedocumentId(string | null) - Document ID (if null, query is disabled)behavior(QueryBehavior & UseQueryOptions) - Cache behavior and React Query options
Returns: TanStack Query result with data: WithId<T> | null
Example:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useDocument<User>("users", userId);
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Conditional fetching
function ConditionalProfile({ userId }: { userId: string | null }) {
const { data } = useDocument<User>("users", userId, {
preferCache: "server-only", // Always fetch from server
});
return data ? <div>{data.name}</div> : null;
}useAddDocument<T>(collectionName, options?)
Create a new document in a collection.
Parameters:
collectionName(string) - Firestore collection nameoptions- Configuration object:invalidate(boolean) - Invalidate queries after add (default: true)enableLogging(boolean) - Enable audit logging (default: false)debug(boolean) - Enable debug logging (default: false)beforeSave(function) - Transform data before saving
Returns: TanStack Query mutation result
Example:
function CreateUserForm() {
const addUser = useAddDocument<User>("users", {
invalidate: true,
enableLogging: true,
debug: true,
beforeSave: (data) => ({
...data,
name: data.name.trim(),
email: data.email.toLowerCase(),
}),
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
try {
const newUser = await addUser.mutateAsync({
name: formData.get("name") as string,
email: formData.get("email") as string,
age: Number(formData.get("age")),
});
console.log("Created user with ID:", newUser.id);
} catch (error) {
console.error("Failed to create user:", error);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="age" type="number" placeholder="Age" required />
<button type="submit" disabled={addUser.isPending}>
{addUser.isPending ? "Creating..." : "Create User"}
</button>
{addUser.isError && <p>Error: {addUser.error.message}</p>}
{addUser.isSuccess && <p>User created successfully!</p>}
</form>
);
}useUpdateDocument<T>(collectionName, options?)
Update an existing document.
Parameters:
collectionName(string) - Firestore collection nameoptions- Configuration object:enableLogging(boolean) - Enable audit logging with previous datadebug(boolean) - Enable debug loggingbeforeSave(function) - Transform data before updating
Returns: TanStack Query mutation result
Example:
function EditUserForm({ user }: { user: WithId<User> }) {
const updateUser = useUpdateDocument<WithId<User>>("users", {
enableLogging: true,
beforeSave: (data) => ({
...data,
name: data.name?.trim(),
}),
});
const handleUpdate = async () => {
await updateUser.mutateAsync({
id: user.id,
name: "New Name",
email: "newemail@example.com",
});
};
return (
<button onClick={handleUpdate} disabled={updateUser.isPending}>
{updateUser.isPending ? "Updating..." : "Update User"}
</button>
);
}useDeleteDocument(collectionName, options?)
Delete a document by ID.
Parameters:
collectionName(string) - Firestore collection nameoptions- Configuration object:enableLogging(boolean) - Log deletion with deleted datadebug(boolean) - Enable debug logging
Returns: TanStack Query mutation result
Example:
function DeleteUserButton({ userId }: { userId: string }) {
const deleteUser = useDeleteDocument("users", {
enableLogging: true,
debug: true,
});
const handleDelete = async () => {
if (confirm("Are you sure?")) {
await deleteUser.mutateAsync(userId);
}
};
return (
<button onClick={handleDelete} disabled={deleteUser.isPending}>
{deleteUser.isPending ? "Deleting..." : "Delete"}
</button>
);
}useCollectionFilters<T>(initial?)
Helper hook for managing dynamic collection filters.
Example:
function FilterableUsersList() {
const { filters, addFilter, removeFilter, clearFilters } =
useCollectionFilters<User>([
{ field: "status", operator: "==", value: "active" },
]);
const { data: users } = useCollection<User>("users", {
where: filters,
});
return (
<div>
<button
onClick={() =>
addFilter({
field: "age",
operator: ">=",
value: 18,
})
}
>
Add Age Filter
</button>
<button onClick={clearFilters}>Clear All</button>
<div>Active Filters: {filters.length}</div>
{users?.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}Statistics Functions
Track and analyze Firestore read operations.
import {
getCollectionStats,
clearCollectionStats,
clearAllCollectionStats,
} from "@romyapps/usefirestore";
// Get stats for a collection
const stats = getCollectionStats("users");
console.log(`Read count: ${stats.readCount}`);
console.log(`Last fetched: ${stats.lastFetched}`);
// Clear stats for a collection
clearCollectionStats("users");
// Clear all stats
clearAllCollectionStats();Advanced Configuration
Custom Logger
import pino from "pino";
const logger = pino();
<FirebaseConfigProvider
config={{
db,
logger: {
debug: logger.debug.bind(logger),
info: logger.info.bind(logger),
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
},
}}
>
{children}
</FirebaseConfigProvider>;Audit Logging
Enable automatic logging of all data mutations:
<FirebaseConfigProvider
config={{
db,
getUserId: () => auth.currentUser?.uid || null,
logging: {
enabled: true,
resolveLogCollection: (collectionName) => `${collectionName}_audit`,
includePreviousData: true, // Include old values in update logs
},
}}
>
{children}
</FirebaseConfigProvider>Log documents will include:
originalDocId- ID of the modified documentaction- CREATE, UPDATE, or DELETEmodifiedBy- User ID fromgetUserId()modifiedOn- Timestamp of the operationpreviousData- Previous document data (UPDATE only)deletedData- Deleted document data (DELETE only)
Custom Storage
Use a custom storage backend for statistics:
import AsyncStorage from "@react-native-async-storage/async-storage";
// Create a Storage-like adapter
const asyncStorageAdapter = {
length: 0,
key: (index: number) => null,
getItem: async (key: string) => await AsyncStorage.getItem(key),
setItem: async (key: string, value: string) =>
await AsyncStorage.setItem(key, value),
removeItem: async (key: string) => await AsyncStorage.removeItem(key),
};
<FirebaseConfigProvider
config={{
db,
storage: asyncStorageAdapter,
}}
>
{children}
</FirebaseConfigProvider>;Default Query Options
Set default TanStack Query options for all hooks:
<FirebaseConfigProvider
config={{
db,
defaultQueryOptions: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: true,
retry: 3,
},
}}
>
{children}
</FirebaseConfigProvider>Cache Strategies
Control how data is fetched with preferCache:
default- Let Firebase SDK decide (tries cache + server)cache-first- Try cache first, fallback to server if emptycache-only- Only use cache, fail if not availableserver-only- Always fetch from server, bypass cache
// Always get fresh data
const { data } = useCollection("users", undefined, {
preferCache: "server-only",
});
// Offline-first approach
const { data } = useCollection("users", undefined, {
preferCache: "cache-first",
});TypeScript Support
Full type safety with generics:
interface Post {
title: string;
content: string;
authorId: string;
publishedAt: Date;
tags: string[];
}
// Type-safe collection
const { data: posts } = useCollection<Post>("posts");
// data is Array<Post & { id: string }>
// Type-safe document
const { data: post } = useDocument<Post>("posts", postId);
// data is (Post & { id: string }) | null
// Type-safe mutations
const addPost = useAddDocument<Post>("posts");
const updatePost = useUpdateDocument<Post & { id: string }>("posts");Examples
Complete CRUD Example
import {
useCollection,
useDocument,
useAddDocument,
useUpdateDocument,
useDeleteDocument,
} from "@romyapps/usefirestore";
interface Todo {
title: string;
completed: boolean;
priority: "low" | "medium" | "high";
}
function TodoApp() {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Read operations
const { data: todos, isLoading } = useCollection<Todo>("todos", {
orderBy: [{ field: "priority", direction: "desc" }],
});
const { data: selectedTodo } = useDocument<Todo>("todos", selectedId);
// Write operations
const addTodo = useAddDocument<Todo>("todos");
const updateTodo = useUpdateDocument<Todo & { id: string }>("todos");
const deleteTodo = useDeleteDocument("todos");
const handleAdd = async (title: string) => {
await addTodo.mutateAsync({
title,
completed: false,
priority: "medium",
});
};
const handleToggle = async (todo: Todo & { id: string }) => {
await updateTodo.mutateAsync({
id: todo.id,
completed: !todo.completed,
});
};
const handleDelete = async (id: string) => {
await deleteTodo.mutateAsync(id);
};
if (isLoading) return <div>Loading todos...</div>;
return (
<div>
<button onClick={() => handleAdd("New Todo")}>Add Todo</button>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
/>
<span>{todo.title}</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
{selectedTodo && (
<div>
<h3>Selected: {selectedTodo.title}</h3>
<p>Priority: {selectedTodo.priority}</p>
</div>
)}
</div>
);
}Real-time Polling
function RealtimeUsers() {
const { data: users } = useCollection<User>("users", undefined, {
refetchInterval: 5000, // Poll every 5 seconds
preferCache: "server-only", // Always fetch fresh data
});
return (
<div>
<h2>Active Users: {users?.length}</h2>
{users?.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}Publishing
This package uses GitHub Actions to automatically publish to npm.
Setup
Create an NPM Access Token
- Go to npmjs.com and log in
- Click your profile → "Access Tokens" → "Generate New Token"
- Choose "Automation" type
- Copy the token
Add NPM Token to GitHub
- Go to your repository on GitHub
- Settings → Secrets and variables → Actions
- Click "New repository secret"
- Name:
NPM_TOKEN - Value: Paste your npm token
- Click "Add secret"
Publishing Methods
Method 1: Create a GitHub Release (Recommended)
- Go to your repository → Releases → "Create a new release"
- Choose or create a tag (e.g.,
v1.0.0) - Add release notes
- Click "Publish release"
- The workflow will automatically run and publish to npm
Method 2: Manual Trigger
- Go to Actions → "Publish to NPM" → "Run workflow"
- Optionally specify a version (e.g.,
1.0.1,patch,minor,major) - Click "Run workflow"
- The package will be published with the specified version
Development Workflow
# 1. Make your changes
git add .
git commit -m "feat: add new feature"
# 2. Update version in package.json
npm version patch # or minor, or major
# 3. Push changes and tags
git push && git push --tags
# 4. Create a release on GitHub (triggers publish)CI/CD
The repository includes two workflows:
- CI (
ci.yml) - Runs on every push/PR to validate the build - Publish (
publish.yml) - Publishes to npm on releases or manual trigger
License
MIT © Camilo Romero
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
Support
For issues and questions, please open an issue on GitHub.