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
Customize
Challenge
3
2

Installation

npx shadcn@latest add https://craftbits.dev/r/use-challenge-state.json

No 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>

FieldTypeDefaultDescription
check(attempt: T) => booleanPredicate that decides whether an attempt resolves the challenge.
maxAttemptsnumberunlimitedCap before the status auto-transitions to failed. Omit for unlimited.
hintsreadonly string[][]Ordered hints revealed one by one via revealHint.
onSuccess() => voidFires once when the status transitions to success.
onFailure() => voidFires once when the status transitions to failed.

UseChallengeStateResult<T>

FieldTypeDescription
statusChallengeStatus"idle" | "attempting" | "success" | "failed".
attemptsnumberTotal submitted attempts so far.
attemptsRemainingnumber | nullRemaining attempts, or null when no maxAttempts is set.
hintsUsednumberHow many hints have been revealed.
currentHintstring | nullThe next hint to reveal, or null when exhausted.
submit(attempt)(attempt: T) => booleanSubmit an attempt. Returns whether it was correct.
revealHint()() => voidAdvance through hints by one. No-op when exhausted.
reset()() => voidRestore the hook to its initial state.
lastAttemptT | nullThe last value passed to submit, or null before any attempt.

Status transitions

FromToWhen
idleattemptingFirst submit lands and is not immediately terminal.
idlesuccessFirst submit is correct.
attemptingsuccessA submit returns true. Fires onSuccess once.
attemptingfailedattempts reaches maxAttempts without success. Fires onFailure once.
anyidlereset is called.

Terminal states are sticky — submit returns false and no-ops once status is success or failed, until reset lands.

Behaviour

  1. 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. The check predicate's argument type drives T.
  2. Callbacks fire exactly once. onSuccess and onFailure are stored in refs and invoked synchronously when the transition lands. Inline functions are safe — no re-firing across renders.
  3. Hints are immutable. Pass a stable reference; the hook indexes by hintsUsed. revealHint is a no-op once every hint has been used.
  4. reset does not consume hints. Calling reset zeroes attempts, status, and lastAttempt, but lets the consumer pass a fresh hints array if needed.
  5. SSR-safe. No window or document touches. 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 wires role="status" on its feedback paragraph.
  • Disable controls in terminal state. When status === "success" or status === "failed", disable the submit button (and the input) — submit already no-ops, but a visibly disabled control makes the latch obvious. Pair with a "new puzzle" / "try again" button that calls reset so 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 (no lessonId / phaseId / curriculum coupling), adds an explicit reset and lastAttempt, exposes attemptsRemaining as a derived field, and routes callbacks through refs so consumers can pass inline functions without re-firing transitions.