Práctica FireChat
En esta práctica vamos a crear una aplicación de chat en tiempo real utilizando React y TypeScript. Utilizaremos Firebase como backend para manejar la autenticación y el almacenamiento de mensajes.
Enlaces útiles
- https://bluuweb-reactfire.netlify.app/
- https://reactrouter.com/start/declarative/installation
- firebase documentation
- https://github.com/FirebaseExtended/reactfire
- github codelab react
npm create vite@latest .
npm i react-router
npm install --save firebase reactfire
📚 Conceptos Clave
Este proyecto demuestra la implementación de:
- Rutas Protegidas: Control de acceso basado en autenticación
- Layouts Anidados: Organización de componentes usando React Router
- Firebase Authentication: Gestión de usuarios con ReactFire
- TypeScript: Tipado estático para mayor seguridad
- Arquitectura por Capas: Separación clara de responsabilidades
🏗️ Estructura del Proyecto (Mejorada)
src/
├── layouts/
│ ├── admin.layout.tsx # Layout con protección de autenticación
│ ├── auth.layout.tsx # Layout para usuarios no autenticados
│ ├── public.layout.tsx # Layout público sin protección
│ └── root.layout.tsx # Layout raíz
├── pages/
│ ├── admin/ # Páginas protegidas
│ │ ├── chat.page.tsx
│ │ ├── dashboard.page.tsx
│ │ └── profile.page.tsx
│ ├── auth/ # Páginas de autenticación
│ │ ├── login.page.tsx
│ │ └── register.page.tsx
│ └── public/ # Páginas públicas
│ ├── home.page.tsx
│ └── not-found.page.tsx
├── config/
│ ├── firebase.ts # Configuración de Firebase
│ └── firebase-services.tsx # Providers de Firebase
├── App.tsx # Configuración de rutas
└── main.tsx # Punto de entrada
🔧 Dependencias Principales
{
"dependencies": {
"react": "^19.1.0",
"react-router": "^7.7.0",
"firebase": "^9.23.0",
"reactfire": "^4.2.3"
}
}
🔥 Configuración de Firebase
src\config\firebase.ts
export const firebaseConfig = {
apiKey: "...",
authDomain: "...",
projectId: "...",
storageBucket: "...",
messagingSenderId: "...",
appId: "...",
};
src\config\firebase-services.tsx
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";
import {
AuthProvider,
FirestoreProvider,
StorageProvider,
useFirebaseApp,
} from "reactfire";
const FirebaseServices = ({ children }: Props) => {
const app = useFirebaseApp();
const auth = getAuth(app);
const firestore = getFirestore(app);
const storage = getStorage(app);
return (
<AuthProvider sdk={auth}>
<FirestoreProvider sdk={firestore}>
<StorageProvider sdk={storage}>{children}</StorageProvider>
</FirestoreProvider>
</AuthProvider>
);
};
main.tsx
- Configuración de Providers
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { BrowserRouter } from "react-router";
import { FirebaseAppProvider } from "reactfire";
import FirebaseServices from "./config/firebase-services.tsx";
import { firebaseConfig } from "./config/firebase.ts";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<FirebaseAppProvider firebaseConfig={firebaseConfig}>
<FirebaseServices>
<BrowserRouter>
<App />
</BrowserRouter>
</FirebaseServices>
</FirebaseAppProvider>
</StrictMode>
);
🔍 Orden de Providers:
FirebaseAppProvider
- Inicializa FirebaseFirebaseServices
- Configura servicios específicosBrowserRouter
- Habilita el enrutamientoApp
- Componente principal
🗺️ Configuración de Rutas
App.tsx
import { Route, Routes } from "react-router";
// ... imports de layouts y páginas
const App = () => {
return (
<Routes>
<Route element={<RootLayout />}>
{/* Rutas Públicas */}
<Route element={<PublicLayout />}>
<Route
index
element={<HomePage />}
/>
<Route
path="*"
element={<NotFoundPage />}
/>
</Route>
{/* Rutas Protegidas */}
<Route
path="admin"
element={<AdminLayout />}
>
<Route
index
element={<DashboardPage />}
/>
<Route
path="profile"
element={<ProfilePage />}
/>
<Route
path="chat"
element={<ChatPage />}
/>
</Route>
{/* Rutas de Autenticación */}
<Route
path="auth"
element={<AuthLayout />}
>
<Route
path="login"
element={<LoginPage />}
/>
<Route
path="register"
element={<RegisterPage />}
/>
</Route>
</Route>
</Routes>
);
};
🔍 Estructura de rutas:
/
- Página principal (pública)/admin/*
- Rutas protegidas (requieren autenticación)/auth/*
- Rutas de autenticación (solo para no autenticados)
🎯 Layouts con Protección Integrada
src\layouts\admin.layout.tsx
- Para rutas protegidas
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AdminLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
// Mostrar loading mientras verificamos el estado de autenticación
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
// Redirigir a login si no está autenticado
if (status === "success" && !signInCheckResult.signedIn) {
return (
<Navigate
to="/auth/login"
replace
/>
);
}
return <Outlet />;
};
export default AdminLayout;
src\layouts\auth.layout.tsx
- Para páginas de autenticación
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AuthLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
// Mostrar loading mientras verificamos el estado de autenticación
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
// Redirigir al dashboard si ya está autenticado
if (status === "success" && signInCheckResult.signedIn) {
return (
<Navigate
to="/admin"
replace
/>
);
}
return <Outlet />;
};
export default AuthLayout;
src\layouts\public.layout.tsx
- Para rutas públicas (sin protección)
import { Outlet } from "react-router";
const PublicLayout = () => {
return <Outlet />;
};
export default PublicLayout;
🔍 Ventajas de integrar la protección en los layouts:
- Cohesión: La lógica de protección está donde realmente se necesita
- Menos dependencias: No necesitas importar componentes adicionales
- Más control: Puedes personalizar el UI de loading por layout
- Mejor rendimiento: Menos niveles de anidamiento de componentes
- Flexibilidad: Cada layout puede tener su propia lógica de redirección
Simple Login and logout
src\pages\auth\login.page.tsx
import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { useAuth } from "reactfire";
const LoginPage = () => {
const auth = useAuth();
const handleGoogleSignIn = async () => {
const provider = new GoogleAuthProvider();
try {
await signInWithPopup(auth, provider);
} catch (error) {
console.log("Error signing in with Google:", error);
}
};
return (
<div>
<h1>Login Page</h1>
<button onClick={handleGoogleSignIn}>Sign in with Google</button>
</div>
);
};
export default LoginPage;
src\pages\admin\dashboard.page.tsx
import { useAuth, useUser } from "reactfire";
const DashboardPage = () => {
const auth = useAuth();
const { data: user } = useUser();
const handleSignOut = async () => {
try {
await auth.signOut();
} catch (error) {
console.log("Error signing out:", error);
}
};
return (
<div>
<h1>Dashboard Page</h1>
<button onClick={handleSignOut}>Sign out</button>
{user ? (
<div>
<h2>Welcome, {user.displayName || "User"}!</h2>
<p>Email: {user.email}</p>
</div>
) : (
<p>No user is signed in.</p>
)}
</div>
);
};
export default DashboardPage;
Variables de entorno .env
Vite utiliza variables de entorno para almacenar configuraciones sensibles como claves API. Crea un archivo .env
en la raíz del proyecto y define tus variables:
VITE_FIREBASE_API_KEY=your_api_key
VITE_FIREBASE_AUTH_DOMAIN=your_auth_domain
VITE_FIREBASE_PROJECT_ID=your_project_id
VITE_FIREBASE_STORAGE_BUCKET=your_storage_bucket
VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
VITE_FIREBASE_APP_ID=your_app_id
Vite expone las variables de entorno que comienzan con VITE_
a tu aplicación. Puedes acceder a ellas en tu código usando import.meta.env
.
Reglas básicas del formato .env
- Clave y valor separados por
=
- No espacios alrededor del
=
- Valores con espacios deben ir entre comillas
- Una variable por línea
- Puedes usar comillas simples o dobles
- Comentarios comienzan con
#
- Las líneas en blanco son ignoradas
src\hooks\use-auth-actions.ts
import {
createUserWithEmailAndPassword,
GoogleAuthProvider,
signInWithEmailAndPassword,
signInWithPopup,
signOut,
updateProfile,
} from "firebase/auth";
import type { AuthError } from "firebase/auth";
import { useState } from "react";
import { useAuth } from "reactfire";
interface AuthActionResult {
success: boolean;
error: AuthError | null;
}
export const useAuthActions = () => {
const [loading, setLoading] = useState(false);
const auth = useAuth();
const login = async (data: {
email: string;
password: string;
}): Promise<AuthActionResult> => {
setLoading(true);
try {
await signInWithEmailAndPassword(auth, data.email, data.password);
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const register = async (data: {
email: string;
password: string;
displayName: string;
/* photoURL?: File */
}): Promise<AuthActionResult> => {
setLoading(true);
try {
const userCredential = await createUserWithEmailAndPassword(
auth,
data.email,
data.password
// photoURL: data.photoURL ? URL.createObjectURL(data.photoURL) : null, // Uncomment if photoURL is used
);
if (userCredential.user) {
await updateProfile(userCredential.user, {
displayName: data.displayName,
});
}
// Forzar la recarga del usuario para sincronizar con ReactFire
// Se utiliza en videos siguientes
await currentUser.user.reload();
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const loginWithGoogle = async (): Promise<AuthActionResult> => {
setLoading(true);
try {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const logout = async (): Promise<AuthActionResult> => {
setLoading(true);
try {
await signOut(auth);
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
return {
login,
register,
loginWithGoogle,
logout,
loading,
};
};
Shadcn
Para mejorar la UI, puedes integrar Shadcn que proporciona componentes estilizados y personalizables. Sus componentes están creados con Tailwind CSS, lo que facilita la personalización y el diseño responsivo.
src\pages\auth\login.page.tsx
import { useAuthActions } from "@/hooks/use-auth-actions";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const LoginPage = () => {
const { loginWithGoogle } = useAuthActions();
const handleLoginWithGoogle = async () => {
const result = await loginWithGoogle();
if (result.success) {
console.log("Login successful");
} else {
console.error("Login failed:", result.error);
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Sign in to your account or continue with Google
</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full"
onClick={handleLoginWithGoogle}
>
Login with Google
</Button>
</CardFooter>
</Card>
);
};
export default LoginPage;
src\pages\admin\dashboard.page.tsx
import { Button } from "@/components/ui/button";
import { useAuthActions } from "@/hooks/use-auth-actions";
import { useUser } from "reactfire";
const DashboardPage = () => {
const { data: user } = useUser();
const { logout } = useAuthActions();
return (
<div className="container mx-auto p-4">
<h1>Dashboard Page</h1>
<p>Welcome, {user?.displayName || "Guest"}!</p>
<p>Email: {user?.email || "Not provided"}</p>
<Button onClick={logout}>Sign Out</Button>
</div>
);
};
export default DashboardPage;
Login and Register Pages
src\layouts\root.layout.tsx
import { Toaster } from "@/components/ui/sonner";
import { Outlet } from "react-router";
const RootLayout = () => {
return (
<>
<Outlet />
<Toaster
position="top-right"
richColors
/>
</>
);
};
export default RootLayout;
src\layouts\auth.layout.tsx
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AuthLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
if (status === "success" && signInCheckResult.signedIn) {
return <Navigate to="/admin" />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full">
<Outlet />
</div>
</div>
);
};
export default AuthLayout;
Zod
src\lib\zod.ts
Opción 1:
import { z } from "zod";
export const loginZodSchema = z.object({
// email: z.email("Invalid email address").trim(),
email: z.string().trim().pipe(z.email()),
password: z.string().min(6, "Password must be at least 6 characters long"),
});
export type LoginZodSchemaType = z.infer<typeof loginZodSchema>;
El método .pipe(z.email())
en Zod (v4) se usa para encadenar esquemas:
- primero se valida (y opcionalmente transforma) con el esquema anterior, y luego el resultado se pasa al esquema de
z.email()
para una validación adicional. - Por ejemplo, si usas
z.string().trim().pipe(z.email())
, primero conviertes el valor a string, luego eliminas los espacios con.trim()
, y finalmente validas que sea un email conz.email()
. Así puedes combinar transformaciones y validaciones en una sola cadena de procesamiento
Opción 2 expresión regular:
import { z } from "zod";
const isValidEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
export const loginZodSchema = z.object({
email: z.string().refine(isValidEmail, {
message: "Invalid email format",
}),
password: z.string().min(6, "Password must be at least 6 characters long"),
});
export type LoginZodSchemaType = z.infer<typeof loginZodSchema>;
Login Page
src\pages\auth\login.page.tsx
import { AuthFooter } from "../../components/auth/auth-footer";
import { useAuthActions } from "../../hooks/use-auth-actions";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { loginZodSchema, type LoginZodSchemaType } from "../../lib/zod";
const LoginPage = () => {
const { loginWithGoogle, loading, login } = useAuthActions();
const form = useForm<LoginZodSchemaType>({
resolver: zodResolver(loginZodSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (values: LoginZodSchemaType) => {
const response = await login(values);
if (response.error) {
console.log(response.error.code);
if (response.error.code === "auth/invalid-login-credentials") {
toast.error("Invalid email or password.");
form.setError("email", {
type: "manual",
message: "Invalid email or password.",
});
form.setError("password", {
type: "manual",
message: "Invalid email or password.",
});
} else {
toast.error("An error occurred while logging in.");
}
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Sign in to your account or continue with Google.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="example@mail.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="******"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={loading}
>
Sign In
</Button>
</form>
</Form>
</CardContent>
<AuthFooter
type="login"
onGoogleAuth={loginWithGoogle}
loading={loading}
/>
</Card>
);
};
export default LoginPage;
Auth Footer
src\components\auth\auth-footer.tsx
import { useAuthActions } from "@/hooks/use-auth-actions";
import { Mail } from "lucide-react";
import { Link } from "react-router";
import { Button } from "../ui/button";
import { CardFooter } from "../ui/card";
interface Props {
loading: boolean;
type: "login" | "register";
}
const AuthFooter = ({ loading, type }: Props) => {
const isLogin = type === "login";
const { loginWithGoogle } = useAuthActions();
const handleLoginWithGoogle = async () => {
const result = await loginWithGoogle();
if (result.success) {
console.log("Login successful");
} else {
console.error("Login failed:", result.error);
}
};
return (
<CardFooter className="flex flex-col items-center gap-4">
<Button
variant="outline"
className="w-full"
onClick={handleLoginWithGoogle}
disabled={loading}
>
<Mail className="mr-2" />
{isLogin ? "Login with Google" : "Register with Google"}
</Button>
<p className="text-center text-sm text-muted-foreground">
{isLogin ? "Don't have an account? " : "Already have an account? "}
<Link to={isLogin ? "/auth/register" : "/auth/login"}>
<Button
variant="link"
className="p-0 h-auto font-normal"
>
{isLogin ? "Register" : "Sign in"}
</Button>
</Link>
</p>
</CardFooter>
);
};
export default AuthFooter;
Register Page
src\lib\zod.ts
...
export const registerZodSchema = z
.object({
email: z.string().trim().pipe(z.email()),
displayName: z
.string()
.min(1, "Display name is required")
.max(50, "Display name must be at most 50 characters long"),
password: z.string().min(6, "Password must be at least 6 characters long"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"], // Esto hace que el error aparezca en el campo confirmPassword
});
export type RegisterZodSchemaType = z.infer<typeof registerZodSchema>;
src\pages\auth\register.page.tsx
import { AuthFooter } from "@/components/auth/auth-footer";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useAuthActions } from "@/hooks/use-auth-actions";
import { registerZodSchema, type RegisterZodSchemaType } from "@/lib/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
const RegisterPage = () => {
const { register, loading, loginWithGoogle } = useAuthActions();
const form = useForm<RegisterZodSchemaType>({
resolver: zodResolver(registerZodSchema),
defaultValues: {
displayName: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = async (values: RegisterZodSchemaType) => {
const response = await register(values);
if (response.error) {
console.log(response.error.code);
if (response.error.code === "auth/email-already-in-use") {
form.setError("email", {
type: "manual",
message: "Email is already in use.",
});
} else {
console.error("Registration error:", response.error);
}
} else {
// Handle successful registration, e.g., redirect or show a success message
console.log("Registration successful", values);
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>Create a new account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter your display name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter your email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder="Enter your password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder="Confirm your password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={loading}
>
Register
</Button>
</form>
</Form>
</CardContent>
<AuthFooter
type="register"
onGoogleAuth={loginWithGoogle}
loading={loading}
/>
</Card>
);
};
export default RegisterPage;
Validaciones React Hook Form
defaultValues
Si los campos password y confirmPassword no están en defaultValues, entonces su valor inicial es undefined, y React Hook Form los tratará como no controlados.
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters long"),
Navbar
src\layouts\admin.layout.tsx
return (
<div>
<Navbar />
<div className="container mx-auto p-4">
<Outlet />
</div>
</div>
);
src\components\navbar.tsx
import { useAuthActions } from "@/hooks/use-auth-actions";
import { cn } from "@/lib/utils";
import { LayoutDashboard, LogOut, MessageCircle, User } from "lucide-react";
import { NavLink } from "react-router";
import { Button } from "./ui/button";
const navigation = [
{ name: "Dashboard", href: "/admin", icon: LayoutDashboard },
{ name: "Chat", href: "/admin/chat", icon: MessageCircle },
{ name: "Profile", href: "/admin/profile", icon: User },
];
const Navbar = () => {
const { logout } = useAuthActions();
return (
<header className="shadow-sm border-b">
<nav className="p-4 flex gap-4">
{navigation.map((item) => (
<NavLink
to={item.href}
key={item.name}
end
className={({ isActive }) =>
cn(
"flex items-center gap-2",
isActive ? "text-blue-600" : "text-gray-700 hover:text-blue-600"
)
}
>
<item.icon className="w-5 h-5" />
{item.name}
</NavLink>
))}
<Button
className="flex items-center gap-2"
onClick={logout}
variant={"outline"}
>
<LogOut />
Logout
</Button>
</nav>
</header>
);
};
export default Navbar;
Actualizar Perfil
ReactFire necesita tiempo para sincronizar los datos de Firebase, y aunque AdminLayout verifica la autenticación, los datos completos del perfil pueden no estar disponibles inmediatamente en componentes hijos.
src\hooks\use-profile-actions.ts
import { updateProfile } from "firebase/auth";
import { useState } from "react";
import type { AuthError } from "firebase/auth";
import { useUser } from "reactfire";
import { useUserActions } from "./use-user-actions";
/**
* Hook personalizado para manejar las acciones del perfil de usuario
* Permite actualizar el displayName y photoURL del usuario autenticado
*/
export const useProfileActions = () => {
const [loading, setLoading] = useState(false);
const { data: user } = useUser();
const { createOrUpdateUser } = useUserActions();
/**
* Actualiza el perfil del usuario en Firebase Auth y sincroniza con Firestore
*/
const updateUserProfile = async (profileData: {
displayName?: string;
photoURL?: string;
}) => {
setLoading(true);
try {
// Validar que el usuario esté autenticado
if (!user) {
throw new Error("Usuario no autenticado");
}
// Actualizar el perfil en Firebase Auth
await updateProfile(user, {
displayName: profileData.displayName || user.displayName,
photoURL: profileData.photoURL || user.photoURL,
});
// Sincronizar los cambios con Firestore
await createOrUpdateUser(user);
// Recargar el usuario para que ReactFire detecte los cambios
await user.reload();
return {
success: true,
error: null,
};
} catch (error) {
console.error("Error al actualizar perfil:", error);
return {
success: false,
error: error as AuthError,
};
} finally {
setLoading(false);
}
};
return {
loading,
updateUserProfile,
};
};
src\pages\admin\profile.page.tsx
import FormProfile from "@/components/profile/form-profile";
import { useUser } from "reactfire";
const ProfilePage = () => {
const { data: user } = useUser();
if (!user) {
return <div>Loading user data...</div>;
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<FormProfile user={user} />
</div>
);
};
export default ProfilePage;
src\components\profile\form-profile.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useProfileActions } from "@/hooks/use-profile-actions";
import type { User } from "firebase/auth";
const profileFormSchema = z.object({
displayName: z.string().min(1, "Display name is required").optional(),
photoURL: z.url("Invalid URL format").optional(),
});
type ProfileFormSchemaType = z.infer<typeof profileFormSchema>;
interface Props {
user: User;
}
const FormProfile = ({ user }: Props) => {
const { loading, updateUserProfile } = useProfileActions();
// Para que photoURL sea realmente opcional y permita dejar el campo vacío en el formulario, debes asignar undefined como valor por defecto si el usuario no tiene un valor de photoURL. Así, el campo será omitido y Zod no intentará validarlo como URL.
const form = useForm<ProfileFormSchemaType>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
displayName: user?.displayName || "",
photoURL: user.photoURL || undefined,
},
});
const onSubmit = async (data: ProfileFormSchemaType) => {
const result = await updateUserProfile({
displayName: data.displayName,
photoURL: data.photoURL,
});
if (result?.error) {
console.error("Error updating profile:", result.error);
} else {
console.log("Profile updated successfully");
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="bluuweb"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="photoURL"
render={({ field }) => (
<FormItem>
<FormLabel>Photo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/photo.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={loading}
>
{loading ? "Updating..." : "Update Profile"}
</Button>
</form>
</Form>
);
};
export default FormProfile;
Campo opcional en Zod
En este caso, photoURL
es un campo opcional que puede ser una URL válida o una cadena vacía. Si se deja vacío, se considera que el usuario no ha proporcionado una foto y se puede manejar adecuadamente en la lógica de actualización del perfil.
const profileFormSchema = z.object({
displayName: z.string().min(1, "Display name is required").optional(),
photoURL: z.union([z.url("Invalid URL format"), z.literal("")]).optional(),
});
Suspense
¿Qué es Suspense
?
Suspense
es una característica de React que permite manejar la carga asíncrona de datos de manera más fluida. Proporciona una forma de mostrar un fallback (como un spinner o un mensaje de carga) mientras se espera que se resuelvan las promesas de datos.
src\layouts\admin.layout.tsx
import Navbar from "@/components/navbar";
import { Suspense } from "react";
import { Navigate, Outlet } from "react-router";
import { useSigninCheck, useUser } from "reactfire";
const AdminLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
// Mostrar loading mientras se verifica el estado de inicio de sesión
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
// Redirigir si el usuario no está autenticado
if (status === "success" && !signInCheckResult.signedIn) {
return (
<Navigate
to="/auth/login"
replace
/>
);
}
return (
<Suspense fallback={<div>Loading User...</div>}>
<AuthenticatedLayout />
</Suspense>
);
};
export default AdminLayout;
const AuthenticatedLayout = () => {
useUser({
suspense: true, // Habilita el modo suspense para obtener el usuario
});
return (
<div>
<Navbar />
<div className="container mx-auto p-4">
<Outlet />
</div>
</div>
);
};
¿ReactFire realiza múltiples llamadas al usar useUser
más de una vez?
Esta pregunta revela mucho sobre cómo ReactFire maneja el estado y optimiza el rendimiento.
La respuesta corta es: no, ReactFire no hace múltiples llamadas ni crea distintos estados de usuario al usar useUser
más de una vez.
La clave está en cómo ReactFire gestiona internamente el estado global y el contexto de React, especialmente cuando se usa con Suspense
.
¿Qué sucede realmente?
- Cuando el componente
AuthenticatedLayout
usauseUser({ suspense: true })
, ReactFire inicia la suscripción al usuario autenticado. Si los datos aún no están listos, se suspende el renderizado y se muestra el fallback correspondiente. - Una vez cargada, la información del usuario se guarda y comparte internamente por ReactFire.
- Si más adelante otro hook (por ejemplo,
useTaskActions
) vuelve a usaruseUser()
, ReactFire reutiliza la misma información ya cargada, sin hacer una nueva llamada a Firebase.
¿Por qué es importante?
- Eficiencia: Se evitan llamadas redundantes, mejorando el rendimiento.
- Consistencia: Toda la app trabaja con el mismo objeto de usuario.
- Patrón de Contexto: Como en React Context, los datos se obtienen una vez y se comparten de forma transparente en todo el árbol de componentes.
useTransition
useTransition
es un hook de React que permite manejar transiciones de estado de manera más eficiente, especialmente en aplicaciones que requieren actualizaciones asíncronas. Es útil para mejorar la experiencia del usuario al evitar bloqueos en la interfaz mientras se realizan operaciones que pueden tardar.
Ejemplo sencillo de uso:
import { useTransition } from "react";
const MyComponent = () => {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
// Aquí va la lógica que puede tardar
// Por ejemplo, una llamada a una API o una actualización de estado
});
};
return (
<div>
<button
onClick={handleClick}
disabled={isPending}
>
{isPending ? "Loading..." : "Click me"}
</button>
</div>
);
};
En este ejemplo, startTransition
se usa para envolver la lógica que puede tardar, lo que permite que React mantenga la interfaz de usuario receptiva. Mientras la transición está en curso, el botón muestra "Loading..." y se desactiva para evitar múltiples clics.
Firestore
Firestore es una base de datos NoSQL en tiempo real que permite almacenar y sincronizar datos entre clientes y servidores. En este proyecto, utilizamos Firestore para manejar los datos de usuario, lista de tareas pendientes y chat.
src\schemas\task.schema.ts
export interface Task {
id: string;
title: string;
description?: string;
completed: boolean;
userId: string; // Reference to UserFirestoreSchema
}
Métodos clave
useUser
: Hook de ReactFire para obtener el usuario autenticado.useFirestore
: Hook de ReactFire para acceder a la instancia de Firestore.collection
: Obtiene una referencia a una colección específica en Firestore.query
: Crea una consulta para filtrar documentos.where
: Filtra documentos según un campo específico.useFirestoreCollectionData
: Hook de ReactFire para obtener datos de una colección de Firestore con soporte parasuspense
y bases de datos en tiempo real.addDoc
: Agrega un nuevo documento a una colección.deleteDoc
: Elimina un documento específico.doc
: Obtiene una referencia a un documento específico en Firestore.updateDoc
: Actualiza un documento existente.
Práctica Tasks
src\hooks\use-task-actions.ts
import type { Task } from "@/schemas/task.schema";
import {
addDoc,
collection,
deleteDoc,
doc,
query,
updateDoc,
where,
} from "firebase/firestore";
import { useFirestore, useFirestoreCollectionData, useUser } from "reactfire";
export const useTaskActions = () => {
const { data: user } = useUser();
const db = useFirestore();
const tasksRef = collection(db, "tasks");
const tasksQuery = query(
tasksRef,
where("userId", "==", user!.uid) // Filtra por el ID del usuario autenticado
);
const { status, data: tasks } = useFirestoreCollectionData(tasksQuery, {
idField: "id", // 👈 Agrega el ID del documento a cada objeto
suspense: true, // 👈 Habilita el modo suspense
});
// CREATE
const createTask = async (taskData: {
title: string;
description?: string;
}) => {
const newTask = {
...taskData, // 👈 SPREAD OPERATOR
completed: false, // Por defecto, una tarea nueva no está completada
userId: user!.uid, // Asigna el ID del usuario autenticado
};
return await addDoc(tasksRef, newTask);
};
// DELETE
const deleteTask = async (id: string) => {
const taskDoc = doc(db, "tasks", id);
return await deleteDoc(taskDoc);
};
// TOGGLE COMPLETED
const toggleTaskCompleted = async (id: string) => {
const task = tasks.find((task) => task.id === id);
if (!task) {
throw new Error("Task not found");
}
const taskDoc = doc(db, "tasks", id);
return await updateDoc(taskDoc, {
completed: !task.completed, // Cambia el estado de completado
});
};
return {
loading: status === "loading",
error: status === "error",
tasks: tasks as Task[],
// Actions
createTask,
deleteTask,
toggleTaskCompleted,
};
};
src\pages\admin\tasks.page.tsx
import FormTask from "@/components/tasks/form.task";
import ListTask from "@/components/tasks/list.task";
import { Suspense } from "react";
const TasksPage = () => {
return (
<div>
<h1 className="text-2xl font-medium">Tasks</h1>
<FormTask />
<Suspense fallback={<div>Loading tasks...</div>}>
<ListTask />
</Suspense>
</div>
);
};
export default TasksPage;
src\components\tasks\form.task.tsx
import { taskZodSchema, type TaskZodSchemaType } from "@/lib/zod.schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useTaskActions } from "@/hooks/use-task-actions";
import { useTransition } from "react";
import { toast } from "sonner";
const FormTask = () => {
const { createTask } = useTaskActions();
const [isPending, startTransition] = useTransition();
const form = useForm<TaskZodSchemaType>({
resolver: zodResolver(taskZodSchema),
defaultValues: {
title: "",
description: "",
},
});
function onSubmit(values: TaskZodSchemaType) {
startTransition(async () => {
try {
await createTask(values);
form.reset();
} catch (error) {
console.error("Error creating task:", error);
toast.error("Failed to create task");
}
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="Task title"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Task description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isPending}
>
{isPending ? "Creating..." : "Create Task"}
</Button>
</form>
</Form>
);
};
export default FormTask;
src\components\tasks\list.task.tsx
import { useTaskActions } from "@/hooks/use-task-actions";
import ItemTask from "./item.task";
const ListTask = () => {
const { tasks } = useTaskActions();
return (
<div>
{/* <pre>{JSON.stringify(tasks, null, 2)}</pre> */}
<div className="space-y-4 mt-4">
{tasks.map((task) => (
<ItemTask
key={task.id}
task={task}
/>
))}
</div>
</div>
);
};
export default ListTask;
src\components\tasks\item.task.tsx
import { useTaskActions } from "@/hooks/use-task-actions";
import { cn } from "@/lib/utils";
import type { Task } from "@/schemas/task.schema";
import { useTransition } from "react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "../ui/card";
interface Props {
task: Task;
}
const ItemTask = ({ task }: Props) => {
const { deleteTask, toggleTaskCompleted } = useTaskActions();
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
try {
await deleteTask(task.id);
} catch (error) {
console.error("Error deleting task:", error);
toast.error("Failed to delete task");
}
});
};
const handleToggleCompleted = async () => {
startTransition(async () => {
try {
await toggleTaskCompleted(task.id);
} catch (error) {
console.error("Error toggling task completion:", error);
toast.error("Failed to toggle task completion");
}
});
};
return (
<Card>
<CardHeader>
<CardTitle
className={cn(
"text-lg font-semibold",
task.completed ? "line-through text-gray-500" : "text-gray-900"
)}
>
{task.title}
</CardTitle>
<CardAction className="space-x-2">
<Button
variant="outline"
onClick={handleToggleCompleted}
disabled={isPending}
>
Update
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
Delete
</Button>
</CardAction>
</CardHeader>
{task.description && <CardContent>{task.description}</CardContent>}
</Card>
);
};
export default ItemTask;
¿Por qué veo los cambios en tiempo real con useFirestoreCollectionData
? ¿Es magia de ReactFire?
No exactamente. Esa inmediatez que experimentas al hacer un CRUD y ver los cambios reflejados al instante en tu UI es una característica propia de Firestore, no de ReactFire.
Cuando usas el SDK de Firestore:
- Los cambios se aplican localmente de inmediato en el cliente, incluso antes de que lleguen al servidor (esto es una forma de actualización optimista).
- El listener en tiempo real se actualiza al instante, gracias a que Firestore notifica los cambios locales primero.
- Luego, el cambio se sincroniza con el servidor. Si todo sale bien, no ves diferencia; si falla, Firestore puede corregirlo automáticamente.
ReactFire, con hooks como useFirestoreCollectionData
, simplemente se encarga de:
- Conectarte al listener de Firestore.
- Mantener la sincronización entre la base de datos y tu UI.
- Re-renderizar tu componente con los datos actualizados.
Las actualizaciones en tiempo real y "optimistas" son una función del SDK de Firestore, y ReactFire solo te las hace fáciles de usar dentro de React. Esa combinación es lo que hace que las apps con Firebase se sientan tan reactivas y fluidas.
Reglas de seguridad de Firestore
Para proteger los datos en Firestore, es crucial definir reglas de seguridad que controlen quién puede leer y escribir en las colecciones. A continuación, se muestra un ejemplo de reglas de seguridad para la colección tasks
, asegurando que solo los usuarios autenticados puedan acceder a sus propios datos.
src\firestore.rules
rules_version = '2'; // Siempre especificamos la versión de las reglas
service cloud.firestore {
match /databases/{database}/documents {
// Estas reglas aplican a la colección 'tasks' y sus documentos
match /tasks/{taskId} {
// Regla de CREACIÓN:
// Solo permite a un usuario crear una nueva tarea SI:
// 1. Está autenticado (request.auth != null)
// Y
// 2. El 'userId' que está intentando establecer en el nuevo documento
// (request.resource.data.userId) es EXACTAMENTE su propio ID de usuario
// autenticado (request.auth.uid).
//
// Esto previene que un usuario malintencionado cree tareas en nombre de otro usuario.
allow create: if request.auth != null && request.auth.uid == request.resource.data.userId;
// Reglas de LECTURA, ACTUALIZACIÓN y ELIMINACIÓN:
// Solo permite a un usuario leer, actualizar o eliminar una tarea SI:
// 1. Está autenticado (request.auth != null)
// Y
// 2. Su ID de usuario (request.auth.uid) coincide con el 'userId'
// que ya está almacenado en el documento existente (resource.data.userId).
//
// 'resource.data' se refiere a los datos del documento TAL COMO ESTÁN
// actualmente en la base de datos. Esto asegura que solo el propietario
// de la tarea pueda verla, modificarla o borrarla.
allow read, update, delete: if request.auth != null && request.auth.uid == resource.data.userId;
}
}
}
Firestore User Management
Para manejar los datos de usuario en Firestore, creamos un esquema y un hook que centraliza las operaciones de creación y actualización de documentos de usuario. Esto asegura que los datos de Firebase Auth se sincronicen correctamente con la colección de usuarios en Firestore.
src\schemas\user.schema.ts
export interface UserFirestoreSchema {
uid: string;
email: string;
displayName?: string;
photoURL?: string;
}
src\hooks\use-user-actions.ts
import type { UserFirestoreSchema } from "@/schemas/user.schema";
import type { User } from "firebase/auth";
import { doc, setDoc } from "firebase/firestore";
import { useFirestore } from "reactfire";
export const useUserActions = () => {
const db = useFirestore();
const createOrUpdateUser = async (user: User) => {
// Validar que el usuario esté disponible
if (!user) {
throw new Error("Usuario no disponible");
}
// Referencia al documento del usuario en Firestore
const userDocRef = doc(db, "users", user.uid);
// Datos del usuario para guardar en Firestore
const userData: UserFirestoreSchema = {
uid: user.uid,
email: user.email || "",
displayName: user.displayName || "",
photoURL: user.photoURL || "",
};
// Crear o actualizar el documento (merge: true preserva campos existentes)
return await setDoc(userDocRef, userData, {
merge: true,
});
};
return {
createOrUpdateUser,
};
};
src\hooks\use-auth-actions.ts
// registerWithEmail
if (userCredential.user) {
await updateProfile(userCredential.user, {
displayName: userData.displayName,
});
// Crear documento del usuario en Firestore
await createOrUpdateUser(userCredential.user);
}
// loginWithGoogle
// Crear o actualizar el usuario en Firestore después del login con Google
if (result.user) {
await createOrUpdateUser(result.user);
}
src\hooks\use-profile-actions.ts
// updateUserProfile
// Actualizar el perfil en Firebase Auth
await updateProfile(user, {
displayName: profileData.displayName || user.displayName,
photoURL: profileData.photoURL || user.photoURL,
});
// Sincronizar los cambios con Firestore
await createOrUpdateUser(user);
Práctica Chat: Introducción
Árbol de colecciones y documentos:
ESTRUCTURA EN FIRESTORE DATABASE:
📁 /users (colección)
📄 abc123uid (documento)
├── displayName: "María García"
├── email: "maria@example.com"
└── photoURL: "https://example.com/avatars/maria.jpg"
📄 def456uid (documento)
├── displayName: "Carlos López"
├── email: "carlos@example.com"
└── photoURL: "https://example.com/avatars/carlos.jpg"
📁 /rooms (colección)
📄 room_abc123_def456 (documento - Chat entre María y Carlos)
├── participants: ["abc123uid", "def456uid"]
├── createdAt: Timestamp(...)
├── lastMessage: {
│ ├── text: "¡Nos vemos mañana!"
│ ├── senderId: "abc123uid"
│ └── timestamp: Timestamp(...)
│ }
└── 📁 messages (subcolección)
├── 📄 msg_001
│ ├── text: "Hola Carlos, ¿cómo estás?"
│ ├── senderId: "abc123uid"
│ ├── timestamp: Timestamp(...)
├── 📄 msg_002
│ └── ... (más mensajes)
└── 📄 msg_003
📄 room_abc123_ghi789 (documento - Chat entre María y Ana)
├── participants: ["abc123uid", "ghi789uid"]
└── 📁 messages (subcolección)
└── ... (mensajes privados entre María y Ana)
src\schemas\room.schema.ts
import type { FieldValue, Timestamp } from "firebase/firestore";
// Interface para Room/Chat
export interface Room {
id: string;
participants: string[]; // Array de UIDs (siempre 2 usuarios)
createdAt: Timestamp | FieldValue;
lastMessage: LastMessage | null;
}
// Interface para el último mensaje en un room
export interface LastMessage {
text: string;
senderId: string;
timestamp: Timestamp | FieldValue;
}
// Interface para Mensaje
export interface Message {
id: string;
text: string;
senderId: string;
timestamp: Timestamp | FieldValue;
}
src\hooks\use-room-actions.ts
import type { Room } from "@/schemas/room.schema";
import { collection, query, where } from "firebase/firestore";
import { useFirestore, useFirestoreCollectionData, useUser } from "reactfire";
export const useRoomActions = () => {
const db = useFirestore();
const { data: user } = useUser();
if (!user) throw new Error("Usuario no autorizado");
const roomRef = collection(db, "rooms");
const roomQuery = query(
roomRef,
where("participants", "array-contains", user.uid)
);
const { data: rooms } = useFirestoreCollectionData(roomQuery, {
suspense: true,
idField: "id",
});
return {
rooms: rooms as Room[],
};
};
src\pages\admin\chat.page.tsx
import MessagesChat from "@/components/chat/messages-chat";
import RoomChat from "@/components/chat/room-chat";
import { Suspense, useState } from "react";
const ChatPage = () => {
const [roomId, setRoomId] = useState("");
const handleSelectedRoomId = (roomId: string) => {
setRoomId(roomId);
};
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2">
<Suspense fallback={<div>Cargando rooms...</div>}>
<RoomChat handleSelectedRoomId={handleSelectedRoomId} />
</Suspense>
{roomId ? (
<Suspense>
<MessagesChat roomId={roomId} />
</Suspense>
) : (
<div>Sin sala seleccionada</div>
)}
</div>
</div>
);
};
export default ChatPage;
src\components\chat\room-chat.tsx
import { useRoomActions } from "@/hooks/use-room-actions";
import { Button } from "../ui/button";
interface Props {
handleSelectedRoomId: (roomId: string) => void;
}
const RoomChat = ({ handleSelectedRoomId }: Props) => {
const { rooms } = useRoomActions();
return (
<div>
{rooms.map((item) => (
<div key={item.id}>
<Button onClick={() => handleSelectedRoomId(item.id)}>
{item.id}
</Button>
</div>
))}
<pre>{JSON.stringify(rooms, null, 2)}</pre>
</div>
);
};
export default RoomChat;
Práctica Chat: Subcolección de mensajes
Una subcolección es una colección anidada dentro de un documento en Firestore. En este caso, cada sala de chat (room
) tiene su propia subcolección llamada messages
, donde se almacenan los mensajes individuales.
src\hooks\use-message-actions.ts
import { collection, orderBy, query } from "firebase/firestore";
import { useFirestore, useFirestoreCollectionData } from "reactfire";
export const useMessageActions = (roomId: string) => {
const db = useFirestore();
const messageRef = collection(db, "rooms", roomId, "messages");
const messageQuery = query(messageRef, orderBy("timestamp", "asc"));
const { data: messages } = useFirestoreCollectionData(messageQuery, {
suspense: true,
idField: "id",
});
return { messages };
};
src\components\chat\messages-chat.tsx
import { useMessageActions } from "@/hooks/use-message-actions";
interface Props {
roomId: string;
}
const MessagesChat = ({ roomId }: Props) => {
const { messages } = useMessageActions(roomId);
return (
<div>
<pre>{JSON.stringify(messages, null, 2)}</pre>
</div>
);
};
export default MessagesChat;
Práctica Chat: Agregar nuevos mensajes
src\hooks\use-message-actions.ts
import type { LastMessage, Message } from "@/schemas/room.schema";
import {
addDoc,
collection,
doc,
orderBy,
query,
serverTimestamp,
updateDoc,
} from "firebase/firestore";
import { useFirestore, useFirestoreCollectionData, useUser } from "reactfire";
export const useMessageActions = (roomId: string) => {
const { data: user } = useUser();
const db = useFirestore();
const messageRef = collection(db, "rooms", roomId, "messages");
const messageQuery = query(messageRef, orderBy("timestamp", "asc"));
const { data: messages } = useFirestoreCollectionData(messageQuery, {
suspense: true,
idField: "id",
});
const sendMessage = async (text: string) => {
if (!user) throw new Error("useMessageActions: No existe usuario");
const timestamp = serverTimestamp();
// crear mensaje
// Usar Omit para excluir 'id' al crear el mensaje
const messageData: Omit<Message, "id"> = {
text: text.trim(),
senderId: user.uid,
timestamp,
};
// actualizar lastMessage en el room
const roomDocumentRef = doc(db, "rooms", roomId);
const lastMessage: LastMessage = {
senderId: user.uid,
text: text.trim(),
timestamp,
};
await Promise.all([
addDoc(messageRef, messageData),
updateDoc(roomDocumentRef, { lastMessage }),
]);
};
return { messages, sendMessage };
};
src\components\chat\messages-chat.tsx
import { useMessageActions } from "@/hooks/use-message-actions";
import FormChat from "./form-chat";
interface Props {
roomId: string;
}
const MessagesChat = ({ roomId }: Props) => {
const { messages, sendMessage } = useMessageActions(roomId);
return (
<div>
<FormChat sendMessage={sendMessage} />
<pre>{JSON.stringify(messages, null, 2)}</pre>
</div>
);
};
export default MessagesChat;
src\lib\zod.schemas.ts
export type TaskZodSchemaType = z.infer<typeof taskZodSchema>;
export const messageZodSchema = z.object({
text: z.string().trim().min(1, "Ingrese algún texto"),
});
export type MessageZodSchemaType = z.infer<typeof messageZodSchema>;
src\components\chat\form-chat.tsx
import { messageZodSchema, type MessageZodSchemaType } from "@/lib/zod.schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useTransition } from "react";
interface Props {
sendMessage: (text: string) => Promise<void>;
}
const FormChat = ({ sendMessage }: Props) => {
const [isLoading, startTransaction] = useTransition();
const form = useForm<MessageZodSchemaType>({
resolver: zodResolver(messageZodSchema),
defaultValues: {
text: "",
},
});
function onSubmit(values: MessageZodSchemaType) {
startTransaction(async () => {
try {
await sendMessage(values.text);
form.reset();
} catch (error) {
console.log(error);
}
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2"
>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Ingrese Texto</FormLabel>
<FormControl>
<Input
placeholder="shadcn"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? "enviando..." : "enviar"}
</Button>
</form>
</Form>
);
};
export default FormChat;
Práctica Chat: Mostrar información del Friend en el chat
src\hooks\use-friend-info.ts
import type { UserFirestore } from "@/schemas/user.schema";
import { doc } from "firebase/firestore";
import { useFirestore, useFirestoreDocData } from "reactfire";
export const useFriendInfo = (friendUID: string) => {
const db = useFirestore();
const friendDocRef = doc(db, "users", friendUID);
const { data: friend } = useFirestoreDocData(friendDocRef, {
idField: "uid",
suspense: true,
});
return {
friend: friend as UserFirestore,
};
};
src\components\chat\messages-chat.tsx
import { useMessagesActions } from "@/hooks/user-messages-actions";
import MessageChat from "./message-chat";
interface Props {
roomId: string;
}
const MessagesChat = ({ roomId }: Props) => {
const { messages } = useMessagesActions(roomId);
return (
<div className="space-y-2">
{/* <pre>{JSON.stringify(messages, null, 2)}</pre> */}
{messages.map((message) => (
<MessageChat
key={message.id}
message={message}
/>
))}
</div>
);
};
export default MessagesChat;
src\components\chat\message-chat.tsx
import { cn } from "@/lib/utils";
import type { Message } from "@/schemas/room.schema";
import { Suspense } from "react";
import { useUser } from "reactfire";
import EmailFriend from "./email-friend";
interface Props {
message: Message;
}
const MessageChat = ({ message }: Props) => {
const { senderId, text } = message;
const { data: user } = useUser();
const isFriend = user?.uid !== senderId;
return (
<div
className={cn(
"max-w-[150px] p-2",
isFriend ? "bg-pink-100" : "bg-green-100 ml-auto"
)}
>
<p>{text}</p>
<p className="truncate text-xs">
{isFriend ? (
<Suspense fallback="cargando email...">
<EmailFriend friendUID={senderId} />
</Suspense>
) : (
user.email
)}
</p>
</div>
);
};
export default MessageChat;
src\components\chat\email-friend.tsx
import { useFriendInfo } from "@/hooks/use-friend-info";
interface Props {
friendUID: string;
}
const EmailFriend = ({ friendUID }: Props) => {
const { friend } = useFriendInfo(friendUID);
return friend.email;
};
export default EmailFriend;
Práctica Chat: Mostrar salas con email del Friend
src\components\chat\list-room-chat.tsx
import { useRoomActions } from "@/hooks/use-room-actions";
import RoomChat from "./room-chat";
interface Props {
handleClickRoomId: (id: string) => void;
}
const ListRoomChat = ({ handleClickRoomId }: Props) => {
const { rooms } = useRoomActions();
return (
<div>
{rooms.map((room) => (
<RoomChat
key={room.id}
room={room}
handleClickRoomId={handleClickRoomId}
/>
))}
{/* <pre>{JSON.stringify(rooms, null, 2)}</pre> */}
</div>
);
};
export default ListRoomChat;
src\components\chat\room-chat.tsx
import type { Room } from "@/schemas/room.schema";
import { Suspense } from "react";
import { useUser } from "reactfire";
import { Button } from "../ui/button";
import EmailFriend from "./email-friend";
interface Props {
room: Room;
handleClickRoomId: (id: string) => void;
}
const RoomChat = ({ room, handleClickRoomId }: Props) => {
const { data: user } = useUser();
const friendUID = room.participants.find((uid) => uid !== user?.uid) || "";
return (
<Button onClick={() => handleClickRoomId(room.id)}>
<Suspense fallback="Cargando email...">
{<EmailFriend friendUID={friendUID} />}
</Suspense>
</Button>
);
};
export default RoomChat;
Práctica Chat: Buscar Friend por email
Para buscar amigos por email, creamos un formulario que permite al usuario ingresar un email y buscar en la colección de usuarios.
src\hooks\use-room-actions.ts
import type { Room } from "@/schemas/room.schema";
import {
addDoc,
collection,
getDocs,
query,
serverTimestamp,
where,
} from "firebase/firestore";
import { useFirestore, useFirestoreCollectionData, useUser } from "reactfire";
export const useRoomActions = () => {
const db = useFirestore();
const { data: user } = useUser();
const roomRef = collection(db, "rooms");
// user.uid
const roomQuery = query(
roomRef,
where("participants", "array-contains", user?.uid)
);
const { data: rooms } = useFirestoreCollectionData(roomQuery, {
suspense: true,
idField: "id",
});
const searchUserWithEmail = async (email: string) => {
const usersRef = collection(db, "users");
const q = query(usersRef, where("email", "==", email));
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
return null;
}
const doc = querySnapshot.docs[0];
return doc.data();
};
const findOrCreateRoom = async (friendEmail: string) => {
if (!user)
return {
success: false,
message: "Error 401",
roomId: null,
};
if (user.email === friendEmail)
return {
success: false,
message: "Error 400 - No te puedes buscar a ti mismo",
roomId: null,
};
const friend = await searchUserWithEmail(friendEmail);
if (!friend)
return {
success: false,
message: "Error 404 - Friend no encontrado",
roomId: null,
};
const existRoom = rooms.find((room) =>
room.participants.find((u: string) => u === friend.uid)
);
if (existRoom)
return {
success: true,
message: "Ya existe la sala",
roomId: existRoom.id,
};
const newRoom: Omit<Room, "id"> = {
createdAt: serverTimestamp(),
lastMessage: null,
participants: [friend.uid, user.uid],
};
const document = await addDoc(roomRef, newRoom);
return {
success: true,
message: "Sala creada",
roomId: document.id,
};
};
return {
rooms: rooms as Room[],
findOrCreateRoom,
};
};
src\pages\admin\chat.page.tsx
import FormMessageChat from "@/components/chat/form-message-chat";
import FormRoom from "@/components/chat/form-room";
import ListRoomChat from "@/components/chat/list-room-chat";
import MessagesChat from "@/components/chat/messages-chat";
import { Suspense, useState } from "react";
const ChatPage = () => {
const [roomId, setRoomId] = useState("");
const handleClickRoomId = (id: string) => {
setRoomId(id);
};
return (
<div className="grid grid-cols-1 md: grid-cols-2 gap-2">
<section className="space-y-2">
{/* Mostrar las rooms */}
<Suspense fallback={<div>Cargando rooms...</div>}>
<FormRoom handleClickRoomId={handleClickRoomId} />
<ListRoomChat
handleClickRoomId={handleClickRoomId}
roomId={roomId}
/>
</Suspense>
</section>
<section>
{/* Mostrar los mensajes */}
{roomId ? (
<Suspense fallback={<div>Cargando mensajes...</div>}>
<FormMessageChat roomId={roomId} />
<MessagesChat roomId={roomId} />
</Suspense>
) : (
<div>Selecciona una sala para chatear</div>
)}
</section>
</div>
);
};
export default ChatPage;
src\lib\zod.schemas.ts
export const findFriendSchema = z.object({
email: z.string().trim().pipe(z.email("Invalid email format")),
});
export type FindFriendSchemaType = z.infer<typeof findFriendSchema>;
src\components\chat\form-room.tsx
import { findFriendSchema, type FindFriendSchemaType } from "@/lib/zod.schemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useRoomActions } from "@/hooks/use-room-actions";
import { useTransition } from "react";
import { toast } from "sonner";
interface Props {
handleClickRoomId: (id: string) => void;
}
const FormRoom = ({ handleClickRoomId }: Props) => {
const { findOrCreateRoom } = useRoomActions();
const [isPending, startTransition] = useTransition();
const form = useForm<FindFriendSchemaType>({
resolver: zodResolver(findFriendSchema),
defaultValues: {
email: "",
},
});
function onSubmit(values: FindFriendSchemaType) {
startTransition(async () => {
const res = await findOrCreateRoom(values.email);
if (!res.success) {
toast.error(res.message);
return;
}
toast.success(res.message);
handleClickRoomId(res.roomId);
// console.log(res);
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="email"
placeholder="friend@mail.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isPending}
>
{isPending ? "Buscando..." : "Buscar"}
</Button>
</form>
</Form>
);
};
export default FormRoom;
Reglas de seguridad para el chat
Reglas:
- users → Solo lees tu perfil y puedes modificarlo si es tuyo.
- tasks → Solo puedes ver o editar tus tareas.
- rooms → Solo puedes ver o modificar salas donde participas.
- messages → Solo puedes leer o enviar mensajes en salas donde estás.
- Prohibido traer listas completas (por privacidad).
src\firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// USERS
match /users/{userId} {
allow read: if request.auth != null;
allow list: if false;
allow write: if request.auth != null && request.auth.uid == userId;
}
// TASKS
match /tasks/{taskId} {
allow create: if request.auth != null && request.auth.uid == request.resource.data.userId;
allow read, update, delete: if request.auth != null && request.auth.uid == resource.data.userId;
}
// ROOMS (incluye mensajes)
match /rooms/{roomId} {
function isParticipant() {
return request.auth != null &&
request.auth.uid in resource.data.participants;
}
allow create: if request.auth != null &&
request.resource.data.participants is list &&
request.auth.uid in request.resource.data.participants;
allow read, write: if isParticipant();
// Subcolección de mensajes
match /messages/{messageId} {
function isRoomParticipant() {
return request.auth != null &&
request.auth.uid in get(/databases/$(database)/documents/rooms/$(roomId)).data.participants;
}
allow read, write: if isRoomParticipant();
}
}
}
}
Explicación de las reglas
Estas son reglas de seguridad de Firestore, y básicamente le dicen a Firebase quién puede leer, escribir, crear o borrar datos, y bajo qué condiciones. Vamos por partes:
1. Versión y servicio
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
- Esto es como decir: "Vamos a usar la versión 2 de las reglas" y "Estas reglas aplican a la base de datos Firestore".
2. Colección users
match /users/{userId} {
allow read: if request.auth != null;
allow list: if false;
allow write: if request.auth != null && request.auth.uid == userId;
}
/users/{userId}
significa: "Estas reglas aplican a cualquier documento dentro de la colecciónusers
".allow read: if request.auth != null;
→ Cualquiera que esté logueado puede leer la información de un usuario.allow list: if false;
→ No se puede hacer una consulta para traer todos los usuarios (para proteger privacidad).allow write: if request.auth != null && request.auth.uid == userId;
→ Un usuario solo puede editar su propio documento (su perfil).
3. Colección tasks
match /tasks/{taskId} {
allow create: if request.auth != null && request.auth.uid == request.resource.data.userId;
allow read, update, delete: if request.auth != null && request.auth.uid == resource.data.userId;
}
- Aquí las reglas están para las tareas:
allow create
→ Solo puedes crear una tarea si estás logueado y el campouserId
que envías en los datos es tu propio UID.allow read, update, delete
→ Solo puedes leer, editar o borrar tareas que tengan en su campouserId
tu UID.
4. Colección rooms
match /rooms/{roomId} {
function isParticipant() {
return request.auth != null &&
request.auth.uid in resource.data.participants;
}
- Aquí entramos al chat.
isParticipant()
es una función que revisa si estás logueado y si tu UID está en la listaparticipants
del room (sala de chat).
allow create: if request.auth != null &&
request.resource.data.participants is list &&
request.auth.uid in request.resource.data.participants;
Para crear una sala, debes:
- Estar logueado.
- Mandar una lista
participants
. - Tu UID debe estar en esa lista.
allow read, write: if isParticipant();
- Solo los participantes de esa sala pueden leer o escribir (editar/borrar) la info de la sala.
5. Subcolección messages
dentro de rooms
match /messages/{messageId} {
function isRoomParticipant() {
return request.auth != null &&
request.auth.uid in get(/databases/$(database)/documents/rooms/$(roomId)).data.participants;
}
allow read, write: if isRoomParticipant();
}
- Aquí se protege cada mensaje dentro de una sala.
- La función
isRoomParticipant()
revisa en la base de datos si tu UID está en losparticipants
de la sala (roomId
). - Solo puedes leer y escribir mensajes si eres participante de esa sala.
Importante
Resetear y limpiar el caché de reactfire:
src\hooks\use-auth-actions.ts
const logout = async (): Promise<AuthActionResponse> => {
setLoading(true);
try {
await signOut(auth);
window.location.href = "/auth/login";
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
Mejorar Chat con Copilot
Promp: Puedes mejorar la apariencia de la página admin/chat, para que se parezca un poco a un chat real, pero manten los componentes de shadcn lo más puros posibles, si necesitas agregar algo de css hazco con tailwind. que sea responsive y manteniendo un diseño minimalista pero funcional. solo trabaja en los archivos ya creados, no crees nuevos.
Deploy a Hosting de Firebase
Instalar Firebase CLI:
npm install -g firebase-tools
Iniciar sesión en Firebase:
firebase login
Inicializar el proyecto:
firebase init
Selecciona las siguientes opciones:
- Hosting: Configure and deploy Firebase Hosting sites
- Usa el proyecto existente que creaste en la consola de Firebase.
- Configura el directorio de publicación como
dist
(o el nombre de tu carpeta de salida). - Configura como una SPA (Single Page Application) respondiendo
yes
a la pregunta de si quieres configurar como SPA. - No sobrescribir
index.html
si te lo pregunta. - Configura las reglas de seguridad según tus necesidades.
firebase deploy
firebase deploy --only hosting