Islands sharing server state
Two islands, one cart kept in the PHP session: the shelf posts, the server answers the cart, and an event tells the other island - a reload shows the same cart.
padSession$padExpose
CustomEventuseEffect
Live
How it is built
PAD, on the server React, in the browser
<div class="cols-2" style="grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr)">
<div data-island="Shelf" data-props="{^shelf}"></div>
<aside data-island="Basket" data-props="{^cart}"></aside>
</div>
<script type="text/babel" src="{$padRoot}react/examples/cart.js"></script><?php
// The cart is the session's: product id => quantity. A post adds one (op=add), takes one
// off (op=remove) or empties it (op=clear), and the answer to it - like the page itself -
// is the cart as the islands show it: lines, count and total. Stock is the server's to
// check: a product sold out cannot be added, however the button was pressed.
$products = array_column ( reactData ( 'products' ), NULL, 'id' );
$counts = padSession ( 'reactCart', [] );
if ( padRequestIs ( 'POST' ) ) {
$op = padRequest ( 'op', '' );
$id = (int) padRequest ( 'id', 0 );
if ( $op == 'clear' )
$counts = [];
elseif ( $op == 'add' and isset ( $products [$id] ) and $products [$id] ['stock'] > ( $counts [$id] ?? 0 ) )
$counts [$id] = ( $counts [$id] ?? 0 ) + 1;
elseif ( $op == 'remove' and isset ( $counts [$id] ) and --$counts [$id] <= 0 )
unset ( $counts [$id] );
padSessionPut ( 'reactCart', $counts );
}
$cart = reactCart ( $counts );
$shelf = [ 'products' => array_slice ( array_values ( $products ), 0, 9 ), 'cart' => $cart ];
$padExpose = [ 'cart' ];
?>// Two islands, two React roots, one cart - kept by PAD in the session. Whichever island
// changes it posts to the page and gets the whole cart back, then tells the other one with a
// 'pad:cart' event on window. Neither keeps a cart of its own that could drift: the server's
// answer is the cart. Reload the page and both start from the session again.
function cartChange(op, id) {
return PadReact.post('examples/cart', id === undefined ? { op } : { op, id }).then(answer => {
window.dispatchEvent(new CustomEvent('pad:cart', { detail: answer.cart }));
return answer.cart;
});
}
function useCart(first) {
const [cart, setCart] = React.useState(first);
React.useEffect(() => {
const listen = event => setCart(event.detail);
window.addEventListener('pad:cart', listen);
return () => window.removeEventListener('pad:cart', listen);
}, []);
return cart;
}
function Shelf({ products, cart: first }) {
const cart = useCart(first);
const [busy, setBusy] = React.useState(null);
const [toast, say] = useToast();
const inCart = id => (cart.lines.find(line => line.id === id) || {}).quantity || 0;
const add = async product => {
setBusy(product.id);
try {
const after = await cartChange('add', product.id);
const line = after.lines.find(one => one.id === product.id);
say(line && line.quantity > inCart(product.id) ? `${product.emoji} ${product.name} added` : `No more ${product.name} in stock`);
} catch (error) {
say(error.message);
} finally {
setBusy(null);
}
};
return (
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
{products.map(product => (
<div key={product.id} className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<span style={{ fontSize: 34 }}>{product.emoji}</span>
{inCart(product.id) > 0 && <span key={inCart(product.id)} className="badge is-react pop">{inCart(product.id)} in cart</span>}
</div>
<strong style={{ display: 'block', marginTop: 8 }}>{product.name}</strong>
<div className="muted small">{money(product.price)} · {product.stock ? product.stock + ' in stock' : 'sold out'}</div>
<button className="btn-sm" style={{ marginTop: 10, width: '100%' }} disabled={busy === product.id} onClick={() => add(product)}>
{busy === product.id ? <span className="spinner" /> : '+'} Add
</button>
</div>
))}
{toast}
</div>
);
}
function Basket(props) {
const cart = useCart(props);
const [busy, setBusy] = React.useState(false);
const change = async (op, id) => {
setBusy(true);
try { await cartChange(op, id); } finally { setBusy(false); }
};
return (
<div className="card" style={{ position: 'sticky', top: 80 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>๐ Cart</h3>
<span key={cart.count} className="badge is-react pop">{cart.count} {cart.count === 1 ? 'item' : 'items'}</span>
</div>
{cart.lines.length === 0 ? (
<div className="empty-state"><span className="big">๐งบ</span>Empty - add something from the shelf.</div>
) : (
<div style={{ margin: '14px 0', display: 'grid', gap: 10 }}>
{cart.lines.map(line => (
<div key={line.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 22 }}>{line.emoji}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{line.name}</div>
<div className="muted small">{line.quantity} ร {money(line.price)}</div>
</div>
<button className="btn-icon btn-ghost" disabled={busy} aria-label={'One ' + line.name + ' less'} onClick={() => change('remove', line.id)}>โ</button>
<button className="btn-icon btn-ghost" disabled={busy} aria-label={'One ' + line.name + ' more'} onClick={() => change('add', line.id)}>+</button>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--line)', paddingTop: 12, fontWeight: 800 }}>
<span>Total</span><span>{money(cart.total)}</span>
</div>
{cart.lines.length > 0 && (
<button className="btn-sm btn-ghost" style={{ marginTop: 12 }} disabled={busy} onClick={() => change('clear')}>Empty the cart</button>
)}
<RequestLog title="Posts to PAD" />
</div>
);
}
PadReact.island('Shelf', Shelf);
PadReact.island('Basket', Basket);