I built a 30-question preference quiz for consenting adults. The product constraint was sharper than the topic: answers should not outlive the tab.
No account. No result URL. No localStorage. Refresh or close the tab, and the map is gone.
That sounds like a privacy-policy sentence. It is actually a state-management problem. This is how KinkWeave is wired.
What we refused to do
A lot of quizzes persist by accident:
- Scores encoded in the query string, so the result is a shareable, indexable document
-
localStorage, so a shared laptop keeps last night’s answers - A “resume later” cookie that becomes a user record
- Treating “skip” as
0, so silence looks like dislike We wanted a map someone could look at, copy by choice, and then destroy by closing a tab. The quiz still has to survive in-app navigation. Privacy, Terms, and the result page all need the same answers. A hard reset on every route change would make the product unusable.
One provider, memory only
The app is Next.js App Router. A client provider wraps the tree. Answers live in a reducer. Scoring is a useMemo over that state.
const result = useMemo(() => {
if (!state.completed || !state.questions) return null;
try {
return scoreQuiz(state.questions, state.answers);
} catch {
return null;
}
}, [state.completed, state.questions, state.answers]);
sessionStorage holds one boolean: the 18+ confirmation. It does not hold answers, scores, or a result name.
That split is load-bearing. Age confirmation should last for the browser session, or people get gated again on every internal link. Preference data should not.
If storage is blocked, the quiz errors on the age check and still does not fall back to writing answers somewhere else.
Same-tab routing keeps the provider alive, so Privacy and Terms do not wipe progress. A refresh unmounts the tree. That is the delete button.
Skip is null, not 0
Each question is a 1–5 rating or an explicit skip.
type Answer = 1 | 2 | 3 | 4 | 5 | null;
null means “this item is not in the math.” It is not a low score.
There are six axes and five questions each. An axis only gets a number when at least four answers are scored:
score = round(100 * (sum - n) / (4 * n))
n is the count of scored answers on that axis. Skips are excluded from both sum and n. All 1s map to 0. All 5s map to 100. Fewer than four scored answers
→ score = null, and the result is a partial map. Missing scores are not drawn as zero on the radar.
“Unsure” is the scale midpoint (3) for arithmetic. A row of Unsure answers therefore lands on 50. The UI has to say that out loud, or people read 50 as
moderate interest. The number and the coverage line have to travel together: how many answered, how many skipped, how many Unsure.
If you ship any instrument with an optional item, decide this before you write the first reducer. The bug is not the formula. The bug is letting undefined
collapse into 0 because it is easier to chart.
Two axes, not one slider
Leadership and Surrender are separate. A high score on one does not subtract from the other.
That is a product rule, but it is also a data-model rule. A bipolar “dominant ↔ submissive” scale is simpler to plot and worse to answer. People can want both,
want neither, or want one today and not the other. Independent axes make the radar honest; they also make “highest axis wins” a weak identity label.
We only name a pattern when:
- all six scores exist
- one axis is uniquely highest
- that high score is at least 60
- the spread from min to max is at least 15
Otherwise the result is an Open Sketch. Ties, mild scores, and flat maps should not mint a personality type. The thresholds are editorial, not norms. The copy
has to say that, or the chart will overclaim.
## Sharing without a result document
The obvious “private but shareable” trick is to stuff scores into the URL. Then the link is the database.
We do not do that. The public quiz link opens the introduction, not a stored result. It carries no scores and no tracking parameters.
If someone wants a copy, they choose it: a text note with the summary and axis scores, or an image generated in the browser. Raw answers never go into that
export. Once the note is on the clipboard or a PNG is on disk, it is outside the page. Closing the tab cannot unsay it. The UI has to say that before the copy
button, not in a footer.
## What still leaves the machine
“Nothing is stored” is a lie if you ignore infrastructure.
The browser still requests pages, fonts, and the question bank. That quiz request has no answers. Hosting logs can still show that
/api/questionswas hit. Optional analytics, if the person allows it, measures start / progress / completion with fixed labels — not answers, not scores, not result names. Be precise in the privacy page. “Answers stay in this tab” is true. “The website has no logs” is not.
What I would repeat
-
Put the retention rule in the state shape. If skip is
null, every scorer, chart, and export has to understandnull. - Do not use the URL as a database for sensitive results. Shareable links are just public records with extra steps.
-
Keep session UX and preference data on different storage. Age-gate in
sessionStorage; answers in memory. - Say what a middle score is. Midpoint math plus missing data will otherwise invent a personality. If you want to see the constraints in the UI, the quiz is at kinkweave.com. I am more interested in how other people model “prefer not to answer” than in whether the radar looks nice. How do you keep skip / unknown / unset from turning into zero in your own scoring code?
Top comments (0)