Skip to content

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:

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 trail
categoriesPath.map((cat) => (
<button key={cat.id} onClick={() => setCurrentCategory(cat)}>
{cat.name}
</button>
));
// Show sub-categories or the form grid
if (!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 level
categoriesPath.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
BreadcrumbFilter
Forms visibleOnly at leaf categoryAlways
NavigationDrill-down + backFlat toggle chips
Good forDeep category treesFlat 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 cache

Creating 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 index
updateConfigPosition(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 TElement
const 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 / number
handleChangeInput?.({
type: element.type,
elementId: element.id,
optionIdOrValue: optionId, // or e.target.value for free-text
});
// Checkbox — handleChangeInput toggles the option in/out automatically
handleChangeInput?.({
type: "checkbox",
elementId: element.id,
optionIdOrValue: option.id,
});
// Read the current value for an element
const 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 messages
const elementMessages = messages.elements[element.id]; // Message[]
// Per-group messages
const groupMessages = messages.groups[group.id];
// Per-option messages
const 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 = update
const draftName = useSubmit((s) => s.draftName);
const savedDraftName = useSubmit((s) => s.savedDraftName); // name from last successful save
const 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 "•" indicator
const 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.


// 1. User clicks a form card → create config and navigate to it
const newConfigId = addConfig({ formId, formName, name, configQuantity: 1 });
navigate(`/config/${newConfigId}`);
// 2. Config page: set current ID on mount, clear on unmount
useEffect(() => {
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("/")