| name | client-componentization |
| description | Use when adding, changing, extracting, or reusing client components, including Storybook stories and controls. |
Generated from ai/registry.json. Do not edit manually.
Canonical skill: ../../../ai/skills/client-componentization.md.
Referenced context:
../../../ai/rules/client-component-rules.md
../../../ai/rules/client-state-rules.md
../../../ai/rules/client-form-rules.md
../../../ai/architecture/client-app.md
../../../ai/examples/good-client-component.md
../../../ai/examples/good-client-form.md
This file is compiled from canonical AI knowledge files. Edit canonical files under ai, then run npm run ai:sync.
Compiled AI Skill: client-componentization
Canonical Skill: ai/skills/client-componentization.md
Client Componentization
Use this skill when adding, changing, extracting, or reusing React components, repeated UI, large components, route-level screens, or form-heavy UI in apps/client.
Goal
Split UI by real ownership and reuse without creating a broad component library or hiding state in god hooks/providers.
Read First
ai/rules/client-component-rules.md
ai/rules/client-state-rules.md
ai/rules/client-form-rules.md
ai/architecture/client-app.md
ai/examples/good-client-component.md
ai/examples/good-client-form.md
Workflow
- Identify whether the change is shared UI, page-specific UI, form behavior, or state ownership cleanup.
- Keep one-off UI inline unless extraction improves reuse, naming, or state boundaries.
- Move shared primitives to
src/components, page-specific pieces under the owning page folder, and layout-specific pieces under the owning layout folder.
- Prefer small child components and focused hooks over a single large route component.
- Reuse existing form and visual primitives before adding new ones.
- Add or update the matching Storybook story for every changed component.
- Place Storybook stories under the owning
stories/ child folder that matches the component's source area.
- Expose Storybook controls or actions for every public prop explicitly declared by the component.
- Reuse shared Storybook mock data and route parameters from
src/stories for route, layout, and page stories.
Expected Output
- Route components read as composition.
- Props remain explicit and small.
- Server state remains in React Query hooks.
- Mutable UI state stays local, nearest-owner, or in focused hooks.
- Component stories document normal, empty, disabled, permission-limited, and error states when those states exist.
- Component stories follow the current
stories/ folder layout instead of loose sibling *.stories.tsx files.
- Storybook
args and argTypes stay in sync with declared component props.
Verification
- Ensure extracted components do not change behavior.
- Run
npm --workspace @capture-flag/client run storybook:build after Storybook, component, or story changes.
- Run
npm --workspace @capture-flag/client run build.
Referenced Context
Reference: ai/rules/client-component-rules.md
Client Component Rules
Rules for React component boundaries in apps/client.
Always
- Extract components when UI repeats or a named component makes screen composition clearer.
- Keep shared client components in
src/components.
- Keep context-independent utilities and reusable hooks in
src/core/<category>/<name>.ts, with one exported function or hook per file.
- Keep route layouts that wrap nested routes in
src/layouts/<LayoutName>.
- Keep route-level screens in
src/pages.
- Keep page-specific components and hooks under
src/pages/<PageName> when they are not shared outside that page.
- Import
src/core utilities and hooks from their direct alias file path such as @core/json/formatJson; do not add index.ts barrels under src/core.
- Import shared components directly through aliases such as
@components/Button; do not assume a central src/components/index.ts barrel exists.
- Keep new or substantially changed React component files in
apps/client at or below 400 lines; when touching larger existing files, prefer splitting real UI responsibilities instead of expanding them further.
- Keep component props small and explicit.
- Prefer
children for layout wrappers such as cards, shells, and empty states.
- Prefer explicit JSX over array-driven rendering for a small, fixed set of known UI items.
- Extract custom hooks for repeated or stateful UI behavior.
- Turn repeated form field label/control/hint/error markup into small primitives before copying it again.
- Shared form controls must accept native props, extra
className, aria-invalid, and ref.
- Add or update Storybook stories when adding or changing reusable, layout, page-specific, or route-level React components in
apps/client.
- Keep Storybook stories in a
stories/ child folder next to the component folder they cover, using *.stories.tsx; route/panel grouping stories belong in the owning route folder's stories/ folder.
- Add Storybook controls or actions for every public prop explicitly declared by the component; use controls for data props and actions for callbacks.
- Keep shared component stories in
src/components/stories and member component stories in src/components/members/stories.
- Keep layout stories in
src/layouts/<LayoutName>/stories.
- Keep page or page-section stories in
src/pages/<PageName>/stories or src/pages/<PageName>/<section>/stories.
- Keep cross-page route or panel grouping stories in
src/pages/stories.
- Keep Storybook fixtures, route constants, and API mocks in
src/stories; do not put component stories there.
Never
- Do not extract one-off UI when it adds indirection without reuse, naming clarity, or state-boundary value.
- Do not turn every extraction into a broad component library.
- Do not move page, domain, route, or API-specific helpers into
src/core.
- Do not let route components become god components.
- Do not fix a god component by moving all state and effects into a god provider or god hook.
- Do not build artificial arrays just to render a handful of fixed, known navigation or action items.
- Do not use React Context for mutable UI state.
- Do not copy fetched React Query data into component state just to pass it down.
- Do not leave component prop changes without matching Storybook
args and argTypes updates.
- Do not place
*.stories.tsx beside component files when a nearby stories/ folder is available.
Data-Driven Rendering
- Use arrays and
.map() when rendering API data, dynamic collections, long repeated groups, or lists whose members are not all known at author time.
- Render items directly when the UI is a short, fixed set of known product actions or navigation entries.
- Split large files by ownership such as layout shell, sidebar, selectors, form, list, detail, and helper hooks; do not hide a large component behind a single large hook.
Core Utilities
- Use
src/core/date, src/core/json, src/core/strings, src/core/validation, or src/core/hooks only for helpers that are independent of Capture Flag domain context.
- Keep tests for each core helper in
src/core/<category>/__tests__/<name>.test.ts.
- Prefer direct alias imports such as
@core/json/formatJson over barrels or grouped core imports.
Storybook Layout
- Use one
*.stories.tsx file per component or cohesive route grouping.
- Use shared mock entities and route strings from
src/stories/mockData.ts when stories need realistic Capture Flag data.
- Use the Storybook API mock installed by
.storybook/preview.tsx for route and panel stories that call client API hooks.
- Set route-specific
parameters.router.initialEntries when a story depends on React Router params.
- Prefer
parameters.layout = "fullscreen" for route, layout, shell, and panel stories that need app-width context.
Verification
- Ensure extracted components do not change behavior.
- Check the related Storybook story was added or updated and exposes controls/actions for every public declared prop.
- Run
npm --workspace @capture-flag/client run storybook:build after Storybook, component, or story changes.
- Run
npm --workspace @capture-flag/client run build after component moves.
Reference: ai/rules/client-state-rules.md
Client State Rules
Rules for state ownership in apps/client.
Always
- Use React Query as the source of truth for server state.
- Let mutation hooks own cache invalidation with
queryClient.invalidateQueries.
- Use local component state for short-lived UI state owned by one component.
- Lift state only to the nearest common owner that explicitly consumes it.
- Use React Router params or search params for linkable, reload-safe, navigation state.
- Keep selection state as IDs, not duplicated entity objects.
- Reconcile selected IDs against current query data in a colocated hook.
- Use
useRouteContext to derive selected organization, project, config, and environment from route params, search params, and React Query data.
- Use search params for route-owned selection that should survive reloads or shared links, such as SDK key config/environment and audit-log project scope.
- Use focused ID-selection hooks such as
useCollectionSelection for page-local collection selection that does not need to be linkable.
- Move repeated, context-independent client hooks to
src/core/hooks/<hook>.ts and import them directly from that file.
- Keep state reset rules near the state owner.
- Keep permission gates derived from route-context roles and shared permission helpers; client permission gates are UX only.
Never
- Do not manually patch copied server arrays in components unless optimistic UI is explicitly required.
- Do not copy React Query data into Zustand or local state just to pass it to children.
- Do not import query keys into components; invalidation belongs in API mutation hooks.
- Do not use React Context for mutable state. Context is only for stable values that do not change during app lifetime.
- Do not add Zustand unless state is truly global, cross-feature, or cross-route and other ownership options are insufficient.
- Do not use Zustand as an event bus for mutation results.
Ownership Order
- React Query hooks under
src/api/<domain> for server/cache state.
- React Router params or search params for route/navigation state.
useRouteContext for selected route resources derived from navigation state plus server data.
- Nearest common page component plus focused hooks for page workflow state.
- Local
useState or React Hook Form state for component-only state.
src/core/hooks/<hook>.ts only for repeated hooks that are independent of page/domain context.
- Small domain-specific Zustand store only for cross-route client state with no server backing.
- React Context only for stable constants or immutable services.
Reference: ai/rules/client-form-rules.md
Client Form Rules
Rules for forms in apps/client.
Always
- Use
react-hook-form for mutation forms and forms with meaningful client validation.
- Use
zod for form schemas when a form submits data to API mutations or has reusable validation rules.
- Connect schemas with
zodResolver from @hookform/resolvers/zod when using React Hook Form with Zod.
- Use
defaultValues for every registered React Hook Form field.
- Use
noValidate on forms so Zod owns validation messages.
- Keep schemas close to the form unless reused by multiple forms.
- Trim string values before sending them to API mutations.
- Omit optional empty string values from mutation payloads instead of sending
"".
- Use
aria-invalid on invalid fields.
- Display field errors next to the field that owns them.
- Use small local field wrappers such as
FormField in complex forms when they reduce repeated label/control/error markup.
- Lightweight filter/search forms may use local state and narrow manual validation when they only update local query filters and do not submit API mutations.
- Do not ask users to type UUIDs for human UI flows; use email, search, selects, route context, or an already selected entity instead.
Never
- Do not parse
FormData manually in React components when React Hook Form or controlled state owns the form.
- Do not rely on browser
required validation for app-level messages.
- Do not keep server errors in React Hook Form field state unless they map to a specific field.
- Do not duplicate parsing, schema, and payload normalization across features when a colocated helper is clearer.
- Do not send empty optional metadata fields when creating flags.
- Do not expose raw UUID entry fields in forms for organizations, projects, configs, environments, members, flags, segments, or audit filters.
Boundaries
- React Hook Form owns field state and field validation.
- Zod owns client-side parsing and validation messages.
- React Query mutation hooks own API calls and cache invalidation.
- Server errors should remain visible from mutation state unless mapped intentionally.
- Local state may own draft/applied filter values when a form only changes query filters and the validation is small and colocated.
Reference: ai/architecture/client-app.md
Client App Architecture
apps/client is a Vite React application for the Capture Flag platform UI.
Entry And Routing
src/main.tsx owns top-level providers.
src/router.tsx owns React Router route definitions.
- Route modules are lazy-loaded through the local
lazyRoute() helper and should expose named exports.
src/layouts contains route layout wrappers that render shared shells, navigation, headers, and nested <Outlet /> regions.
src/pages contains route-level screens. Multi-file route screens use folder modules with index.ts named exports; simple one-file screens may stay as direct page files.
src/components contains shared UI used by multiple pages or sections.
src/core contains context-independent client utilities and reusable hooks organized by category.
src/api contains client request functions, React Query hooks, operation barrels, domain barrels, and domain query keys.
src/routing contains route path and route context helpers shared by pages and layouts.
src/stories contains shared Storybook fixtures and API mocks, not component stories.
src/test contains shared Vitest and Testing Library helpers.
PlatformLayout owns the authenticated shell, navigation frame, sidebar/header state, logout flow, and nested route outlet.
- Selected organization, project, config, and environment state is derived by route helpers such as
useRouteContext, not stored in a mutable layout context.
Route Map
/: redirects to /organizations.
/login: GitHub login screen.
/account: authenticated user account details and display name editing.
/organizations and /organizations/:organizationId: organization selection and organization members.
/organizations/:organizationId/projects and /organizations/:organizationId/projects/:projectId: project selection and project members.
/organizations/:organizationId/projects/:projectId/environments: environments for the selected project.
/organizations/:organizationId/projects/:projectId/configs and /organizations/:organizationId/projects/:projectId/configs/:configId: configs and public Config JSON preview.
/organizations/:organizationId/projects/:projectId/configs/:configId/flags: feature flags and remote config values.
/organizations/:organizationId/projects/:projectId/configs/:configId/segments: reusable targeting segments.
/organizations/:organizationId/projects/:projectId/sdk-keys: SDK key lifecycle for project configs/environments, with selected config/environment in ?configId= and ?environmentId= when needed.
/organizations/:organizationId/audit-logs: organization/project audit log timeline, with selected project in ?projectId= when needed.
*: redirects to /.
Data Flow
- React Query owns server state such as authenticated user, organizations, projects, configs, environments, SDK keys, members, and feature flags.
- API operations live under
src/api/<domain>/<operation>.
- Request functions perform HTTP calls and contain no React imports.
- Query and mutation hooks are the UI-facing API.
- Query hooks may use
useQuery or useInfiniteQuery according to the API shape.
- Mutation hooks invalidate affected query keys.
- Mutations that affect derived server state may invalidate query keys from multiple API domains inside the mutation hook.
- API request and hook tests mock successful responses and API errors instead of reaching a real backend.
- Route params, search params, and server state are combined by
useRouteContext for selected resources and redirect-safe navigation paths.
- Permission gates in the client are UX only; API guards and services remain authoritative.
UI Composition
- Route components compose page sections and own screen-level flow.
- Repeated panels, forms, controls, lists, and empty states move into named components.
- React component files should stay at or below 400 lines by splitting real responsibilities into focused files.
- Short, fixed navigation or action sets should be rendered explicitly instead of through artificial arrays.
- Page-specific components stay colocated under the page folder until reused elsewhere.
- Layout-specific components stay colocated under
src/layouts/<LayoutName> until reused by another layout or page.
- Shared primitives live under
src/components and are imported directly through aliases such as @components/Button.
- Member management uses shared
components/members primitives with page-specific role options.
- Feature flag and segment page internals stay colocated under their page folders until reused.
- Storybook stories live in a
stories/ child folder beside the source area they cover.
- Route-level and cross-page grouping stories live in
src/pages/stories.
- Shared Storybook data and fetch mocks live in
src/stories and are reused by stories and page tests.
Shared Core Utilities
- Context-independent helpers and reusable client hooks live under
src/core/<category>/<name>.ts.
- Current core categories include
date, json, strings, validation, and hooks.
- Each core file exports one function or hook; import it from the direct file path such as
src/core/date/toDate.
- Do not add
index.ts barrels under src/core; multiple helpers require multiple explicit imports.
- Core tests live under
src/core/<category>/__tests__/<name>.test.ts next to the category they cover.
- Client tests live in
__tests__/ child folders beside the source area they cover, mirroring Storybook stories/ folders.
- Shared test setup and helpers live under
src/test and provide Testing Library render helpers, React Query providers, and fetch response mocks.
- Coverage is run with
npm --workspace @capture-flag/client run test:coverage and should stay at 90% or higher for configured client targets.
- Page, domain, API, or route-specific helpers stay colocated with their owning feature until they become context-independent reuse.
Client Test And Story Layout
- API request and hook tests live in
src/api/__tests__ and cover operation behavior through mocked fetch responses.
- Shared component tests live in
src/components/__tests__; member component tests live in src/components/members/__tests__.
- Page tests live in
src/pages/<PageName>/__tests__ when they cover one page folder.
- Page subsection tests live in
src/pages/<PageName>/<section>/__tests__ when they cover colocated page internals such as feature flags or segments.
- Root-level client tests live in
src/__tests__ only for source files owned directly by src, such as permissions.ts.
- Shared component stories live in
src/components/stories; member component stories live in src/components/members/stories.
- Layout stories live in
src/layouts/<LayoutName>/stories.
- Page and page subsection stories live in the owning page folder's
stories/ child folder.
- Cross-page route or panel grouping stories live in
src/pages/stories and use route parameters plus shared mock data to render realistic page states.
- Do not place component stories in
src/stories; that folder is reserved for shared Storybook data, routes, and API mocks.
Form Flow
- React Hook Form owns field state and submission.
- Zod schemas parse and validate form values.
- API mutation hooks submit normalized payloads and refresh server state.
Reference: ai/examples/good-client-component.md
Good Client Component
Source: apps/client/src/components/Button.tsx (sha256: 7e1bcb1c45c5d1a5610f108be5028ce68d34ded2c394d326428eca08702a992d)
Source: apps/client/src/components/Panel.tsx (sha256: c0dfcd9d6984e9741af7abe91c60ad8bcb30e4d8f18c3c1456bdea5882d2472f)
Why this is canonical:
- Accepts native element props instead of inventing a custom prop surface.
- Keeps variants explicit and local to the primitive.
- Uses
classnames as cls for optional classes.
Canonical shared component patterns from apps/client/src/components.
Button Primitive
import cls from "classnames";
import type { ComponentPropsWithoutRef } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
const baseButtonClassName =
"inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium shadow-xs outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
const buttonClassNames: Record<ButtonVariant, string> = {
danger:
"border border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20",
ghost:
"border border-transparent bg-transparent text-foreground shadow-none hover:bg-accent hover:text-accent-foreground",
primary: "border border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
};
type ButtonProps = ComponentPropsWithoutRef<"button"> & {
variant?: ButtonVariant;
};
export function Button({ className, variant = "primary", ...props }: ButtonProps) {
return (
<button className={cls(baseButtonClassName, buttonClassNames[variant], className)} {...props} />
);
}
This accepts native button props, keeps variants explicit, and uses cls for optional classes.
Panel Wrapper
export function Panel({ children, className, showTitle = true, title, wide = false }: PanelProps) {
return (
<section
className={cls("grid gap-4 text-foreground", className, {
"lg:col-span-2": wide,
})}
>
{showTitle ? <h2 className="text-xl font-semibold tracking-tight">{title}</h2> : null}
{children}
</section>
);
}
This keeps layout composition explicit through children, accepts optional native composition through className, and preserves product visual language.
Reference: ai/examples/good-client-form.md
Good Client Form
Source: apps/client/src/components/CreateNameForm.tsx (sha256: e60e8092b9d41bd7a2b5136ff048d6b5455abe40c796386dea079369b823e1e3)
Source: apps/client/src/pages/FlagsPage/featureFlags/CreateFeatureFlagForm.tsx (sha256: a7d1e86cdb9b5082b43efe4959ecae33eb6eeda011e2a78d8df8fc7ed8a76b02)
Why this is canonical:
- Keeps schema, default values, field errors, and submission in the owning form.
- Uses React Hook Form with
zodResolver and noValidate.
- Shows field errors beside the field that owns them.
- Uses a local
FormField wrapper in complex forms to reduce repeated label/control/error markup.
Canonical form pattern from apps/client/src/components/CreateNameForm.tsx.
const createNameFormSchema = z.object({
name: z.string().trim().min(1, "Enter a name.").max(120, "Use up to 120 characters."),
});
type CreateNameFormValues = z.infer<typeof createNameFormSchema>;
const {
formState: { errors, isSubmitting },
handleSubmit,
register,
reset,
} = useForm<CreateNameFormValues>({
defaultValues: {
name: "",
},
resolver: zodResolver(createNameFormSchema),
});
<form
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
noValidate
onSubmit={handleSubmit(submit)}
>
<div>
<TextInput
aria-invalid={errors.name ? true : undefined}
disabled={isDisabled}
placeholder={placeholder}
{...register("name")}
/>
<FieldError>{errors.name?.message}</FieldError>
</div>
<Button className="justify-self-start" disabled={isDisabled} type="submit">
Create
</Button>
</form>
This pattern keeps schema, default values, noValidate, field errors, and submit reset close to the owning form.
Local Field Wrapper For Complex Forms
<FormField error={errors.key?.message} label="SDK key" required htmlFor={keyId}>
<TextInput
aria-invalid={errors.key ? true : undefined}
autoComplete="off"
disabled={isDisabled}
id={keyId}
placeholder="newCheckout"
{...register("key")}
/>
</FormField>
function FormField({ children, error, htmlFor, label, required = false }: FormFieldProps) {
return (
<div className="grid gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={htmlFor}>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</label>
{children}
<FieldError>{error}</FieldError>
</div>
);
}
This pattern is local to the owning form and avoids promoting page-specific field structure into shared components too early.