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.