shadimage
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";

More from quiz-ui

Browse all components in this library →