# @stapel/categories-react 0.36.0 Headless React flow pair for stapel-categories (contract >=0.25 <0.26) — business + state, zero visual opinion. Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh, verification-403 interception, i18n engine, analytics facade, TanStack Query layer. ## The one right way (do this, the rest is a review/lint smell) - No raw fetch/axios. The client is injected via /StapelConfigProvider; every hook and flow already carries auth, refresh, and the error envelope. - Render errors, never try/catch them: a flow's state carries FlowError{code,params}; render `t(code, params)` and branch on `explainCategoriesError(code)` remediation. - Server state = the use* hooks (query layer); keys come only from categoriesQueryKeys. - User strings = i18n keys (registerCategoriesI18n); never string literals. - Sign-in UI = a headless flow component; copy it (shadcn-style) to restyle. ## Layers api (typed client) · model (hooks, session) · flows (machines) · headless · i18n ## Documented flows (flows.json — canonical id, steps, endpoints) ## Operations (typed; use the named op, never a path string) Request/response schema names are in manifest.json + the generated types. Paths are relative to `/categories/api/v1/`. - categories_api_v1_categories_bulk_add_create: POST /categories/bulk_add/ - categories_api_v1_categories_bulk_commands_create: POST /categories/bulk-commands/ - categories_api_v1_categories_by_slug_retrieve: GET /categories/by-slug/{slug}/ - categories_api_v1_categories_carousel_list: GET /categories/carousel/ - categories_api_v1_categories_children_list: GET /categories/{id}/children/ - categories_api_v1_categories_create: POST /categories/ - categories_api_v1_categories_data.json_list: GET /categories/data.json/ - categories_api_v1_categories_deleted_children_list: GET /categories/{id}/deleted-children/ - categories_api_v1_categories_destroy: DELETE /categories/{id}/ - categories_api_v1_categories_feature_editor_apply_create: POST /categories/{id}/feature-editor/apply/ - categories_api_v1_categories_feature_editor_draft_create: POST /categories/{id}/feature-editor/draft/ - categories_api_v1_categories_feature_editor_retrieve: GET /categories/{id}/feature-editor/ - categories_api_v1_categories_features_list: GET /categories/{id}/features/ - categories_api_v1_categories_links_create: POST /categories/{id}/links/ - categories_api_v1_categories_links_destroy: DELETE /categories/{id}/links/{target_pk}/ - categories_api_v1_categories_links_list: GET /categories/{id}/links/ - categories_api_v1_categories_list: GET /categories/ - categories_api_v1_categories_partial_update: PATCH /categories/{id}/ - categories_api_v1_categories_retrieve: GET /categories/{id}/ - categories_api_v1_categories_revision_retrieve: GET /categories/revision/ - categories_api_v1_categories_roots_list: GET /categories/roots/ - categories_api_v1_categories_undelete_create: POST /categories/{id}/undelete/ - categories_api_v1_categories_update: PUT /categories/{id}/ - categories_api_v1_categories_validate_configs_retrieve: GET /categories/{id}/validate-configs/ - categories_api_v1_categories_validate_dto_create: POST /categories/{id}/validate-dto/ - categories_api_v1_features_bulk_add_create: POST /features/bulk_add/ - categories_api_v1_features_convert_type_create: POST /features/{id}/convert-type/ - categories_api_v1_features_create: POST /features/ - categories_api_v1_features_data.json_list: GET /features/data.json/ - categories_api_v1_features_destroy: DELETE /features/{id}/ - categories_api_v1_features_list: GET /features/ - categories_api_v1_features_partial_update: PATCH /features/{id}/ - categories_api_v1_features_retrieve: GET /features/{id}/ - categories_api_v1_features_revision_retrieve: GET /features/revision/ - categories_api_v1_features_update: PUT /features/{id}/ - categories_api_v1_tree_list: GET /tree/ - collect_translation_keys: GET /categories/translation-keys/ ## Query hooks (server state; keys come only from the key factory) - useCategoriesRevision (query) → revision : UseQueryResult - useCategory (query) → retrieve : UseQueryResult - useCategoryCarousel (query) → carousel : UseQueryResult - useCategoryCatalog (query) → — : UseQueryResult - useCategoryChildren (query) → children : UseQueryResult - useCategoryFeatures (query) → features : UseQueryResult - useCategoryTree (query) → tree : UseQueryResult ## Other hooks (call shape; a REQUIRED option missing is a type error) - useCategoryLevels(parentIds: readonly (number | null)[], options?: CategoryBrowseOptions — 1 optional) → CategoryFanOut - useCategoryRows(ids: readonly number[], options?: {…} — 2 optional) → CategoryFanOut - useCategorySearch(query: string, options?: UseCategorySearchOptions — 3 optional) → readonly CategorySearchHit[] - useCategoryCascade(options?: UseCategoryCascadeOptions — 7 optional) → CategoryCascadeBag ## Errors (render t(code, params); UX from remediation) 64 keys (full catalog: manifest.json §errors). By remediation: fix_input 37 · retry 14 · verify 5 · wait_and_retry 5 · contact_support 2 · reauthenticate 1. Param-bearing keys (interpolation slots matter): - error.400.categories_duplicate_slug [400] → fix_input {slug} - error.400.categories_feature_editor_invalid [400] → fix_input {reason} - error.400.description_too_long [400] → fix_input {max_length} - error.400.description_too_short [400] → fix_input {min_length} - error.400.feature_above_maximum [400] → fix_input {feature} - error.400.feature_below_minimum [400] → fix_input {feature} - error.400.feature_invalid_config [400] → fix_input {feature} - error.400.feature_invalid_format [400] → fix_input {feature} - error.400.feature_invalid_rules [400] → fix_input {feature} - error.400.feature_invalid_type [400] → fix_input {feature} - error.400.feature_mandatory_missing [400] → fix_input {feature} - error.400.feature_not_allowed [400] → fix_input {feature} - error.400.feature_not_in_options [400] → fix_input {feature} - error.400.feature_unknown [400] → fix_input {feature} - error.400.feature_unknown_type [400] → fix_input {feature} - error.400.field.blank [400] → fix_input {field} - error.400.field.does_not_exist [400] → fix_input {field} - error.400.field.invalid [400] → fix_input {field} - error.400.field.invalid_choice [400] → fix_input {field} - error.400.field.max_length [400] → fix_input {field,max_length} - error.400.field.max_value [400] → fix_input {field,max_value} - error.400.field.min_length [400] → fix_input {field,min_length} - error.400.field.min_value [400] → fix_input {field,min_value} - error.400.field.null [400] → fix_input {field} - error.400.field.required [400] → fix_input {field} - error.400.field.unique [400] → fix_input {field} - error.404.categories_slug_not_found [404] → retry {slug} - error.409.categories_feature_editor_conflict [409] → fix_input {expected,actual} - error.429.rate_limit [429] → wait_and_retry {retry_after_minutes} ## Analytics events (typed; defineEvent → events.json, drift-gated) - (no app defineEvent() in this pair — its analytic events are the auto-instrumented flow funnels below) ## Flow funnels (auto-instrumented: flow.. {phase}) ```tsx // Typed event + tracked() click (the one right way; §3.1). const planSelected = defineEvent({ name: "pricing.plan.selected", description: "User picked a plan", props: { plan: prop.oneOf(["free", "pro", "team"], "Plan code") }, }); const { tracked } = useTracked();