/ REACT + NEXT.JS /
Deriving What to Show
One pure function instead of scattered ifs, from a real bug.
I built a booking site for a party-rental business. The owner can hide any item from customers with one checkbox. Soon after launch she reported something strange:
"When I hide the free gate, it hides the whole bundle it belongs to."
That one sentence is a great way to learn three things that matter in every React and Next.js app:
- Derived data: what to compute instead of store.
- Pure functions: why putting a rule in one plain function makes it easy to test.
- The server/client line: which work belongs on the server and which in the browser.
The situation
The site sells pieces (a ball pit, play balls, a safety gate) and bundles (named groups of pieces, like "Crawler Set"). Each piece has two settings the owner controls:
The data, as the database returns it
type Piece = {
id: string
name: string
showAlone: boolean // the owner's "show on its own" checkbox
units: number // how many she owns; 0 means none in service
}
type Bundle = {
id: string
name: string
pieceIds: string[]
}The safety gate is free and only makes sense inside a bundle, so she turns showAlone off. Nobody should buy a gate by itself. But the bundle that contains it should still be for sale.
/ DEBUGGING /
Reproduce the bug first
The original rule
function originalRule(pieces, bundles) {
// Only pieces shown on their own, and in stock
const visible = pieces.filter((p) => p.showAlone && p.units > 0)
const visibleIds = new Set(visible.map((p) => p.id))
return {
pieces: visible,
// A bundle needs every one of its pieces to be visible
bundles: bundles.filter((b) => b.pieceIds.every((id) => visibleIds.has(id))),
}
}Try it below. Untick Show on its own for the Safety Gate and watch the Crawler Set.
The bug, live
The owner's settings
Soft Ball Pit
Play Balls
Safety Gate
What a customer sees
Sold on their own
- Soft Ball Pit
- Play Balls
Bundles
- Ball PartyIncludes: Soft Ball Pit, Play Balls
Why did the whole bundle disappear?
Compute it, don't store it
Notice what never changed: the owner's settings. Whether a bundle shows is not something she sets. It is derived from her settings every time, and the rule that derives it was wrong.
That is the key habit: keep only the facts you can't work out (what she owns, what she switched off) and compute everything else from them.
Facts
pieces, bundles, switches
One rule
a pure function
What to show
derived result
If you stored "is this bundle visible?" as its own field, you would have to remember to update it every time any piece changed. Sooner or later you'd forget, and the page would show something that disagrees with the facts. A computed value can't be out of date.
React teaches the same idea for state: don't put something in useState if you can calculate it from props or other state during render.
The fix: ask two separate questions
The fixed rule
function fixedRule(pieces, bundles) {
// Question 1: what can be booked at all?
const inStock = pieces.filter((p) => p.units > 0)
const stockIds = new Set(inStock.map((p) => p.id))
// A bundle is offered when every piece is in stock
const offered = bundles.filter(
(b) => b.pieceIds.length > 0 && b.pieceIds.every((id) => stockIds.has(id)),
)
const inBundles = new Set(offered.flatMap((b) => b.pieceIds))
return {
// Question 2: what is sold on its own?
pieces: inStock.filter((p) => p.showAlone),
// Hidden pieces still needed to show and book an offered bundle
bundlePieces: inStock.filter((p) => !p.showAlone && inBundles.has(p.id)),
bundles: offered,
}
}Two details are worth studying. First, the function now returns bundlePieces: the hidden pieces the page still needs, so a bundle's "Includes" list can show Safety Gate even though the gate isn't listed on its own. Second, it uses a Set for lookups, which is faster and clearer than searching an array inside a loop.
Compare both rules using the same switches:
Original vs. fixed
The owner's settings
Soft Ball Pit
Play Balls
Safety Gate
What a customer sees
Sold on their own
- Soft Ball Pit
- Play Balls
Bundles
- Crawler SetIncludes: Soft Ball Pit, Safety Gate
- Ball PartyIncludes: Soft Ball Pit, Play Balls
Things to try: hide the gate; mark the ball pit out of stock; hide every piece. Predict the result before you look.
/ PURE FUNCTIONS /
Why a plain function makes this easy to trust
fixedRule is a pure function: the same input always gives the same output, and it touches nothing outside itself. No database, no network, no React. That means a test needs no setup at all, just data in and answers out:
A real test, using Node's built-in test runner
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
describe('fixedRule', () => {
it('keeps a bundle when one of its pieces is hidden from the list', () => {
const gate = { id: 'gate', name: 'Safety Gate', showAlone: false, units: 1 }
const pit = { id: 'pit', name: 'Soft Ball Pit', showAlone: true, units: 1 }
const bundle = { id: 'set', name: 'Crawler Set', pieceIds: ['pit', 'gate'] }
const result = fixedRule([pit, gate], [bundle])
assert.deepEqual(result.pieces.map((p) => p.id), ['pit'])
assert.deepEqual(result.bundlePieces.map((p) => p.id), ['gate'])
assert.deepEqual(result.bundles.map((b) => b.id), ['set'])
})
})This test is the bug report, written in code. It fails on the original rule and passes on the fixed one, and it will keep protecting that behavior long after anyone remembers the story.
/ NEXT.JS /
Where this runs: the server/client line
In the Next.js App Router, a page is a server component by default. It can await a database query directly. The interactive part, the cart with buttons and quantities, is a client component. In the real site the flow looks like this:
Server component (page)
await the database, then call the rule
Pure function
decides what to show
Props
plain data crosses the boundary
Client component
useState, clicks, the cart
app/page.tsx (a server component)
export default async function Page() {
const all = await loadCatalogue() // database read, server only
const store = selectStorefront(all) // the pure rule
return (
<RentalBuilder
products={store.pieces}
bundlePieces={store.bundlePieces} // so a bundle can list a hidden piece
bundles={store.bundles}
/>
)
}RentalBuilder.tsx (a client component)
'use client'
export function RentalBuilder({ products, bundlePieces, bundles }) {
// The browser may look up any piece by id...
const known = [...products, ...bundlePieces]
// ...but only the ones sold alone get their own card
return products.map((p) => <PieceCard key={p.id} piece={p} />)
}A rule of thumb that works well:
- Decide on the server. Reading data and applying business rules happen once, in one place.
- Interact in the client. Anything that needs useState, clicks or the browser goes in a 'use client' file.
- Pass plain data between them. Props that cross the line must be simple values the server can send.
/ THE SECOND BUG /
The same rule lived in two places
The page looked perfect after the fix: the bundle showed, the gate was in its list. Then I tried to book it, and the site replied that the gate "is already booked". Nothing was booked.
The reason was a second copy of the same idea, in a database query that decides which physical items are available:
Before: hidden pieces had no units, so nothing could be reserved
SELECT u.id, u.product_id
FROM units u JOIN products p ON p.id = u.product_id
WHERE u.active AND p.active -- p.active is "show on its own"After: availability only cares whether the unit exists
-- A hidden piece ("Show on its own" off) still has real units
SELECT u.id, u.product_id FROM units u WHERE u.activeThe lesson: when you change what a word means ("active" now means sold on its own, not exists), search for every place that used it. I only caught this one because I tested the whole path end to end, not just the page.
Don't trust the browser
Hiding the gate from the page doesn't make it unbuyable. Anyone can send a hand-made request to your server. So the booking code re-checks on the server that a piece being ordered alone is one that is sold alone:
Server-side check before booking
const soldAlone = new Set(store.pieces.map((p) => p.id))
if (cart.pieces.some((p) => p.qty > 0 && !soldAlone.has(p.id))) {
return { ok: false, error: 'One of those items is no longer available.' }
}The screen is a convenience. The server is the authority.
/ CHALLENGE /
Your turn: write the test first
The owner has a new request:
"Sometimes I want to retire a piece completely: it must not show anywhere, not even inside a bundle."
Before changing any code, write down the tests that describe the new behavior. Imagine a new field, retired: boolean, on Piece.
Before you look at hints, think through:
- What should happen to a bundle that contains a retired piece?
- Should a retired piece ever appear in the list of items sold on their own?
- What about a retired piece that is also out of stock?
What to take with you
- Store facts, derive the rest. A computed value can't go out of date.
- Keep each rule in one pure function. It is easy to read, easy to test and easy to change.
- Don't let one word mean two things. "Hidden from the list" and "not available" are different questions.
- Search for every use when a meaning changes, then test the whole path.
- Server decides, client interacts, and the server always re-checks.
- Turn bug reports into tests.