Rxova
← use-everywhere

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

  1. 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.
  2. event.data is any. Rename lines in the sender and the receiver still compiles, and sets the cart to undefined.
  3. The price feed opens in every tab: five tabs, five sockets to the same server.
  4. A tab opened later starts with an empty cart and no prices. A BroadcastChannel only 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 changes
const 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 cart
export 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 tab
export 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

  1. useSharedReducer sends the action, not the list. The leader tab puts the actions in one order, and every tab applies both.
  2. Actions are a CartAction union. dispatch takes nothing else, and the reducer has to handle each one.
  3. useLeaderEffect opens the socket in one tab. When that tab closes, another takes over and opens it.
  4. 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 order
function 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 cart
export 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 tab
export 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>
)
}