A generator is a tempting thing to build. State, a button, Math.random(), done in twenty minutes, and it feels like a real tool.
It is also, as normally built, a page with nothing on it. Everything it will ever say exists only after a click, which means a crawler sees an empty box, a reader with a slow connection sees an empty box, and the search term that brought anybody there in the first place appears nowhere in the response.
Our team name generator is built the other way round, and you can check it with curl:
curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Quizteama Aguilera"
# 1
curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Multiple Scoregasms"
# 1
Every name in the pool is in that response. All 110 of them, in five categories, as ordinary text in a list under the tool. The tool draws from the list; it does not hold it.
Why the list is the page
"Pub quiz team names" is what people search for. "Pub quiz team name generator" is what a much smaller number of people search for. A page that only satisfies the second query, by hiding its content behind a button press, has thrown away the first.
That is the entire argument, and it is written at the top of the data module so that it survives the next refactor:
/**
* The team name pool behind /tools/team-name-generator.
*
* It lives in a module rather than inside the component so that the page can
* render every name into the HTML on the server. A generator whose entire
* content only exists after a button is clicked has nothing for a crawler to
* read, and "pub quiz team names" is a query people search far more often than
* they search for a generator. The interactive part picks from this list; it
* does not hold it.
*
* Kept clean rather than crude on purpose. Whoever reads the results out has to
* say every one of these in front of a room that may include somebody's
* grandmother, and the clever ones get a bigger laugh than the shocking ones.
*/
The shape is a plain typed constant, five categories, each with a one-line blurb describing what it is good for:
export type TeamNameCategory = {
key: string
label: string
/** What this category is good for, in one line. */
blurb: string
names: readonly string[]
}
export const TEAM_NAME_CATEGORIES = [
{
key: 'puns',
label: 'Puns on famous names',
blurb: 'The biggest category and the most reliable. A name everybody knows, bent around a quiz word.',
names: ['Quizteama Aguilera', 'Les Quizerables', 'Tequila Mockingbird', /* ... */],
},
// honest, food, music-film, wordplay
] as const satisfies readonly TeamNameCategory[]
export const ALL_TEAM_NAMES: readonly string[] = TEAM_NAME_CATEGORIES.flatMap(
(category) => category.names
)
as const satisfies is doing real work there. as const keeps the literal types so the category keys stay a known union rather than widening to string, and satisfies checks the shape without erasing those literals the way an annotation would. The page then maps over the same constant the generator draws from, so the visible list and the pool cannot drift apart: there is one array.
The split, for the curious, is 28 puns, 24 honest self-assessments, 20 food and drink, 18 music and film, and 20 pieces of straight wordplay.
The generator renders nothing on the server, deliberately
Here is the other half of the same curl:
curl -s https://pub-trivia.app/tools/team-name-generator | grep -c "Press the button for six names"
# 1
That is the empty state of the tool, which is what the server sends. The six drawn names are not in the HTML at all, and that is not an oversight.
const DRAW_SIZE = 6
function draw(pool: readonly string[], count: number): string[] {
const remaining = [...pool]
const picked: string[] = []
while (picked.length < count && remaining.length > 0) {
const index = Math.floor(Math.random() * remaining.length)
picked.push(remaining.splice(index, 1)[0])
}
return picked
}
export function TeamNameGenerator() {
const [category, setCategory] = useState<string>("all")
const [names, setNames] = useState<string[]>([])
const pool = category === "all"
? ALL_TEAM_NAMES
: (TEAM_NAME_CATEGORIES.find((c) => c.key === category)?.names ?? ALL_TEAM_NAMES)
const generate = useCallback(() => {
setNames(draw(pool, DRAW_SIZE))
}, [pool])
useEffect(() => {
generate()
}, [generate])
// ...
}
Two decisions in that, both of which are the standard way a "random thing" component goes wrong.
The initial state is an empty array and the first draw happens in an effect. The tempting version is useState(() => draw(pool, 6)), which looks cleaner and is broken. In a server-rendered app that initialiser runs twice, once on the server and once on the client, and Math.random() does not agree with itself across the two. React renders the server's six names, hydrates, finds six different names, and reports a hydration mismatch. Drawing in an effect means the server renders the empty state, the client hydrates that same empty state, and the first draw happens afterwards as a state update, which is exactly what a client-side random pick is.
The draw is without replacement. remaining.splice(index, 1) removes the pick from the local copy, so a draw of six is six different names rather than six independent samples that might repeat. With 110 names in the pool, independent sampling collides more often than people expect: the chance of at least one duplicate in six draws is about 13 percent, which is often enough that somebody would have reported it as a bug. It also means the list items can key on the name itself, since the keys within a draw are unique by construction.
The useCallback with pool in its dependency array gives one more thing for free. Clicking a category changes pool, which changes generate, which re-runs the effect, so picking "Food and drink" immediately draws from the new pool instead of waiting for another button press. That is the behaviour you would have written a second handler for.
What it costs
Nothing on the network. The pool is a string array imported into both the page and the component, so it is part of the same JavaScript the page already ships and part of the same HTML the page already sends. There is no API route, no database and no request made when you press the button. The whole tool is a useState and a function.
Which is the general version of the point: the interactive thing and the readable thing do not have to compete. Render the data as text, then let the widget draw from the same array.
Try it
- The team name generator is free and needs no account. Press "Six more" a few times, then scroll past it and read the whole list that was in the HTML before you did.
- Pub quiz team names is the longer written version, which is a different page because it answers a different question.
- If you disable JavaScript and reload, the list is still there and the tool politely shows its empty state. That is the design, not the fallback.
Running the night that these teams turn up to is the part we charge for, and the free tier needs no card.
Top comments (0)