Implementation Flow
This guide walks through the complete implementation of the SDK flow from end to end. It assumes you have already installed the SDK and initialized it in your application.
┌─────────────────────────────────────────┐│ Create Configs page ││ Browse categories → select a form ││ → manage the config list │└──────────────────┬──────────────────────┘ │ navigate to /config/:id┌──────────────────▼──────────────────────┐│ Config Form page ││ Set currentConfigId → fill in steps ││ → mark config as completed │└──────────────────┬──────────────────────┘ │ navigate to /checkout┌──────────────────▼──────────────────────┐│ Checkout page ││ Review finished configs → fill contact ││ details → submit │└─────────────────────────────────────────┘Phase 1 — Create Configs Page
This page lets the user browse available forms organised by category, create one or more configs, and manage them before moving on.
Browsing categories
The SDK provides two ready-to-use navigation strategies through useFormCategories. Pick the one that fits your UX:
Breadcrumb strategy
The user drills down through a category tree. Forms only appear once shouldShowForms is true (leaf node reached). A back() helper navigates up one level.
const categoriesPath = useFormCategories( (s) => s.strategies.breadcrumbs({ homeText: "Home" }).categoriesPath);const currentCategory = useFormCategories( (s) => s.strategies.breadcrumbs({ homeText: "Home" }).currentCategory);const shouldShowForms = useFormCategories( (s) => s.strategies.breadcrumbs({ homeText: "Home" }).shouldShowForms);const setCurrentCategory = useFormCategories( (s) => s.strategies.breadcrumbs({ homeText: "Home" }).setCurrentCategory);const back = useFormCategories( (s) => s.strategies.breadcrumbs({ homeText: "Home" }).back);
// Render breadcrumb trailcategoriesPath.map((cat) => ( <button key={cat.id} onClick={() => setCurrentCategory(cat)}> {cat.name} </button>));
// Show sub-categories or the form gridif (!shouldShowForms) { currentCategory?.subcategories?.map((sub) => ( <button key={sub.id} onClick={() => setCurrentCategory(sub)}> {sub.name} </button> ));} else { // render <FormGrid />}Filter strategy
All category levels are shown simultaneously as filter chips. The form grid is always visible and filtered by the active selection.
const categoriesPath = useFormCategories( (s) => s.strategies.filter({ allText: "All" }).categoriesPath);const isSelectedCategory = useFormCategories( (s) => s.strategies.filter({ allText: "All" }).isSelectedCategory);const setCurrentCategory = useFormCategories( (s) => s.strategies.filter({ allText: "All" }).setCurrentCategory);
// Render one row of filter chips per levelcategoriesPath.map((level) => level.subcategories?.map((cat) => ( <button key={cat.id} className={isSelectedCategory(cat) ? "active" : ""} onClick={() => setCurrentCategory(cat)} > {cat.name} </button> )));
// Always render <FormGrid /> below the filters| Breadcrumb | Filter | |
|---|---|---|
| Forms visible | Only at leaf category | Always |
| Navigation | Drill-down + back | Flat toggle chips |
| Good for | Deep category trees | Flat or 2-level trees |
Displaying the form grid
useForms gives you the paginated form catalog, automatically filtered by whatever category is active.
const forms = useForms((s) => s.paginatedFormsData);const setSearch = useForms((s) => s.setSearch);const setNextPage = useForms((s) => s.setNextPage);const totalPages = useForms((s) => s.totalPages);const page = useForms((s) => s.page);
if (forms.isLoading) return <p>Loading forms…</p>;
// forms.data?.data is the TForm[] for the current page// setSearch("keyword") resets to page 1 automatically// prefetchForms({ categoryId }) can be called on hover to warm the cacheCreating a config
When the user clicks a form card, call addConfig. It returns the new config’s ID — use it directly for navigation.
const addConfig = useConfigs((s) => s.addConfig);const configsCounter = useConfigs((s) => s.configsCounter);
function onFormClick(form: TForm) { const newConfigId = addConfig({ formId: form.id, formName: form.name, name: `Config ${configsCounter + 1}`, configQuantity: 1, });
// Navigate to the config page to start filling it in navigate(`/config/${newConfigId}`);}Internally, addConfig creates a FormStoreItem (in formSubstore) and a ValuesStoreItem (in valuesSubstore) for the new config ID and immediately starts fetching the form definition.
Managing the config list
The config list is stored in useConfigs. Each TConfig exposes a derived status — no manual calculation needed.
const configs = useConfigs((s) => s.configs);const removeConfig = useConfigs((s) => s.removeConfig);const updateConfig = useConfigs((s) => s.updateConfig);const copyConfig = useConfigs((s) => s.copyConfig);const updateConfigPosition = useConfigs((s) => s.updateConfigPosition);
configs.map((config) => ( <div key={config.id}> <span>{config.name}</span>
{/* Status badge — auto-derived from required elements */} <span>{config.status}</span> {/* "incomplete" | "in-progress" | "completed" */}
{/* Quantity stepper */} <button onClick={() => updateConfig(config.id, { configQuantity: config.configQuantity - 1 })}>-</button> <span>{config.configQuantity}</span> <button onClick={() => updateConfig(config.id, { configQuantity: config.configQuantity + 1 })}>+</button>
{/* Actions */} <button onClick={() => copyConfig(config.id)}>Duplicate</button> <button onClick={() => removeConfig(config.id)}>Remove</button> </div>));
// Reorder: zero-based target indexupdateConfigPosition(config.id, newIndex);missingRequiredElements on each config lists the elements the user still needs to fill in — useful for showing inline validation hints before proceeding.
Phase 2 — Config Form Page
This page renders the form for a single config, one step at a time.
Setting the current config ID
useFormByCurrentConfigId and useValuesByCurrentConfigId always operate on the currently active config. You set it by calling setCurrentConfigId when the page mounts, and clear it on unmount.
import { useEffect } from "react";import { useParams } from "react-router";import { useConfigs } from "../sdk";
export default function ConfigPage() { const setCurrentConfigId = useConfigs((s) => s.setCurrentConfigId); const configId = useParams().configId;
useEffect(() => { if (!configId) { setCurrentConfigId(-1); return; } setCurrentConfigId(Number(configId));
// Clear on unmount so stale data is not used if the user navigates away return () => setCurrentConfigId(-1); }, [configId, setCurrentConfigId]);
// ...rest of the page}-1 is the sentinel value for “no active config”. Once set, all ByCurrentConfigId hooks resolve to the correct config’s data.
Rendering the current step
const currentStep = useFormByCurrentConfigId((s) => s.currentStep);const isLoading = useFormByCurrentConfigId((s) => s.isLoading);const isCurrentStepFinished = useFormByCurrentConfigId((s) => s.isCurrentStepFinished);const isFirstStep = useFormByCurrentConfigId((s) => s.isFirstStep);const isLastStep = useFormByCurrentConfigId((s) => s.isLastStep);const setNextCurrentStep = useFormByCurrentConfigId((s) => s.setNextCurrentStep);const setPreviousCurrentStep = useFormByCurrentConfigId((s) => s.setPreviousCurrentStep);
if (isLoading) return <p>Loading…</p>;if (!currentStep) return null;
// A step is either a TGroup (has an `elements` property) or a TElementconst isGroup = "elements" in currentStep;
return ( <div> {isGroup ? ( <GroupView group={currentStep} /> ) : ( <ElementView element={currentStep} /> )}
<button disabled={isFirstStep} onClick={setPreviousCurrentStep}> Back </button> <button disabled={!isCurrentStepFinished} onClick={setNextCurrentStep}> {isLastStep ? "Finish" : "Next"} </button> </div>);Use isLastStep to swap “Next” for “Finish” or to show a link to the checkout page.
Step sidebar (direct navigation)
Let the user jump to any visible step directly. stepsAvailability[i] is false when a constraint hides step i.
const steps = useFormByCurrentConfigId((s) => s.steps);const currentProgress = useFormByCurrentConfigId((s) => s.currentProgress);const stepsAvailability = useFormByCurrentConfigId((s) => s.stepsAvailability);const setCurrentProgress = useFormByCurrentConfigId((s) => s.setCurrentProgress);
steps?.map((step, i) => step ? ( <button key={step.id} disabled={!stepsAvailability?.[i]} className={currentProgress === i ? "active" : ""} onClick={() => setCurrentProgress?.(i)} > {step.label} </button> ) : null);steps and stepsAvailability are always the same length. An entry in steps can be undefined if a constraint hid it but its position still needs to be occupied to keep the indices aligned.
Handling user input
handleChangeInput is the recommended way to wire up any input. It automatically handles the toggle logic for checkboxes.
const values = useValuesByCurrentConfigId((s) => s.values);const handleChangeInput = useValuesByCurrentConfigId((s) => s.handleChangeInput);
// Select / radio / text / numberhandleChangeInput?.({ type: element.type, elementId: element.id, optionIdOrValue: optionId, // or e.target.value for free-text});
// Checkbox — handleChangeInput toggles the option in/out automaticallyhandleChangeInput?.({ type: "checkbox", elementId: element.id, optionIdOrValue: option.id,});
// Read the current value for an elementconst selected = values?.[element.id] ?? [];For bulk operations (e.g. restoring from a draft), use setElementValue directly:
const setVal = useValuesByCurrentConfigId((s) => s.setElementValue);setVal?.(elementId, ["option-id-1", "option-id-2"]);Showing constraint messages
After every value change the constraint engine runs and may attach messages to elements, groups, or options.
const messages = useFormByCurrentConfigId((s) => s.messages);
// Per-element messagesconst elementMessages = messages.elements[element.id]; // Message[]// Per-group messagesconst groupMessages = messages.groups[group.id];// Per-option messagesconst optionMessages = messages.options[option.id];
// Each Message: { type: "info" | "warning" | "error", message: string }elementMessages?.map((msg) => ( <p key={msg.message} className={msg.type}>{msg.message}</p>));Showing option prices
const resolveOptionPrice = useNumberFormatter((s) => s.getResolveOptionPrice());
// In your option renderer:const priceLabel = resolveOptionPrice?.({ option, element, includedText: "included", // shown when the option price is 0});// Returns a formatted string: "€ 120,00" or "included"Phase 3 — Checkout / Submit Page
This page shows a read-only summary of all completed configs, collects contact details, and sends the request.
Reading finished configs
getFinishedConfigs() returns only configs with status === "completed". Each entry bundles the config metadata, constraint-evaluated steps, selected values, and quantities — everything you need to render a summary.
const finishedConfigs = useFinishedConfigs((s) => s.getFinishedConfigs());
// FinishedConfig shape:// {// config: TConfig,// stepsWithSelections: TStep[], // constraint-evaluated, hidden steps removed// form: TForm | undefined,// values: TValues, // Record<elementId, string[]>// quantities: TQuantities, // Record<elementId, Record<optionId, number>>// selectedProducts: TProduct[],// }
// Quantities are multiplied by config.configQuantity by default.// Pass { skipMultiplyConfigQuantity: true } to get the raw (per-unit) quantities.const rawQuantities = useFinishedConfigs((s) => s.getFinishedConfigs({ skipMultiplyConfigQuantity: true }));Iterate stepsWithSelections to render the summary. A step is either a TGroup ("elements" in step) or a TElement.
Displaying the config price
const getResolvedConfigPrice = useNumberFormatter((s) => s.getResolvedConfigPrice);
const priceLabel = getResolvedConfigPrice({ configId: config.id, multiplyConfigQuantity: true, includedText: "included",});// Returns a formatted string: "€ 2 400,00" or "included"Collecting contact details
Fixed fields (name and email)
const fixedContactDetails = useContactDetails((s) => s.fixedContactDetails);const setFixedContactDetailsValue = useContactDetails((s) => s.setFixedContactDetailsValue);
// fixedContactDetails always has exactly two entries:// [{ id: "name", value: string, error: false | "missing" },// { id: "email", value: string, error: false | "missing" | "invalid-email" }]
fixedContactDetails.map((field) => ( <div key={field.id}> <input value={field.value} onChange={(e) => setFixedContactDetailsValue({ id: field.id, value: e.target.value }) } /> {field.error && <span className="error">{field.error}</span>} </div>));Dynamic request fields
Request fields are loaded from the API and vary per installation.
const requestFieldsData = useContactDetails((s) => s.requestFieldsData);const requestFieldsValues = useContactDetails((s) => s.requestFieldsValues);const requestFieldsErrors = useContactDetails((s) => s.requestFieldsErrors);const setSubFieldValue = useContactDetails((s) => s.setSubFieldValue);
// requestFieldsData: ApiResource<TRequestField[]>// Each TRequestField has an id and a sub_fields array (TSubField[])
if (requestFieldsData.isLoading) return <p>Loading…</p>;
requestFieldsData.data?.map((field) => field.sub_fields.map((subField) => { const value = requestFieldsValues[field.id]?.[subField.id] ?? ""; // Error types: false | "missing" | "invalid-number" | "unchecked-checkbox" const error = requestFieldsErrors[field.id]?.[subField.id];
return ( <div key={subField.id}> <input type={subField.type === "checkbox" ? "checkbox" : "text"} value={value} onChange={(e) => setSubFieldValue({ requestFieldId: field.id, requestSubFieldId: subField.id, value: e.target.value, }) } /> {error && <span className="error">{error}</span>} </div> ); }));Validation runs inline on every change. Submitting with missing required fields triggers validation for all fields at once.
Submitting
const submit = useSubmit((s) => s.submit);const isSubmitEnabled = useSubmit((s) => s.isSubmitEnabled);const isLoading = useSubmit((s) => s.isLoadingSubmit);const error = useSubmit((s) => s.errorSubmit);
async function handleSubmit() { const { ok } = await submit(); if (ok) navigate("/success");}
// isSubmitEnabled is false when:// - any config has status !== "completed"// - fixedContactDetails has validation errors// - any requestFieldsErrors entry is not false<button disabled={!isSubmitEnabled || isLoading} onClick={handleSubmit}> {isLoading ? "Submitting…" : "Submit Request"}</button>{error && <p className="error">{error}</p>}Draft Saving
Users can save their progress and return to it later. Draft saving is only available when the user is authenticated and no form is still loading — check isDraftEnabled before showing the UI.
const isDraftEnabled = useSubmit((s) => s.isDraftEnabled);const isDraftSave = useSubmit((s) => s.isDraftSave); // true = first save, false = updateconst draftName = useSubmit((s) => s.draftName);const savedDraftName = useSubmit((s) => s.savedDraftName); // name from last successful saveconst setDraftName = useSubmit((s) => s.setDraftName);const saveOrUpdateDraft = useSubmit((s) => s.saveOrUpdateDraft);const isLoadingDraft = useSubmit((s) => s.isLoadingDraft);const errorDraft = useSubmit((s) => s.errorDraft);
// Detect unsaved changes to show a "•" indicatorconst hasUnsavedChanges = draftName !== savedDraftName;
async function handleSave() { const { ok, type } = await saveOrUpdateDraft(); // type: "save" (first time) | "update" (subsequent calls)}Loading a draft
Typically done on a dedicated route (e.g. /draft/:draftId):
const openFromDraft = useSubmit((s) => s.openFromDraft);const isLoadingOpenFromDraft = useSubmit((s) => s.isLoadingOpenFromDraft);
useEffect(() => { openFromDraft(Number(draftId)).then(({ ok }) => { if (ok) navigate("/"); });}, [draftId]);Re-using a previous request
Let users copy a previous order as a starting point:
const openFromRequestCopy = useSubmit((s) => s.openFromRequestCopy);
useEffect(() => { openFromRequestCopy(Number(requestId)).then(({ ok }) => { if (ok) navigate("/"); });}, [requestId]);Both openFromDraft and openFromRequestCopy call setupFromPreConfig internally to restore the store state, including pre-filled contact details if the original contained them.
Page Navigation Summary
// 1. User clicks a form card → create config and navigate to itconst newConfigId = addConfig({ formId, formName, name, configQuantity: 1 });navigate(`/config/${newConfigId}`);
// 2. Config page: set current ID on mount, clear on unmountuseEffect(() => { setCurrentConfigId(Number(configId)); return () => setCurrentConfigId(-1);}, [configId]);
// 3. User finishes all steps → show a "Go to checkout" link// (isLastStep is true on the final step)
// 4. Checkout: all completed configs appear in getFinishedConfigs()// User fills contact details → submit()
// 5. Draft page (/draft/:id): call openFromDraft(id) → navigate("/")// 6. Request copy page (/request-copy/:id): call openFromRequestCopy(id) → navigate("/")