PatternsState and Logic
Sync State Across Tabs (BroadcastChannel)
Keep application state synchronized across browser tabs
BroadcastChannel enables communication between tabs—perfect for syncing auth, theme, and real-time updates across all open instances of your app.
BroadcastChannel API
// utils/broadcast.ts
class TabSync {
private channel: BroadcastChannel;
constructor(channelName: string) {
this.channel = new BroadcastChannel(channelName);
}
send(message: any): void {
this.channel.postMessage(message);
}
listen(callback: (message: any) => void): () => void {
const handler = (event: MessageEvent) => {
callback(event.data);
};
this.channel.addEventListener('message', handler);
// Return cleanup function
return () => {
this.channel.removeEventListener('message', handler);
};
}
close(): void {
this.channel.close();
}
}
export const tabSync = new TabSync('app-sync');React Hook
// hooks/useSyncedState.ts
export function useSyncedState<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [state, setState] = useState<T>(() => {
// Load from localStorage
const saved = loadFromLocalStorage<T>(key);
return saved !== null ? saved : initialValue;
});
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
// Create channel
channelRef.current = new BroadcastChannel(key);
// Listen for updates from other tabs
const handleMessage = (event: MessageEvent) => {
setState(event.data);
};
channelRef.current.addEventListener('message', handleMessage);
return () => {
channelRef.current?.removeEventListener('message', handleMessage);
channelRef.current?.close();
};
}, [key]);
const setValue = (value: T) => {
// Update local state
setState(value);
// Save to localStorage
saveToLocalStorage(key, value);
// Broadcast to other tabs
channelRef.current?.postMessage(value);
};
return [state, setValue];
}
// Usage
export function ThemeToggle() {
const [theme, setTheme] = useSyncedState('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme: {theme}
</button>
);
}Auth Sync
// hooks/useSyncedAuth.ts
interface AuthState {
user: User | null;
token: string | null;
}
export function useSyncedAuth() {
const [auth, setAuth] = useSyncedState<AuthState>('auth', {
user: null,
token: null,
});
const login = (user: User, token: string) => {
setAuth({ user, token });
};
const logout = () => {
setAuth({ user: null, token: null });
};
return { auth, login, logout };
}
// Usage
export function App() {
const { auth, logout } = useSyncedAuth();
// When user logs out in one tab,
// all tabs log out automatically
return <div>{auth.user?.name}</div>;
}Real-Time Notifications Sync
// hooks/useNotificationSync.ts
interface Notification {
id: string;
message: string;
read: boolean;
}
export function useNotificationSync() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const channelRef = useRef<BroadcastChannel>();
useEffect(() => {
channelRef.current = new BroadcastChannel('notifications');
channelRef.current.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'NEW_NOTIFICATION':
setNotifications(prev => [...prev, data]);
break;
case 'MARK_READ':
setNotifications(prev =>
prev.map(n =>
n.id === data.id ? { ...n, read: true } : n
)
);
break;
case 'CLEAR_ALL':
setNotifications([]);
break;
}
});
return () => channelRef.current?.close();
}, []);
const addNotification = (notification: Notification) => {
setNotifications(prev => [...prev, notification]);
channelRef.current?.postMessage({
type: 'NEW_NOTIFICATION',
data: notification,
});
};
const markAsRead = (id: string) => {
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: true } : n))
);
channelRef.current?.postMessage({
type: 'MARK_READ',
data: { id },
});
};
const clearAll = () => {
setNotifications([]);
channelRef.current?.postMessage({ type: 'CLEAR_ALL' });
};
return {
notifications,
addNotification,
markAsRead,
clearAll,
};
}Cart Sync (E-commerce)
// hooks/useCartSync.ts
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
export function useCartSync() {
const [cart, setCart] = useSyncedState<CartItem[]>('cart', []);
const addToCart = (item: CartItem) => {
setCart(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...prev, item];
});
};
const removeFromCart = (id: string) => {
setCart(prev => prev.filter(item => item.id !== id));
};
const updateQuantity = (id: string, quantity: number) => {
setCart(prev =>
prev.map(item =>
item.id === id ? { ...item, quantity } : item
)
);
};
const clearCart = () => {
setCart([]);
};
const total = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
cart,
total,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
};
}Leader Election (One Tab Manages Task)
// utils/leader-election.ts
export function useLeaderElection() {
const [isLeader, setIsLeader] = useState(false);
const channelRef = useRef<BroadcastChannel>();
const leaderCheckRef = useRef<NodeJS.Timeout>();
useEffect(() => {
channelRef.current = new BroadcastChannel('leader-election');
// Check if already a leader
let hasLeader = false;
channelRef.current.addEventListener('message', (event) => {
if (event.data.type === 'LEADER_ALIVE') {
hasLeader = true;
} else if (event.data.type === 'LEADER_RESIGN') {
hasLeader = false;
tryBecomeLeader();
}
});
const tryBecomeLeader = () => {
// Ask if there's a leader
channelRef.current?.postMessage({ type: 'LEADER_CHECK' });
setTimeout(() => {
if (!hasLeader) {
setIsLeader(true);
// Announce leadership
channelRef.current?.postMessage({ type: 'LEADER_ALIVE' });
// Keep announcing
leaderCheckRef.current = setInterval(() => {
channelRef.current?.postMessage({ type: 'LEADER_ALIVE' });
}, 5000);
}
}, 100);
};
// Try to become leader
tryBecomeLeader();
// Announce resignation before unload
const handleBeforeUnload = () => {
if (isLeader) {
channelRef.current?.postMessage({ type: 'LEADER_RESIGN' });
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
clearInterval(leaderCheckRef.current);
window.removeEventListener('beforeunload', handleBeforeUnload);
if (isLeader) {
channelRef.current?.postMessage({ type: 'LEADER_RESIGN' });
}
channelRef.current?.close();
};
}, [isLeader]);
return isLeader;
}
// Usage: Only leader tab polls for updates
export function AutoRefresh() {
const isLeader = useLeaderElection();
useEffect(() => {
if (!isLeader) return;
// Only leader polls
const interval = setInterval(() => {
fetchUpdates();
}, 10000);
return () => clearInterval(interval);
}, [isLeader]);
return isLeader ? <div>🎯 Leader Tab</div> : null;
}Fallback for Unsupported Browsers
// utils/cross-tab-sync.ts
export function createCrossTabSync(key: string) {
// Check BroadcastChannel support
if (typeof BroadcastChannel !== 'undefined') {
return new BroadcastChannel(key);
}
// Fallback: Use localStorage events
return {
postMessage: (data: any) => {
localStorage.setItem(`${key}-sync`, JSON.stringify({
data,
timestamp: Date.now(),
}));
},
addEventListener: (_type: string, callback: (e: any) => void) => {
const handler = (e: StorageEvent) => {
if (e.key === `${key}-sync` && e.newValue) {
const { data } = JSON.parse(e.newValue);
callback({ data });
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
},
close: () => {},
};
}Best Practices
- Namespace channels: Use unique names per feature
- Version messages: Handle schema changes
- Handle duplicates: Same tab receives message
- Clean up: Close channels on unmount
- Sync on visibility: Update when tab becomes visible
- Leader election: For shared tasks
- Test across tabs: Open multiple tabs
- Fallback support: localStorage events
- Limit message size: Keep under 1MB
- Document protocol: Message types and structure
Cross-tab sync creates a unified experience—changes in one tab instantly reflect in all others!