🗄️ Database Guide
Choose and implement storage for an Expo or React Native app with practical patterns for AsyncStorage, SQLite, Supabase, Firebase, Prisma, offline synchronization, migrations, security, and performance.
Index
- Database Options
- Local Storage (AsyncStorage)
- Local SQLite
- Supabase (PostgreSQL)
- Firebase Firestore
- Custom Backend with Prisma
- Sync Patterns
- Migrations and Schemas
- Database Best Practices
- Implementation Checklist
Database Options
| Option | Type | Best for | Pros | Cons |
|---|---|---|---|---|
| AsyncStorage | Local (Key-Value) | Preferences, simple cache | Simple, included | Not relational, size limit |
| SQLite | Local (SQL) | Offline-first apps, structured data | Relational, fast, offline | Local only, no sync |
| Supabase | Cloud (PostgreSQL) | Apps with users, real-time | SQL, built-in Auth, real-time | Requires connection |
| Firebase Firestore | Cloud (NoSQL) | Simple apps, real-time | Easy setup, scalable | Limited queries, costly at scale |
| Backend + Prisma | Cloud (SQL) | Full control, complex apps | Flexible, typed | More development |
Quick Decision
Do you need sync across devices?
├── NO → AsyncStorage or SQLite
└── YES → Do you need complex SQL queries?
├── YES → Supabase or custom backend
└── NO → Firebase Firestore
Local Storage (AsyncStorage)
InstalaciĂłn
npx expo install @react-native-async-storage/async-storage
Uso Básico
import AsyncStorage from '@react-native-async-storage/async-storage';
// Guardar string
await AsyncStorage.setItem('user_token', 'abc123');
// Guardar objeto (JSON)
await AsyncStorage.setItem('user_preferences', JSON.stringify({
theme: 'dark',
language: 'es',
notifications: true,
}));
// Leer
const token = await AsyncStorage.getItem('user_token');
const prefsString = await AsyncStorage.getItem('user_preferences');
const prefs = prefsString ? JSON.parse(prefsString) : null;
// Eliminar
await AsyncStorage.removeItem('user_token');
// Eliminar mĂşltiples
await AsyncStorage.multiRemove(['key1', 'key2', 'key3']);
// Limpiar todo
await AsyncStorage.clear();
// Obtener todas las keys
const keys = await AsyncStorage.getAllKeys();
Hook Personalizado para AsyncStorage
// hooks/useAsyncStorage.ts
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export function useAsyncStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadValue = async () => {
try {
const item = await AsyncStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.error('Error loading from AsyncStorage:', error);
} finally {
setIsLoading(false);
}
};
loadValue();
}, [key]);
const setValue = useCallback(async (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
await AsyncStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error saving to AsyncStorage:', error);
}
}, [key, storedValue]);
const removeValue = useCallback(async () => {
try {
await AsyncStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error('Error removing from AsyncStorage:', error);
}
}, [key, initialValue]);
return { value: storedValue, setValue, removeValue, isLoading };
}
IntegraciĂłn con React Query
// contexts/DataContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
const STORAGE_KEY = 'todos';
export const [TodoProvider, useTodos] = createContextHook(() => {
const queryClient = useQueryClient();
// Leer datos
const todosQuery = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
},
});
// Guardar datos
const saveMutation = useMutation({
mutationFn: async (todos: Todo[]) => {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
return todos;
},
onSuccess: (data) => {
queryClient.setQueryData(['todos'], data);
},
});
// Agregar todo
const addTodo = (title: string) => {
const newTodo: Todo = {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date().toISOString(),
};
const updated = [...(todosQuery.data || []), newTodo];
saveMutation.mutate(updated);
};
// Toggle completado
const toggleTodo = (id: string) => {
const updated = (todosQuery.data || []).map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
saveMutation.mutate(updated);
};
// Eliminar todo
const deleteTodo = (id: string) => {
const updated = (todosQuery.data || []).filter(todo => todo.id !== id);
saveMutation.mutate(updated);
};
return {
todos: todosQuery.data || [],
isLoading: todosQuery.isLoading,
addTodo,
toggleTodo,
deleteTodo,
};
});
SQLite Local
InstalaciĂłn
npx expo install expo-sqlite
ConfiguraciĂłn y Uso
// lib/database.ts
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('myapp.db');
// Inicializar tablas
export async function initDatabase() {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
avatar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
completed INTEGER DEFAULT 0,
due_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos (user_id);
`);
}
// Operaciones CRUD
export const todosDB = {
async getAll(userId: number) {
return db.getAllAsync<Todo>(
'SELECT * FROM todos WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
},
async getById(id: number) {
return db.getFirstAsync<Todo>(
'SELECT * FROM todos WHERE id = ?',
[id]
);
},
async create(todo: Omit<Todo, 'id' | 'created_at'>) {
const result = await db.runAsync(
'INSERT INTO todos (user_id, title, description, completed, due_date) VALUES (?, ?, ?, ?, ?)',
[todo.user_id, todo.title, todo.description || null, todo.completed ? 1 : 0, todo.due_date || null]
);
return result.lastInsertRowId;
},
async update(id: number, updates: Partial<Todo>) {
const fields = Object.keys(updates)
.map(key => `${key} = ?`)
.join(', ');
const values = Object.values(updates);
await db.runAsync(
`UPDATE todos SET ${fields} WHERE id = ?`,
[...values, id]
);
},
async delete(id: number) {
await db.runAsync('DELETE FROM todos WHERE id = ?', [id]);
},
async toggleCompleted(id: number) {
await db.runAsync(
'UPDATE todos SET completed = NOT completed WHERE id = ?',
[id]
);
},
};
Hook para SQLite con React Query
// hooks/useSQLiteTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { todosDB } from '@/lib/database';
export function useSQLiteTodos(userId: number) {
const queryClient = useQueryClient();
const todosQuery = useQuery({
queryKey: ['todos', userId],
queryFn: () => todosDB.getAll(userId),
});
const createMutation = useMutation({
mutationFn: (todo: Omit<Todo, 'id' | 'created_at'>) => todosDB.create(todo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Partial<Todo> }) =>
todosDB.update(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => todosDB.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
return {
todos: todosQuery.data || [],
isLoading: todosQuery.isLoading,
create: createMutation.mutate,
update: updateMutation.mutate,
delete: deleteMutation.mutate,
isCreating: createMutation.isPending,
};
}
Supabase (PostgreSQL)
InstalaciĂłn
npx expo install @supabase/supabase-js
ConfiguraciĂłn
// lib/supabase.ts
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
// Tipos generados (puedes generarlos con supabase gen types)
export interface Database {
public: {
Tables: {
profiles: {
Row: {
id: string;
email: string;
name: string;
avatar_url: string | null;
created_at: string;
};
Insert: Omit<Database['public']['Tables']['profiles']['Row'], 'created_at'>;
Update: Partial<Database['public']['Tables']['profiles']['Insert']>;
};
todos: {
Row: {
id: number;
user_id: string;
title: string;
description: string | null;
completed: boolean;
due_date: string | null;
created_at: string;
};
Insert: Omit<Database['public']['Tables']['todos']['Row'], 'id' | 'created_at'>;
Update: Partial<Database['public']['Tables']['todos']['Insert']>;
};
};
};
}
Operaciones CRUD con Supabase
// lib/supabase-db.ts
import { supabase, Database } from './supabase';
type Todo = Database['public']['Tables']['todos']['Row'];
type TodoInsert = Database['public']['Tables']['todos']['Insert'];
type TodoUpdate = Database['public']['Tables']['todos']['Update'];
export const supabaseTodos = {
async getAll(userId: string): Promise<Todo[]> {
const { data, error } = await supabase
.from('todos')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
async getById(id: number): Promise<Todo | null> {
const { data, error } = await supabase
.from('todos')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
},
async create(todo: TodoInsert): Promise<Todo> {
const { data, error } = await supabase
.from('todos')
.insert(todo)
.select()
.single();
if (error) throw error;
return data;
},
async update(id: number, updates: TodoUpdate): Promise<Todo> {
const { data, error } = await supabase
.from('todos')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: number): Promise<void> {
const { error } = await supabase
.from('todos')
.delete()
.eq('id', id);
if (error) throw error;
},
};
Suscripciones en Tiempo Real
// hooks/useRealtimeTodos.ts
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import { supabaseTodos } from '@/lib/supabase-db';
export function useRealtimeTodos(userId: string) {
const queryClient = useQueryClient();
const todosQuery = useQuery({
queryKey: ['todos', userId],
queryFn: () => supabaseTodos.getAll(userId),
});
useEffect(() => {
// Suscribirse a cambios en tiempo real
const channel = supabase
.channel('todos-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'todos',
filter: `user_id=eq.${userId}`,
},
(payload) => {
console.log('Cambio recibido:', payload);
// Invalidar query para refetch
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId, queryClient]);
return todosQuery;
}
Contexto Completo con Supabase
// contexts/SupabaseDataContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { supabaseTodos } from '@/lib/supabase-db';
import { useAuth } from './AuthContext';
export const [SupabaseDataProvider, useSupabaseData] = createContextHook(() => {
const queryClient = useQueryClient();
const { user } = useAuth();
const userId = user?.id;
// Query de todos
const todosQuery = useQuery({
queryKey: ['todos', userId],
queryFn: () => supabaseTodos.getAll(userId!),
enabled: !!userId,
});
// SuscripciĂłn en tiempo real
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel('todos-realtime')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'todos',
filter: `user_id=eq.${userId}`,
},
() => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId, queryClient]);
// Mutations
const createMutation = useMutation({
mutationFn: (data: { title: string; description?: string }) =>
supabaseTodos.create({
user_id: userId!,
title: data.title,
description: data.description || null,
completed: false,
due_date: null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: number; updates: any }) =>
supabaseTodos.update(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => supabaseTodos.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos', userId] });
},
});
return {
todos: todosQuery.data || [],
isLoading: todosQuery.isLoading,
error: todosQuery.error,
createTodo: createMutation.mutateAsync,
updateTodo: updateMutation.mutateAsync,
deleteTodo: deleteMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
};
});
Firebase Firestore
InstalaciĂłn
npx expo install firebase
ConfiguraciĂłn
// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
Operaciones CRUD con Firestore
// lib/firestore-db.ts
import {
collection,
doc,
getDocs,
getDoc,
addDoc,
updateDoc,
deleteDoc,
query,
where,
orderBy,
onSnapshot,
Timestamp,
} from 'firebase/firestore';
import { db } from './firebase';
interface Todo {
id: string;
userId: string;
title: string;
description?: string;
completed: boolean;
dueDate?: Date;
createdAt: Date;
}
const todosCollection = collection(db, 'todos');
export const firestoreTodos = {
async getAll(userId: string): Promise<Todo[]> {
const q = query(
todosCollection,
where('userId', '==', userId),
orderBy('createdAt', 'desc')
);
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt?.toDate(),
dueDate: doc.data().dueDate?.toDate(),
})) as Todo[];
},
async getById(id: string): Promise<Todo | null> {
const docRef = doc(db, 'todos', id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) return null;
return {
id: docSnap.id,
...docSnap.data(),
createdAt: docSnap.data().createdAt?.toDate(),
dueDate: docSnap.data().dueDate?.toDate(),
} as Todo;
},
async create(todo: Omit<Todo, 'id' | 'createdAt'>): Promise<string> {
const docRef = await addDoc(todosCollection, {
...todo,
createdAt: Timestamp.now(),
dueDate: todo.dueDate ? Timestamp.fromDate(todo.dueDate) : null,
});
return docRef.id;
},
async update(id: string, updates: Partial<Todo>): Promise<void> {
const docRef = doc(db, 'todos', id);
const updateData = { ...updates };
if (updates.dueDate) {
updateData.dueDate = Timestamp.fromDate(updates.dueDate) as any;
}
await updateDoc(docRef, updateData);
},
async delete(id: string): Promise<void> {
const docRef = doc(db, 'todos', id);
await deleteDoc(docRef);
},
// SuscripciĂłn en tiempo real
subscribe(userId: string, callback: (todos: Todo[]) => void) {
const q = query(
todosCollection,
where('userId', '==', userId),
orderBy('createdAt', 'desc')
);
return onSnapshot(q, (snapshot) => {
const todos = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt?.toDate(),
dueDate: doc.data().dueDate?.toDate(),
})) as Todo[];
callback(todos);
});
},
};
Hook con Firestore y Tiempo Real
// hooks/useFirestoreTodos.ts
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { firestoreTodos } from '@/lib/firestore-db';
export function useFirestoreTodos(userId: string) {
const queryClient = useQueryClient();
const [todos, setTodos] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
// SuscripciĂłn en tiempo real
useEffect(() => {
const unsubscribe = firestoreTodos.subscribe(userId, (newTodos) => {
setTodos(newTodos);
setIsLoading(false);
queryClient.setQueryData(['todos', userId], newTodos);
});
return () => unsubscribe();
}, [userId, queryClient]);
const createMutation = useMutation({
mutationFn: (data: { title: string; description?: string }) =>
firestoreTodos.create({
userId,
title: data.title,
description: data.description,
completed: false,
}),
});
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: string; updates: Partial<Todo> }) =>
firestoreTodos.update(id, updates),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => firestoreTodos.delete(id),
});
return {
todos,
isLoading,
create: createMutation.mutateAsync,
update: updateMutation.mutateAsync,
delete: deleteMutation.mutateAsync,
};
}
Backend Propio con Prisma
Estructura
backend/
├── prisma/
│ └── schema.prisma
├── trpc/
│ ├── todos/
│ │ ├── list/route.ts
│ │ ├── create/route.ts
│ │ ├── update/route.ts
│ │ └── delete/route.ts
│ └── app-router.ts
└── lib/
└── prisma.ts
Schema de Prisma
// backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
todos Todo[]
sessions Session[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Todo {
id String @id @default(cuid())
userId String
title String
description String?
completed Boolean @default(false)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
Procedimientos tRPC
// backend/trpc/todos/list/route.ts
import { z } from 'zod';
import { protectedProcedure } from '../../trpc';
export const listTodosProcedure = protectedProcedure
.input(z.object({
completed: z.boolean().optional(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
}).optional())
.query(async ({ ctx, input }) => {
const { completed, limit, cursor } = input || {};
const todos = await ctx.db.todo.findMany({
where: {
userId: ctx.userId,
...(completed !== undefined && { completed }),
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (todos.length > limit) {
const nextItem = todos.pop();
nextCursor = nextItem?.id;
}
return {
items: todos,
nextCursor,
};
});
// backend/trpc/todos/create/route.ts
import { z } from 'zod';
import { protectedProcedure } from '../../trpc';
export const createTodoProcedure = protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
dueDate: z.string().datetime().optional(),
}))
.mutation(async ({ ctx, input }) => {
const todo = await ctx.db.todo.create({
data: {
userId: ctx.userId,
title: input.title,
description: input.description,
dueDate: input.dueDate ? new Date(input.dueDate) : null,
},
});
return todo;
});
Uso en el Cliente
// En componentes React
import { trpc } from '@/lib/trpc';
function TodoList() {
const todosQuery = trpc.todos.list.useQuery({ limit: 20 });
const createMutation = trpc.todos.create.useMutation({
onSuccess: () => {
todosQuery.refetch();
},
});
const handleCreate = () => {
createMutation.mutate({
title: 'Nuevo todo',
description: 'DescripciĂłn opcional',
});
};
if (todosQuery.isLoading) return <Loading />;
if (todosQuery.error) return <Error message={todosQuery.error.message} />;
return (
<FlatList
data={todosQuery.data?.items}
renderItem={({ item }) => <TodoItem todo={item} />}
/>
);
}
Patrones de SincronizaciĂłn
Offline-First con Cola de SincronizaciĂłn
// lib/syncQueue.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
interface SyncOperation {
id: string;
type: 'create' | 'update' | 'delete';
table: string;
data: any;
timestamp: number;
}
const QUEUE_KEY = 'sync_queue';
export const syncQueue = {
async add(operation: Omit<SyncOperation, 'id' | 'timestamp'>) {
const queue = await this.getQueue();
queue.push({
...operation,
id: Date.now().toString(),
timestamp: Date.now(),
});
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
},
async getQueue(): Promise<SyncOperation[]> {
const stored = await AsyncStorage.getItem(QUEUE_KEY);
return stored ? JSON.parse(stored) : [];
},
async remove(id: string) {
const queue = await this.getQueue();
const updated = queue.filter(op => op.id !== id);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(updated));
},
async processQueue(syncFn: (op: SyncOperation) => Promise<void>) {
const isConnected = await NetInfo.fetch().then(state => state.isConnected);
if (!isConnected) return;
const queue = await this.getQueue();
for (const operation of queue) {
try {
await syncFn(operation);
await this.remove(operation.id);
} catch (error) {
console.error('Sync failed for operation:', operation.id, error);
break; // Stop processing on error to maintain order
}
}
},
};
Hook de SincronizaciĂłn
// hooks/useSyncedData.ts
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
import { syncQueue } from '@/lib/syncQueue';
export function useSyncedData() {
const queryClient = useQueryClient();
useEffect(() => {
// Escuchar cambios de conectividad
const unsubscribe = NetInfo.addEventListener(state => {
if (state.isConnected) {
// Procesar cola cuando hay conexiĂłn
syncQueue.processQueue(async (operation) => {
// Implementar lĂłgica de sincronizaciĂłn segĂşn el tipo
switch (operation.type) {
case 'create':
await api.create(operation.table, operation.data);
break;
case 'update':
await api.update(operation.table, operation.data.id, operation.data);
break;
case 'delete':
await api.delete(operation.table, operation.data.id);
break;
}
});
// Refetch datos después de sincronizar
queryClient.invalidateQueries();
}
});
return () => unsubscribe();
}, [queryClient]);
}
Migraciones y Esquemas
Supabase Migrations
-- supabase/migrations/001_initial.sql
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
due_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ĂŤndices
CREATE INDEX idx_todos_user_id ON todos(user_id);
-- RLS (Row Level Security)
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
-- PolĂticas
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Users can view own todos"
ON todos FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can create own todos"
ON todos FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own todos"
ON todos FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own todos"
ON todos FOR DELETE
USING (auth.uid() = user_id);
Database Best Practices
Choose storage by responsibility
- Use SecureStore for small secrets and authentication material.
- Use AsyncStorage for non-sensitive preferences and lightweight cached values.
- Use SQLite for structured local data, offline search, and operation queues.
- Use Supabase, Firebase, or a custom backend when data must sync between users or devices.
- Define one authoritative source for each record. A cache must not silently become a second source of truth.
Validate every boundary
Types do not validate data at runtime. Parse network responses, persisted JSON, deep-link parameters, and synchronization payloads before using them in the application.
import { z } from 'zod';
const todoSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
completed: z.boolean(),
updatedAt: z.string().datetime(),
});
const todo = todoSchema.parse(untrustedValue);
Enforce authorization in the backend
- Client-side checks improve the interface but do not secure data.
- Enable Row Level Security on every Supabase table exposed through the API.
- Test policies as an anonymous user, the record owner, and a signed-in non-owner.
- Never include a database password or Supabase service-role key in the mobile app.
- Select only the fields the client needs and avoid exposing internal administrative columns.
Version every schema change
- Commit migrations with the application code.
- Apply the same ordered migrations in development, staging, and production.
- Make destructive changes backward-compatible while older app versions remain active.
- Back up production data before high-risk migrations.
- Test restoration as well as backup creation.
Design offline writes deliberately
An offline-capable write needs a stable operation identifier, a retry policy, idempotent backend behavior, and an explicit conflict strategy. Show pending and failed synchronization states in the interface instead of implying that every local write already reached the server.
Use last-write-wins only when losing an intermediate edit is acceptable. Financial, inventory, and collaborative data usually require version checks, transactions, or domain-specific conflict handling.
Measure before optimizing
- Paginate large result sets.
- Index columns used by filters, joins, ordering, and security policies.
- Inspect query plans before and after adding an index.
- Avoid issuing one network query per rendered list row.
- Monitor slow queries, failed migrations, policy errors, and unusual request volume.
Checklist de ImplementaciĂłn
- Elegir soluciĂłn de base de datos segĂşn necesidades
- Configurar variables de entorno
- Definir esquema/tipos de datos
- Implementar operaciones CRUD básicas
- Integrar con React Query para caché
- Implementar manejo de errores
- Agregar estados de carga en UI
- Implementar tiempo real (si aplica)
- Configurar sincronizaciĂłn offline (si aplica)
- Probar en iOS, Android y Web
- Implementar migraciones para cambios de esquema
Build from a production-ready foundation
Prefer this pattern already implemented?
Expo Boilerplate includes the production integrations, project structure and step-by-step documentation so you can focus on your product.
One-time payment · Lifetime updates · 7-day refund