quiz-ui
hookquiz-coresource: ok · refreshed
Quiz engine
Headless quiz engine: state machine, branching resolution, and the useQuizEngine/useQuizStep/useQuizAnswers hooks. Installed automatically as a dependency of every component below.
npx shadcn add "https://quiz-ui-phi.vercel.app/r/quiz-core.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files5 · source cached 10/8/2026, 6:16:05 AM
components/ui/quiz/core/types.ts
/**
* Core types for the quiz engine.
*
* These types are intentionally plain data (no functions embedded in the
* serializable shapes) so that a `QuizDefinition` can be written by hand,
* generated by an AI agent, or produced by a future no-code builder, and
* then fed straight into `useQuizEngine` without modification.
*/
export type AnswerValue = string | number | boolean | string[];
/** All answers collected so far, keyed by step id. */
export type QuizAnswers = Record<string, AnswerValue | undefined>;
/**
* A single branching rule evaluated against the current answers.
* Rules for a step are evaluated in array order; the first match wins.
* If no rule matches, the step's `next` fallback is used.
*/
export interface QuizBranchRule {
/** Human-readable label, shown only in devtools/inspection, never at runtime. */
label?: string;
/**
* Answer id to compare (usually the current step's id). Defaults to the
* owning step's id if omitted.
*/
answerId?: string;
/** Match if the answer strictly equals this value. */
equals?: AnswerValue;
/** Match if the answer (array or string) includes this value. */
includes?: string;
/** Id of the step to go to when this rule matches. */
goTo: string;
}
export interface QuizStepDefinition {
/** Unique, stable id. Used as the key into `answers` and for branching. */
id: string;
/** Discriminates which component should render this step. Consumer-defined. */
type: string;
/** Arbitrary props forwarded to the step's component (question text, options, etc). */
props?: Record<string, unknown>;
/** Whether an answer is required to advance. Defaults to true. */
required?: boolean;
/** Branching rules, evaluated in order. */
branch?: QuizBranchRule[];
/**
* Fallback next step id when no branch rule matches (or none are defined).
* Omit on the last step of a linear path.
*/
next?: string;
}
export interface QuizDefinition {
id: string;
title?: string;
/** The id of the first step. */
entry: string;
steps: QuizStepDefinition[];
}
export interface QuizEngineState {
currentStepId: string;
/** Ids of steps visited, in order, for progress calculation and back-navigation. */
history: string[];
answers: QuizAnswers;
status: "in-progress" | "complete";
}
export type QuizEngineAction =
| { type: "ANSWER"; stepId: string; value: AnswerValue }
| { type: "ADVANCE" }
| { type: "BACK" }
| { type: "GO_TO"; stepId: string }
| { type: "RESET" };
components/ui/quiz/core/engine.ts
import type {
AnswerValue,
QuizAnswers,
QuizBranchRule,
QuizDefinition,
QuizEngineAction,
QuizEngineState,
QuizStepDefinition,
} from "./types";
/**
* This is a plain, explicit transition table rather than a general-purpose
* state-machine library (see SDD Section 8: XState vs. hand-rolled reducer).
* Chosen for zero added dependency weight and because the branching model
* needed here — "evaluate rules in order against current answers, first
* match wins, else fall back to `next`" — doesn't need XState's guards,
* actors, or parallel states. If nested/parallel funnels become a real
* requirement later, this module is the only place that needs to change;
* `QuizDefinition` was designed to be a valid input either way.
*/
export function getStep(
definition: QuizDefinition,
stepId: string
): QuizStepDefinition {
const step = definition.steps.find((s) => s.id === stepId);
if (!step) {
throw new Error(
`quiz-ui: no step with id "${stepId}" in quiz "${definition.id}"`
);
}
return step;
}
function ruleMatches(rule: QuizBranchRule, stepId: string, answers: QuizAnswers): boolean {
const answerId = rule.answerId ?? stepId;
const value = answers[answerId];
if (rule.equals !== undefined) {
return value === rule.equals;
}
if (rule.includes !== undefined) {
if (Array.isArray(value)) return value.includes(rule.includes);
if (typeof value === "string") return value.includes(rule.includes);
return false;
}
// A rule with neither `equals` nor `includes` is treated as an
// unconditional match — useful as a catch-all at the end of a rule list.
return true;
}
/** Resolves the next step id for the given step, given current answers. */
export function resolveNext(
step: QuizStepDefinition,
answers: QuizAnswers
): string | undefined {
if (step.branch) {
for (const rule of step.branch) {
if (ruleMatches(rule, step.id, answers)) {
return rule.goTo;
}
}
}
return step.next;
}
export function createInitialState(definition: QuizDefinition): QuizEngineState {
return {
currentStepId: definition.entry,
history: [definition.entry],
answers: {},
status: "in-progress",
};
}
export function isStepAnswered(step: QuizStepDefinition, answers: QuizAnswers): boolean {
if (step.required === false) return true;
const value = answers[step.id];
if (value === undefined) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.trim().length > 0;
return true;
}
/**
* Creates the reducer for a specific quiz definition. Definition is closed
* over rather than passed with every action so the reducer signature stays
* a plain `(state, action) => state`, compatible with `useReducer` as-is.
*/
export function createQuizReducer(definition: QuizDefinition) {
return function quizReducer(
state: QuizEngineState,
action: QuizEngineAction
): QuizEngineState {
switch (action.type) {
case "ANSWER": {
return {
...state,
answers: { ...state.answers, [action.stepId]: action.value },
};
}
case "ADVANCE": {
const currentStep = getStep(definition, state.currentStepId);
const nextId = resolveNext(currentStep, state.answers);
if (!nextId) {
return { ...state, status: "complete" };
}
return {
...state,
currentStepId: nextId,
history: [...state.history, nextId],
};
}
case "BACK": {
if (state.history.length <= 1) return state;
const history = state.history.slice(0, -1);
return {
...state,
history,
currentStepId: history[history.length - 1],
status: "in-progress",
};
}
case "GO_TO": {
getStep(definition, action.stepId); // throws if invalid
return {
...state,
currentStepId: action.stepId,
history: [...state.history, action.stepId],
status: "in-progress",
};
}
case "RESET": {
return createInitialState(definition);
}
default:
return state;
}
};
}
/** Rough progress estimate: steps visited vs. longest path from entry. */
export function estimateProgress(
definition: QuizDefinition,
state: QuizEngineState
): number {
const total = definition.steps.length;
if (total === 0) return 1;
const visited = new Set(state.history).size;
return Math.min(visited / total, 1);
}
export type { AnswerValue };
components/ui/quiz/core/QuizProvider.tsx
"use client";
import * as React from "react";
import type {
AnswerValue,
QuizAnswers,
QuizDefinition,
QuizEngineState,
} from "./types";
import {
createInitialState,
createQuizReducer,
estimateProgress,
getStep,
isStepAnswered,
} from "./engine";
export interface QuizContextValue {
definition: QuizDefinition;
state: QuizEngineState;
/** 0–1 estimate of how far through the funnel the user is. */
progress: number;
currentStep: ReturnType<typeof getStep>;
isCurrentStepAnswered: boolean;
canGoBack: boolean;
answer: (value: AnswerValue) => void;
advance: () => void;
back: () => void;
goTo: (stepId: string) => void;
reset: () => void;
}
const QuizContext = React.createContext<QuizContextValue | null>(null);
export interface QuizProviderProps {
definition: QuizDefinition;
children: React.ReactNode;
/** Called once, when the engine transitions into "complete". */
onComplete?: (answers: QuizAnswers) => void;
}
/**
* Owns a single quiz engine instance for the subtree. This is the only
* component in the library that touches `useReducer` directly — everything
* else (QuizRoot, QuizStep, individual inputs) reads through the hooks in
* `./hooks`, so swapping the underlying state implementation later only
* requires changes here.
*/
export function QuizProvider({ definition, children, onComplete }: QuizProviderProps) {
const reducer = React.useMemo(() => createQuizReducer(definition), [definition]);
const [state, dispatch] = React.useReducer(reducer, definition, createInitialState);
const hasCompletedRef = React.useRef(false);
React.useEffect(() => {
if (state.status === "complete" && !hasCompletedRef.current) {
hasCompletedRef.current = true;
onComplete?.(state.answers);
}
if (state.status === "in-progress") {
hasCompletedRef.current = false;
}
}, [state.status, state.answers, onComplete]);
const currentStep = getStep(definition, state.currentStepId);
const value = React.useMemo<QuizContextValue>(
() => ({
definition,
state,
progress: estimateProgress(definition, state),
currentStep,
isCurrentStepAnswered: isStepAnswered(currentStep, state.answers),
canGoBack: state.history.length > 1,
answer: (value: AnswerValue) =>
dispatch({ type: "ANSWER", stepId: currentStep.id, value }),
advance: () => dispatch({ type: "ADVANCE" }),
back: () => dispatch({ type: "BACK" }),
goTo: (stepId: string) => dispatch({ type: "GO_TO", stepId }),
reset: () => dispatch({ type: "RESET" }),
}),
[definition, state, currentStep]
);
return <QuizContext.Provider value={value}>{children}</QuizContext.Provider>;
}
export function useQuizContext(): QuizContextValue {
const ctx = React.useContext(QuizContext);
if (!ctx) {
throw new Error(
"quiz-ui: this hook must be used inside a <QuizProvider> / <QuizRoot>."
);
}
return ctx;
}
components/ui/quiz/core/hooks.ts
"use client";
import { useQuizContext } from "./QuizProvider";
import type { AnswerValue, QuizAnswers } from "./types";
/**
* Full engine access: current step, answers, progress, and every
* transition action. Prefer the narrower hooks below (`useQuizStep`,
* `useQuizAnswers`) inside individual step/input components — reaching for
* `useQuizEngine` everywhere causes more re-renders than necessary and
* makes a component's actual dependencies harder for a reader (human or
* agent) to see at a glance.
*/
export function useQuizEngine() {
return useQuizContext();
}
/**
* Everything a single step's UI needs: which step is active, whether it's
* answered, and navigation actions. Does not expose other steps' answers.
*/
export function useQuizStep() {
const { currentStep, isCurrentStepAnswered, advance, back, canGoBack, progress } =
useQuizContext();
return { step: currentStep, isAnswered: isCurrentStepAnswered, advance, back, canGoBack, progress };
}
/**
* Read/write access to the answer for the *current* step, plus the full
* answers map for components (like QuizResult) that need to read
* everything collected so far.
*/
export function useQuizAnswers(): {
value: AnswerValue | undefined;
setValue: (value: AnswerValue) => void;
all: QuizAnswers;
} {
const { state, currentStep, answer } = useQuizContext();
return {
value: state.answers[currentStep.id],
setValue: answer,
all: state.answers,
};
}
components/ui/quiz/core/index.ts
export { QuizProvider, useQuizContext } from "./QuizProvider";
export type { QuizContextValue, QuizProviderProps } from "./QuizProvider";
export { useQuizEngine, useQuizStep, useQuizAnswers } from "./hooks";
export {
createInitialState,
createQuizReducer,
estimateProgress,
getStep,
isStepAnswered,
resolveNext,
} from "./engine";
export type {
AnswerValue,
QuizAnswers,
QuizBranchRule,
QuizDefinition,
QuizEngineAction,
QuizEngineState,
QuizStepDefinition,
} from "./types";