useChallengeState
A React hook for the "user attempts a single goal, possibly multiple times, possibly with hints" pattern that shows up everywhere in lesson UIs, code puzzles, and quiz challenges.
Different from a round-based predict/reveal loop (one prediction per round, many rounds): useChallengeState is one challenge with many attempts. The hook tracks attempt count, runs your check predicate, reveals hints one at a time, fires onSuccess / onFailure exactly once each, and latches into a terminal success / failed state until you call reset.
Pick a number from 1 to 10.
- status
- idle
- attempts
- 0
- attempts remaining
- 3
- hints used
- 0 / 2
Installation
npx shadcn@latest add https://craftbits.dev/r/use-challenge-state.jsonNo external dependencies.
Usage
"use client";
import { useChallengeState } from "@craft-bits/core";
export function GuessTheNumber() {
const challenge = useChallengeState<number>({
check: (n) => n === 7,
maxAttempts: 3,
hints: ["It's odd.", "It's prime."],
});
if (challenge.status === "success") {
return <p>Nice — got it in {challenge.attempts}.</p>;
}
if (challenge.status === "failed") {
return <p>Out of attempts.</p>;
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = Number(new FormData(e.currentTarget).get("g"));
challenge.submit(value);
}}
>
<input name="g" type="number" />
<button type="submit">Guess</button>
<button type="button" onClick={challenge.revealHint}>
Hint ({challenge.hintsUsed} / 2)
</button>
</form>
);
}API
useChallengeState<T>(options: UseChallengeStateOptions<T>): UseChallengeStateResult<T>
UseChallengeStateOptions<T>
| Field | Type | Default | Description |
|---|---|---|---|
check | (attempt: T) => boolean | — | Predicate that decides whether an attempt resolves the challenge. |
maxAttempts | number | unlimited | Cap before the status auto-transitions to failed. Omit for unlimited. |
hints | readonly string[] | [] | Ordered hints revealed one by one via revealHint. |
onSuccess | () => void | — | Fires once when the status transitions to success. |
onFailure | () => void | — | Fires once when the status transitions to failed. |
UseChallengeStateResult<T>
| Field | Type | Description |
|---|---|---|
status | ChallengeStatus | "idle" | "attempting" | "success" | "failed". |
attempts | number | Total submitted attempts so far. |
attemptsRemaining | number | null | Remaining attempts, or null when no maxAttempts is set. |
hintsUsed | number | How many hints have been revealed. |
currentHint | string | null | The next hint to reveal, or null when exhausted. |
submit(attempt) | (attempt: T) => boolean | Submit an attempt. Returns whether it was correct. |
revealHint() | () => void | Advance through hints by one. No-op when exhausted. |
reset() | () => void | Restore the hook to its initial state. |
lastAttempt | T | null | The last value passed to submit, or null before any attempt. |
Status transitions
| From | To | When |
|---|---|---|
idle | attempting | First submit lands and is not immediately terminal. |
idle | success | First submit is correct. |
attempting | success | A submit returns true. Fires onSuccess once. |
attempting | failed | attempts reaches maxAttempts without success. Fires onFailure once. |
| any | idle | reset is called. |
Terminal states are sticky — submit returns false and no-ops once status is success or failed, until reset lands.
Behaviour
- Generic over the attempt type. Pass
useChallengeState<number>for a guess-the-number game,useChallengeState<string>for a typed-answer puzzle,useChallengeState<readonly string[]>for an ordered-words drill. Thecheckpredicate's argument type drivesT. - Callbacks fire exactly once.
onSuccessandonFailureare stored in refs and invoked synchronously when the transition lands. Inline functions are safe — no re-firing across renders. - Hints are immutable. Pass a stable reference; the hook indexes by
hintsUsed.revealHintis a no-op once every hint has been used. resetdoes not consume hints. Callingresetzeroes attempts, status, andlastAttempt, but lets the consumer pass a freshhintsarray if needed.- SSR-safe. No
windowordocumenttouches. The hook's initial render is deterministic.
Examples
Multi-step puzzle with no cap
const c = useChallengeState<string>({
check: (s) => s.trim().toLowerCase() === "depth-first search",
});
// `attemptsRemaining` is `null` — render an unlimited-tries UI.Sound + analytics on success
const { play } = useSound();
const c = useChallengeState<number>({
check: (n) => n === target,
maxAttempts: 5,
onSuccess: () => {
play("success");
track("challenge_solved", { attempts: c.attempts });
},
onFailure: () => play("error"),
});onSuccess / onFailure fire exactly once per challenge cycle (between reset calls), so analytics is safe to call inline without an idempotency guard.
"New puzzle" replay
const [target, setTarget] = useState(() => makeTarget());
const c = useChallengeState<number>({
check: (n) => n === target,
maxAttempts: 3,
});
const replay = () => {
setTarget(makeTarget());
c.reset();
};Props
useChallengeState is a generic hook over the attempt type T. See the Options and Result tables above for the full surface.
Accessibility
useChallengeState is a low-level utility — it has no DOM surface of its own. Two notes for consumers:
- Announce status changes. Render the success / failure feedback inside an
aria-live="polite"region so screen-reader users hear the result without refocusing the input. The bundled demo wiresrole="status"on its feedback paragraph. - Disable controls in terminal state. When
status === "success"orstatus === "failed", disable the submit button (and the input) —submitalready no-ops, but a visibly disabled control makes the latch obvious. Pair with a "new puzzle" / "try again" button that callsresetso keyboard users always have an explicit way to continue.
Credits
- Extracted from:
algoflashcards(src/platform/hooks/state/useChallengeState.ts). The library version generalizes the source's lesson-specific signature (nolessonId/phaseId/ curriculum coupling), adds an explicitresetandlastAttempt, exposesattemptsRemainingas a derived field, and routes callbacks through refs so consumers can pass inline functions without re-firing transitions.