Ejemplos Avanzados
Técnicas y patrones avanzados para usar ReactFire como un profesional
📄 Paginación con Firestore
Aprende a implementar paginación eficiente con grandes cantidades de datos:
Hook personalizado para paginación
import { useState, useEffect } from 'react';
import { useFirestore } from 'reactfire';
import {
collection,
query,
orderBy,
limit,
startAfter,
getDocs,
QueryDocumentSnapshot,
DocumentData
} from 'firebase/firestore';
interface UsePaginationOptions {
collectionName: string;
pageSize: number;
orderField: string;
orderDirection?: 'asc' | 'desc';
}
interface PaginationResult {
data: any[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
reset: () => void;
}
export function usePagination({
collectionName,
pageSize,
orderField,
orderDirection = 'desc'
}: UsePaginationOptions): PaginationResult {
const firestore = useFirestore();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [lastDoc, setLastDoc] = useState | null>(null);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
let q = query(
collection(firestore, collectionName),
orderBy(orderField, orderDirection),
limit(pageSize)
);
// Si tenemos un último documento, continuar desde ahí
if (lastDoc) {
q = query(
collection(firestore, collectionName),
orderBy(orderField, orderDirection),
startAfter(lastDoc),
limit(pageSize)
);
}
const snapshot = await getDocs(q);
const newData = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
if (newData.length < pageSize) {
setHasMore(false);
}
setData(prev => lastDoc ? [...prev, ...newData] : newData);
setLastDoc(snapshot.docs[snapshot.docs.length - 1] || null);
} catch (error) {
console.error('Error al cargar más datos:', error);
} finally {
setLoading(false);
}
};
const reset = () => {
setData([]);
setLastDoc(null);
setHasMore(true);
loadMore();
};
useEffect(() => {
loadMore();
}, []);
return { data, loading, hasMore, loadMore, reset };
}
Componente que usa paginación
import React from 'react';
import { usePagination } from './usePagination';
function ListaPublicacionesPaginada() {
const {
data: publicaciones,
loading,
hasMore,
loadMore
} = usePagination({
collectionName: 'publicaciones',
pageSize: 10,
orderField: 'fechaCreacion'
});
return (
<div>
<h3>📰 Publicaciones</h3>
<div className="publicaciones-grid">
{publicaciones.map(publicacion => (
<div key={publicacion.id} className="publicacion-card">
<h4>{publicacion.titulo}</h4>
<p>{publicacion.resumen}</p>
<small>Por {publicacion.autor}</small>
</div>
))}
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loading}
className="load-more-btn"
>
{loading ? 'Cargando...' : 'Cargar más'}
</button>
)}
{!hasMore && publicaciones.length > 0 && (
<p className="no-more-data">¡Has visto todas las publicaciones!</p>
)}
</div>
);
}
🔍 Búsqueda en Tiempo Real
Implementa búsqueda eficiente combinando diferentes estrategias:
Hook para búsqueda con debounce
import { useState, useEffect, useMemo } from 'react';
import { useFirestore, useFirestoreCollectionData } from 'reactfire';
import {
collection,
query,
where,
orderBy,
limit,
startAt,
endAt
} from 'firebase/firestore';
function useDebounce(value: string, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
interface UseSearchOptions {
collectionName: string;
searchField: string;
limit?: number;
}
export function useSearch(searchTerm: string, options: UseSearchOptions) {
const firestore = useFirestore();
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const searchQuery = useMemo(() => {
if (!debouncedSearchTerm.trim()) {
return query(
collection(firestore, options.collectionName),
orderBy('fechaCreacion', 'desc'),
limit(options.limit || 20)
);
}
// Búsqueda por prefijo (para nombres, títulos, etc.)
const searchEnd = debouncedSearchTerm + '\uf8ff';
return query(
collection(firestore, options.collectionName),
orderBy(options.searchField),
startAt(debouncedSearchTerm),
endAt(searchEnd),
limit(options.limit || 20)
);
}, [firestore, debouncedSearchTerm, options]);
const { data: results = [] } = useFirestoreCollectionData(searchQuery, {
idField: 'id'
});
return {
results,
isSearching: debouncedSearchTerm !== searchTerm,
hasSearchTerm: Boolean(debouncedSearchTerm.trim())
};
}
Componente de búsqueda
import React, { useState, Suspense } from 'react';
import { useSearch } from './useSearch';
function BuscadorProductos() {
const [termino, setTermino] = useState('');
return (
<div className="buscador">
<div className="search-input-container">
<input
type="text"
placeholder="Buscar productos..."
value={termino}
onChange={(e) => setTermino(e.target.value)}
className="search-input"
/>
<span className="search-icon">🔍</span>
</div>
<Suspense fallback={<div>Buscando...</div>}>
<ResultadosBusqueda termino={termino} />
</Suspense>
</div>
);
}
function ResultadosBusqueda({ termino }: { termino: string }) {
const { results, isSearching, hasSearchTerm } = useSearch(termino, {
collectionName: 'productos',
searchField: 'nombre',
limit: 15
});
if (isSearching) {
return <div className="searching">🔄 Buscando...</div>;
}
if (hasSearchTerm && results.length === 0) {
return (
<div className="no-results">
<p>😔 No se encontraron productos para "{termino}"</p>
<p>Intenta con otros términos de búsqueda</p>
</div>
);
}
return (
<div className="resultados">
{hasSearchTerm && (
<p>Se encontraron {results.length} productos para "{termino}"</p>
)}
<div className="productos-grid">
{results.map(producto => (
<div key={producto.id} className="producto-card">
<img src={producto.imagen} alt={producto.nombre} />
<h4>{producto.nombre}</h4>
<p className="precio">${producto.precio}</p>
<p className="descripcion">{producto.descripcion}</p>
</div>
))}
</div>
</div>
);
}
📱 Soporte Offline
Habilita funcionalidad offline para que tu app funcione sin conexión:
Configuración de persistencia offline
import React from 'react';
import { getFirestore, enableIndexedDbPersistence } from 'firebase/firestore';
import {
FirestoreProvider,
useFirebaseApp,
useInitFirestore
} from 'reactfire';
function FirestoreProviderWithOffline({ children }) {
const app = useFirebaseApp();
const { status, data: firestore } = useInitFirestore(async (firebaseApp) => {
const db = getFirestore(firebaseApp);
try {
// Habilitar persistencia offline
await enableIndexedDbPersistence(db, {
synchronizeTabs: true // Sincronizar entre pestañas
});
console.log('✅ Persistencia offline habilitada');
} catch (err) {
if (err.code === 'failed-precondition') {
console.warn('⚠️ Múltiples pestañas abiertas, persistencia deshabilitada');
} else if (err.code === 'unimplemented') {
console.warn('⚠️ El navegador no soporta persistencia offline');
} else {
console.error('❌ Error al habilitar persistencia:', err);
}
}
return db;
});
if (status === 'loading') {
return <div>Inicializando Firestore...</div>;
}
return (
<FirestoreProvider sdk={firestore}>
{children}
</FirestoreProvider>
);
}
Hook para detectar estado de conexión
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// Componente que muestra el estado de conexión
function EstadoConexion() {
const isOnline = useOnlineStatus();
return (
<div className={`connection-status ${isOnline ? 'online' : 'offline'}`}>
{isOnline ? (
<>
<span className="status-icon">🟢</span>
<span>Conectado</span>
</>
) : (
<>
<span className="status-icon">🔴</span>
<span>Sin conexión (modo offline)</span>
</>
)}
</div>
);
}
Componente con funcionalidad offline
import React, { useState } from 'react';
import { useFirestore, useFirestoreCollectionData } from 'reactfire';
import { collection, addDoc, query, orderBy } from 'firebase/firestore';
import { useOnlineStatus } from './useOnlineStatus';
function NotasOffline() {
const firestore = useFirestore();
const isOnline = useOnlineStatus();
const [nuevaNota, setNuevaNota] = useState('');
const notasQuery = query(
collection(firestore, 'notas'),
orderBy('fechaCreacion', 'desc')
);
const { data: notas = [] } = useFirestoreCollectionData(notasQuery, {
idField: 'id'
});
const agregarNota = async (e) => {
e.preventDefault();
if (!nuevaNota.trim()) return;
try {
await addDoc(collection(firestore, 'notas'), {
contenido: nuevaNota,
fechaCreacion: new Date(),
sincronizada: isOnline
});
setNuevaNota('');
if (isOnline) {
console.log('✅ Nota guardada y sincronizada');
} else {
console.log('💾 Nota guardada localmente, se sincronizará cuando vuelvas online');
}
} catch (error) {
console.error('Error al guardar nota:', error);
}
};
return (
<div className="notas-app">
<div className="app-header">
<h3>📝 Mis Notas</h3>
<EstadoConexion />
</div>
<form onSubmit={agregarNota} className="nueva-nota-form">
<textarea
value={nuevaNota}
onChange={(e) => setNuevaNota(e.target.value)}
placeholder="Escribe una nueva nota..."
rows={3}
/>
<button type="submit">
{isOnline ? 'Guardar y Sincronizar' : 'Guardar Localmente'}
</button>
</form>
<div className="notas-lista">
{notas.map(nota => (
<div key={nota.id} className="nota-item">
<p>{nota.contenido}</p>
<div className="nota-meta">
<small>
{nota.fechaCreacion?.toDate?.()?.toLocaleString() || 'Fecha no disponible'}
</small>
{!nota.sincronizada && (
<span className="no-sync">⏳ Pendiente de sincronización</span>
)}
</div>
</div>
))}
</div>
</div>
);
}
⚡ Optimización y Rendimiento
Técnicas para mejorar el rendimiento de tu aplicación ReactFire:
Hook optimizado con cache
import { useMemo } from 'react';
import { useFirestore, useFirestoreCollectionData } from 'reactfire';
import { collection, query, where, orderBy, limit } from 'firebase/firestore';
// Cache simple para consultas
const queryCache = new Map();
function useOptimizedQuery(
collectionName: string,
filters: Array<{ field: string; operator: any; value: any }> = [],
orderField?: string,
limitCount?: number
) {
const firestore = useFirestore();
const optimizedQuery = useMemo(() => {
// Crear una clave única para la consulta
const cacheKey = JSON.stringify({
collection: collectionName,
filters,
order: orderField,
limit: limitCount
});
// Verificar si ya tenemos esta consulta en cache
if (queryCache.has(cacheKey)) {
return queryCache.get(cacheKey);
}
// Construir la consulta
let q = collection(firestore, collectionName);
// Aplicar filtros
filters.forEach(filter => {
q = query(q, where(filter.field, filter.operator, filter.value));
});
// Aplicar ordenamiento
if (orderField) {
q = query(q, orderBy(orderField, 'desc'));
}
// Aplicar límite
if (limitCount) {
q = query(q, limit(limitCount));
}
// Guardar en cache
queryCache.set(cacheKey, q);
return q;
}, [firestore, collectionName, filters, orderField, limitCount]);
return useFirestoreCollectionData(optimizedQuery, {
idField: 'id'
});
}
// Ejemplo de uso
function ProductosPorCategoria({ categoria }: { categoria: string }) {
const { data: productos = [] } = useOptimizedQuery(
'productos',
[{ field: 'categoria', operator: '==', value: categoria }],
'fechaCreacion',
20
);
return (
<div>
<h3>Productos en {categoria}</h3>
{productos.map(producto => (
<div key={producto.id}>{producto.nombre}</div>
))}
</div>
);
}
Componente con lazy loading
import React, { lazy, Suspense } from 'react';
import { useIntersectionObserver } from './useIntersectionObserver';
// Lazy loading de componentes pesados
const GraficaDetallada = lazy(() => import('./GraficaDetallada'));
const TablaCompleta = lazy(() => import('./TablaCompleta'));
function useIntersectionObserver(
ref: React.RefObject<Element>,
options: IntersectionObserverInit = {}
) {
const [isIntersecting, setIsIntersecting] = React.useState(false);
React.useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options]);
return isIntersecting;
}
function DashboardOptimizado() {
const graficaRef = React.useRef<HTMLDivElement>(null);
const tablaRef = React.useRef<HTMLDivElement>(null);
const mostrarGrafica = useIntersectionObserver(graficaRef, {
threshold: 0.1
});
const mostrarTabla = useIntersectionObserver(tablaRef, {
threshold: 0.1
});
return (
<div className="dashboard">
<h2>📊 Dashboard</h2>
{/* Sección siempre visible */}
<div className="resumen-cards">
<TarjetaResumen titulo="Ventas" valor="$12,450" />
<TarjetaResumen titulo="Usuarios" valor="1,234" />
<TarjetaResumen titulo="Pedidos" valor="89" />
</div>
{/* Gráfica que se carga cuando es visible */}
<div ref={graficaRef} className="grafica-section">
{mostrarGrafica ? (
<Suspense fallback={<div>Cargando gráfica...</div>}>
<GraficaDetallada />
</Suspense>
) : (
<div className="placeholder">
<p>📈 Gráfica se cargará cuando sea visible</p>
</div>
)}
</div>
{/* Tabla que se carga cuando es visible */}
<div ref={tablaRef} className="tabla-section">
{mostrarTabla ? (
<Suspense fallback={<div>Cargando tabla...</div>}>
<TablaCompleta />
</Suspense>
) : (
<div className="placeholder">
<p>📋 Tabla se cargará cuando sea visible</p>
</div>
)}
</div>
</div>
);
}
function TarjetaResumen({ titulo, valor }: { titulo: string; valor: string }) {
return (
<div className="tarjeta-resumen">
<h4>{titulo}</h4>
<p className="valor">{valor}</p>
</div>
);
}
🎯 Consultas Específicas
Usa indices compuestos en Firestore para consultas complejas y siempre limita los resultados con limit()
.
💾 Cache Inteligente
Implementa cache para consultas frecuentes y usa useFirestoreDocDataOnce
para datos que no cambian.
📱 Lazy Loading
Carga componentes pesados solo cuando sean necesarios usando React.lazy() e Intersection Observer.
🔄 Debounce
Usa debounce en búsquedas y formularios para evitar consultas excesivas a Firebase.