🔔 Notifications System
Index
- Notification Options
- Local Notifications
- Push Notifications with Expo
- Push Notifications with Firebase
- Notifications Context
- Permission Handling
- Deep Linking from Notifications
Notification Options
| Option | Type | Best for | Pros | Cons |
|---|
| Local Notifications | Local | Reminders, alarms | No server, offline | Not remote |
| Expo Push | Push | Expo apps, prototyping | Easy setup, free | Expo only |
| Firebase FCM | Push | Production apps | Scalable, analytics | More setup |
| OneSignal | Push | Marketing, segmentation | Powerful dashboard | Cost at scale |
Local Notifications
Installation
npx expo install expo-notifications
Basic Configuration
// lib/notifications.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function requestPermissions() {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
return finalStatus === 'granted';
}
Programar Notificación Local
// lib/notifications.ts
import * as Notifications from 'expo-notifications';
export async function scheduleLocalNotification({
title,
body,
data,
trigger,
}: {
title: string;
body: string;
data?: Record<string, unknown>;
trigger?: Notifications.NotificationTriggerInput;
}) {
const id = await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
sound: true,
},
trigger: trigger ?? null,
});
return id;
}
export async function scheduleDelayedNotification(
title: string,
body: string,
seconds: number
) {
return scheduleLocalNotification({
title,
body,
trigger: { seconds, repeats: false },
});
}
export async function scheduleDailyNotification(
title: string,
body: string,
hour: number,
minute: number
) {
return scheduleLocalNotification({
title,
body,
trigger: {
hour,
minute,
repeats: true,
},
});
}
export async function cancelNotification(id: string) {
await Notifications.cancelScheduledNotificationAsync(id);
}
export async function cancelAllNotifications() {
await Notifications.cancelAllScheduledNotificationsAsync();
}
Hook para Notificaciones Locales
// hooks/useLocalNotifications.ts
import { useEffect, useRef, useState } from 'react';
import * as Notifications from 'expo-notifications';
import { useMutation } from '@tanstack/react-query';
import {
requestPermissions,
scheduleLocalNotification,
cancelNotification,
cancelAllNotifications
} from '@/lib/notifications';
export function useLocalNotifications() {
const [hasPermission, setHasPermission] = useState(false);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
requestPermissions().then(setHasPermission);
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('Notification received:', notification);
}
);
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
console.log('Notification tapped:', response);
const data = response.notification.request.content.data;
// Handle navigation based on data
}
);
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
const scheduleMutation = useMutation({
mutationFn: (params: { title: string; body: string; seconds?: number }) =>
scheduleLocalNotification({
title: params.title,
body: params.body,
trigger: params.seconds ? { seconds: params.seconds } : null,
}),
});
const cancelMutation = useMutation({
mutationFn: cancelNotification,
});
return {
hasPermission,
schedule: scheduleMutation.mutate,
cancel: cancelMutation.mutate,
cancelAll: cancelAllNotifications,
isScheduling: scheduleMutation.isPending,
};
}
Push Notifications con Expo
Obtener Push Token
// lib/pushNotifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Permission not granted for push notifications');
return null;
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF6B35',
});
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data;
}
Enviar Push desde Backend
// backend/lib/push.ts
interface PushMessage {
to: string;
title: string;
body: string;
data?: Record<string, unknown>;
sound?: 'default' | null;
badge?: number;
}
export async function sendPushNotification(message: PushMessage) {
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: message.to,
title: message.title,
body: message.body,
data: message.data,
sound: message.sound ?? 'default',
badge: message.badge,
}),
});
return response.json();
}
export async function sendPushToMultiple(tokens: string[], message: Omit<PushMessage, 'to'>) {
const messages = tokens.map(token => ({
...message,
to: token,
}));
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
});
return response.json();
}
Push Notifications con Firebase
Instalación
npx expo install @react-native-firebase/app @react-native-firebase/messaging
Configuración
// lib/firebasePush.ts
import messaging from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
export async function requestFirebasePermission() {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
return enabled;
}
export async function getFirebaseToken(): Promise<string | null> {
const hasPermission = await requestFirebasePermission();
if (!hasPermission) {
return null;
}
const token = await messaging().getToken();
return token;
}
export function onTokenRefresh(callback: (token: string) => void) {
return messaging().onTokenRefresh(callback);
}
export function onForegroundMessage(callback: (message: any) => void) {
return messaging().onMessage(callback);
}
export function setBackgroundMessageHandler(handler: (message: any) => Promise<void>) {
messaging().setBackgroundMessageHandler(handler);
}
Hook para Firebase Push
// hooks/useFirebasePush.ts
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
getFirebaseToken,
onTokenRefresh,
onForegroundMessage,
} from '@/lib/firebasePush';
export function useFirebasePush(onMessage?: (message: any) => void) {
const [token, setToken] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState(false);
useEffect(() => {
getFirebaseToken().then((t) => {
if (t) {
setToken(t);
setHasPermission(true);
}
});
const unsubscribeRefresh = onTokenRefresh((newToken) => {
setToken(newToken);
});
const unsubscribeMessage = onForegroundMessage((message) => {
console.log('FCM Message:', message);
onMessage?.(message);
});
return () => {
unsubscribeRefresh();
unsubscribeMessage();
};
}, [onMessage]);
const registerMutation = useMutation({
mutationFn: async () => {
const newToken = await getFirebaseToken();
if (newToken) {
setToken(newToken);
setHasPermission(true);
// Save token to your backend
// await api.saveToken(newToken);
}
return newToken;
},
});
return {
token,
hasPermission,
register: registerMutation.mutate,
isRegistering: registerMutation.isPending,
};
}
Contexto de Notificaciones
// contexts/NotificationsContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
interface NotificationItem {
id: string;
title: string;
body: string;
data?: Record<string, unknown>;
read: boolean;
createdAt: string;
}
const STORAGE_KEY = 'app_notifications';
const PUSH_TOKEN_KEY = 'push_token';
export const [NotificationsProvider, useNotifications] = createContextHook(() => {
const queryClient = useQueryClient();
const router = useRouter();
const [pushToken, setPushToken] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState(false);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
const notificationsQuery = useQuery({
queryKey: ['notifications'],
queryFn: async (): Promise<NotificationItem[]> => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
},
});
const saveMutation = useMutation({
mutationFn: async (notifications: NotificationItem[]) => {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
return notifications;
},
onSuccess: (data) => {
queryClient.setQueryData(['notifications'], data);
},
});
const registerPushMutation = useMutation({
mutationFn: async () => {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
return null;
}
setHasPermission(true);
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
const tokenData = await Notifications.getExpoPushTokenAsync();
const token = tokenData.data;
await AsyncStorage.setItem(PUSH_TOKEN_KEY, token);
setPushToken(token);
return token;
},
});
useEffect(() => {
AsyncStorage.getItem(PUSH_TOKEN_KEY).then((token) => {
if (token) {
setPushToken(token);
setHasPermission(true);
}
});
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
const { title, body, data } = notification.request.content;
addNotification({
title: title || 'Notificación',
body: body || '',
data: data as Record<string, unknown>,
});
}
);
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
if (data?.route) {
router.push(data.route as string);
}
}
);
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
const notifications = notificationsQuery.data || [];
const unreadCount = notifications.filter((n) => !n.read).length;
const addNotification = useCallback(
(notification: Omit<NotificationItem, 'id' | 'read' | 'createdAt'>) => {
const newNotification: NotificationItem = {
id: Date.now().toString(),
...notification,
read: false,
createdAt: new Date().toISOString(),
};
const updated = [newNotification, ...notifications];
saveMutation.mutate(updated);
},
[notifications, saveMutation]
);
const markAsRead = useCallback(
(id: string) => {
const updated = notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
);
saveMutation.mutate(updated);
},
[notifications, saveMutation]
);
const markAllAsRead = useCallback(() => {
const updated = notifications.map((n) => ({ ...n, read: true }));
saveMutation.mutate(updated);
}, [notifications, saveMutation]);
const clearAll = useCallback(() => {
saveMutation.mutate([]);
}, [saveMutation]);
const scheduleLocal = useCallback(
async (title: string, body: string, trigger?: Notifications.NotificationTriggerInput) => {
return Notifications.scheduleNotificationAsync({
content: { title, body, sound: true },
trigger: trigger ?? null,
});
},
[]
);
return {
notifications,
unreadCount,
hasPermission,
pushToken,
isLoading: notificationsQuery.isLoading,
registerPush: registerPushMutation.mutateAsync,
isRegistering: registerPushMutation.isPending,
addNotification,
markAsRead,
markAllAsRead,
clearAll,
scheduleLocal,
};
});
Manejo de Permisos
Componente de Solicitud de Permisos
// components/NotificationPermissionPrompt.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Bell } from 'lucide-react-native';
import { Button } from '@/components/Button';
import { useTheme } from '@/contexts/ThemeContext';
import { useNotifications } from '@/contexts/NotificationsContext';
interface Props {
onDismiss?: () => void;
}
export function NotificationPermissionPrompt({ onDismiss }: Props) {
const { colors } = useTheme();
const { registerPush, isRegistering, hasPermission } = useNotifications();
if (hasPermission) return null;
const handleEnable = async () => {
await registerPush();
onDismiss?.();
};
return (
<View style={[styles.container, { backgroundColor: colors.surface }]}>
<View style={[styles.iconContainer, { backgroundColor: colors.primaryLight }]}>
<Bell size={32} color={colors.primary} />
</View>
<Text style={[styles.title, { color: colors.text }]}>
Mantente al día
</Text>
<Text style={[styles.description, { color: colors.textSecondary }]}>
Activa las notificaciones para recibir actualizaciones importantes
</Text>
<View style={styles.buttons}>
<Button
title="Activar"
onPress={handleEnable}
loading={isRegistering}
/>
<Button
title="Ahora no"
variant="ghost"
onPress={onDismiss}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 24,
borderRadius: 16,
alignItems: 'center',
margin: 16,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
title: {
fontSize: 20,
fontWeight: '600' as const,
marginBottom: 8,
},
description: {
fontSize: 14,
textAlign: 'center',
marginBottom: 24,
lineHeight: 20,
},
buttons: {
width: '100%',
gap: 8,
},
});
Deep Linking desde Notificaciones
Configurar Deep Links
// app/_layout.tsx
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
function useNotificationDeepLink() {
const router = useRouter();
useEffect(() => {
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
if (data?.type === 'message') {
router.push(`/(tabs)/messages/${data.chatId}`);
} else if (data?.type === 'item') {
router.push({
pathname: '/(tabs)/(home)/item-detail',
params: { id: data.itemId },
});
} else if (data?.route) {
router.push(data.route as string);
}
}
);
return () => subscription.remove();
}, [router]);
}
Enviar con Deep Link Data
// Desde backend o localmente
await Notifications.scheduleNotificationAsync({
content: {
title: 'Nuevo mensaje',
body: 'Juan te envió un mensaje',
data: {
type: 'message',
chatId: 'chat-123',
},
},
trigger: null,
});
Checklist de Implementación