A cart open in three tabs
A shopper has the same store open in three tabs, with live prices coming from a WebSocket. An item added in any tab has to appear in all of them, and a tab opened later has to start from the same cart.
By hand
- Each tab builds the new list from its own copy and sends all of it. Two tabs add at once, and the list that arrives last has no item from the other.
event.dataisany. Renamelinesin the sender and the receiver still compiles, and sets the cart toundefined.- The price feed opens in every tab: five tabs, five sockets to the same server.
- A tab opened later starts with an empty cart and no prices. A
BroadcastChannelonly carries what is sent after it opens.
import { useEffect, useState } from 'react'
type Line = { sku: string; qty: number }type Prices = Record<string, number>
// cart.ts: every tab keeps its own copy, and tells the others when it changesconst channel = new BroadcastChannel('shop')
export function useCart() { // A tab opened now starts here, whatever the other tabs already hold const [lines, setLines] = useState<Line[]>([])
useEffect(() => { const onMessage = (event: MessageEvent) => { // event.data is any: nothing checks what the other tab sent, or that it still sends it if (event.data.type === 'cart') setLines(event.data.lines) } channel.addEventListener('message', onMessage) return () => channel.removeEventListener('message', onMessage) }, [])
function add(sku: string) { // Built from this tab's copy. Two tabs adding at once each send a list without the other's item const next = lines.some((line) => line.sku === sku) ? lines.map((line) => (line.sku === sku ? { ...line, qty: line.qty + 1 } : line)) : [...lines, { sku, qty: 1 }] setLines(next) channel.postMessage({ type: 'cart', lines: next }) }
function remove(sku: string) { const next = lines.filter((line) => line.sku !== sku) setLines(next) channel.postMessage({ type: 'cart', lines: next }) }
return { lines, add, remove }}
// prices.ts: live prices for what is in the cartexport function usePrices() { const [prices, setPrices] = useState<Prices>({})
useEffect(() => { // Runs in every open tab: five tabs, five sockets to the same feed const socket = new WebSocket('wss://shop.example.com/prices') socket.onmessage = (event) => setPrices(JSON.parse(event.data)) return () => socket.close() }, [])
return prices}
// CartSummary.tsx: the same component in every tabexport function CartSummary() { const { lines, add, remove } = useCart() const prices = usePrices() const total = lines.reduce((sum, line) => sum + line.qty * (prices[line.sku] ?? 0), 0)
return ( <ul> {lines.map((line) => ( <li key={line.sku}> {line.sku} × {line.qty} <button onClick={() => add(line.sku)}>+1</button> <button onClick={() => remove(line.sku)}>Remove</button> </li> ))} <li>Total {total.toFixed(2)}</li> </ul> )}With use-everywhere
useSharedReducersends the action, not the list. The leader tab puts the actions in one order, and every tab applies both.- Actions are a
CartActionunion.dispatchtakes nothing else, and the reducer has to handle each one. useLeaderEffectopens the socket in one tab. When that tab closes, another takes over and opens it.- A new tab asks the open ones for the current cart and prices, and starts from them.
import { useLeaderEffect, useSharedReducer, useSharedState } from 'use-everywhere'
type Line = { sku: string; qty: number }type Prices = Record<string, number>type CartAction = { type: 'add'; sku: string } | { type: 'remove'; sku: string }
// cart.ts: a tab sends what it did, and every tab applies the same actions in the same orderfunction cart(lines: Line[], action: CartAction): Line[] { switch (action.type) { case 'add': return lines.some((line) => line.sku === action.sku) ? lines.map((line) => (line.sku === action.sku ? { ...line, qty: line.qty + 1 } : line)) : [...lines, { sku: action.sku, qty: 1 }] case 'remove': return lines.filter((line) => line.sku !== action.sku) }}
export function useCart() { // A tab opened now asks the open tabs for the cart, and starts from theirs const [lines, dispatch] = useSharedReducer(cart, [], { key: 'cart' }) return { lines, add: (sku: string) => dispatch({ type: 'add', sku }), remove: (sku: string) => dispatch({ type: 'remove', sku }), }}
// prices.ts: live prices for what is in the cartexport function usePrices() { // The same value in every tab, and handed to a new tab when it opens const [prices, setPrices] = useSharedState<Prices>('prices', {})
// Runs in one tab only, and moves to another tab when that one closes useLeaderEffect(() => { const socket = new WebSocket('wss://shop.example.com/prices') socket.onmessage = (event) => setPrices(JSON.parse(event.data)) return () => socket.close() })
return prices}
// CartSummary.tsx: the same component in every tabexport function CartSummary() { const { lines, add, remove } = useCart() const prices = usePrices() const total = lines.reduce((sum, line) => sum + line.qty * (prices[line.sku] ?? 0), 0)
return ( <ul> {lines.map((line) => ( <li key={line.sku}> {line.sku} × {line.qty} <button onClick={() => add(line.sku)}>+1</button> <button onClick={() => remove(line.sku)}>Remove</button> </li> ))} <li>Total {total.toFixed(2)}</li> </ul> )}