Headless SDK: challenges
Wire a multi-step challenge into your own UI in under 50 lines. The SDK handles state, polling, optimistic updates, and reward claim. You write the markup.
Key takeaways
Quick read- useChallenge returns the active challenge state, objective progress, and completion handlers.
- Objective state is polled every 15 seconds. Use submitEvent to update optimistically when you control the action.
- Completion fires automatically server-side. Listen with onCompleted for the handoff (claim reward, route to success).
- Always render the full path from the start. Hidden objectives kill completion rate.
- Use useChallenge inside a single screen. For shared dashboards, use useChallenges (plural) with a filter.
Quickstart
A working challenge in 40 lines
"use client";
import { useChallenge } from "@bricqs/headless-react";
export function OnboardingChallenge() {
const {
challenge,
objectives,
isCompleted,
progressPercent,
isLoading,
error,
onCompleted,
} = useChallenge({ challengeId: "onboarding_v3" });
// Side-effect handler for the completion moment
onCompleted((reward) => {
// route to success, claim reward, fire analytics
window.location.href = `/welcome/done?reward=${reward.id}`;
});
if (isLoading) return <div className="animate-pulse h-12" />;
if (error) return <p>Could not load the challenge.</p>;
return (
<section>
<header className="flex items-baseline justify-between mb-4">
<h2 className="font-bold">{challenge.title}</h2>
<span className="text-sm text-slate-500">{progressPercent}% done</span>
</header>
<ol className="space-y-2">
{objectives.map((o) => (
<li
key={o.id}
className={`flex items-center gap-3 ${o.isComplete ? "opacity-60" : ""}`}
>
<span>{o.isComplete ? "✓" : "○"}</span>
<span>{o.label}</span>
{!o.isComplete && o.cta && (
<a className="ml-auto text-blue-600" href={o.cta.href}>
{o.cta.label}
</a>
)}
</li>
))}
</ol>
{isCompleted && (
<p className="mt-4 text-green-700 font-semibold">Challenge complete</p>
)}
</section>
);
}Anatomy
What the hook returns
Three concepts cover almost every UI you will build.
API
GET /challenges/:id/state returns objectives, progress, and reward. The hook polls this every 15 seconds.
SDK
useChallenge({ challengeId }) gives you the same shape, plus loading state, completion callback, and submitEvent for optimistic updates.
User sees
A live progress bar, a checklist that advances as the user acts, and a completion moment that hands off to your reward UI.
Optimistic updates
Update the UI before the server confirms
Polling is fine for slow-changing screens. For active flows where the user just did the action, submit the event from the client and let the SDK reconcile.
"use client";
import { useChallenge } from "@bricqs/headless-react";
export function CompleteProfileStep() {
const { submitEvent } = useChallenge({ challengeId: "onboarding_v3" });
async function onSave(profile: ProfileForm) {
await saveProfileToYourBackend(profile);
// Optimistically advance the challenge.
// The SDK reconciles with the server on the next poll.
submitEvent({
eventType: "profile_completed",
attributes: { has_avatar: !!profile.avatar },
idempotencyKey: `profile_completed:${profile.userId}`,
});
}
return <ProfileForm onSubmit={onSave} />;
}submitEvent fires through the same pipeline as a server-side POST. Idempotency keys prevent double-credit if the user clicks twice.
Multiple challenges
Listing active challenges
On dashboards, render every active challenge with one hook.
"use client";
import { useChallenges } from "@bricqs/headless-react";
export function ChallengeDashboard() {
const { challenges, isLoading } = useChallenges({
status: "active",
limit: 5,
});
if (isLoading) return <Skeleton count={3} />;
if (challenges.length === 0) return <Empty />;
return (
<ul className="grid gap-4">
{challenges.map((c) => (
<li key={c.id} className="rounded-xl border p-5">
<h3 className="font-bold mb-1">{c.title}</h3>
<p className="text-sm text-slate-500 mb-3">{c.deadlineLabel}</p>
<ProgressBar percent={c.progressPercent} />
</li>
))}
</ul>
);
}Completion handoff
Handle the success moment cleanly
onCompleted(async (event) => {
// event = {
// challengeId,
// completedAt,
// reward: { id, type, value, claim_url? },
// facts: [...]
// }
// 1. Show your celebration UI
setShowConfetti(true);
// 2. Auto-claim the reward (or route to a claim screen)
if (event.reward.type === "coupon") {
const code = await claimReward(event.reward.id);
setCouponCode(code);
}
// 3. Fire analytics
analytics.track("challenge_completed", {
challenge_id: event.challengeId,
reward_id: event.reward.id,
});
});The completion event fires once per challenge per participant. The SDK guarantees this even on poll retries.
Common mistakes
What goes wrong
Calling submitEvent without an idempotency key. Double-clicks double-credit the objective.
Always pass idempotencyKey. Use a deterministic value (participant + action + period).
Hiding objectives that are not yet active. The user feels lost.
Render every objective from the start, marked as upcoming. Hidden steps tank completion rate.
Listening to onCompleted in a component that re-renders. Side-effects fire repeatedly.
Register onCompleted once at the top of the screen, or use the imperative client.on('challenge.completed') API.
Using useChallenge for non-challenge state (points, tier).
Use the dedicated hooks: usePoints, useTier, useStreak. They are smaller, faster, and shaped for their purpose.
Polling unnecessarily on screens where the user is unlikely to act.
Pass pollInterval={0} or unmount the hook when off-screen. The default 15-second poll is fine for active surfaces only.
Developer FAQ
Common questions when integrating gamification with Bricqs.
Ready to ship?
Wire it up with the Bricqs SDK or API
Headless SDK for React UIs, REST API for any backend. Same engine behind both.
