React Component Design
This page describes the standard approach for creating React UI components in
Arda’s ux-prototype repository. The same structural principles apply to React
components in other repositories, omitting the Storybook-specific artifacts
where they are not applicable.
File and Naming Conventions
Section titled “File and Naming Conventions”- File names: kebab-case (
badge.tsx,item-card.tsx), not PascalCase. - Component names: PascalCase with
Ardaprefix (ArdaBadge,ArdaButton). - Directory structure:
src/components/{atoms|molecules|organisms}/{component-name}/ - Artifacts per component:
{name}.tsx— component implementation{name}.stories.tsx— Storybook stories{name}.test.tsx— unit tests{name}.mdx— component documentation
Interface Separation by Lifecycle Phase
Section titled “Interface Separation by Lifecycle Phase”Split component props into distinct TypeScript interfaces based on when they
change. Only define the interfaces that are relevant to the component —
InitConfig and RuntimeConfig are optional for purely presentational
components.
<ComponentName>StaticConfig
Section titled “<ComponentName>StaticConfig”System-level configuration — properties determined by the system design that cannot be changed by user or tenant settings without a code release.
- Structural variations (e.g., input type: text/email/tel)
- System-defined constraints (accepted file types, aspect ratios, size limits)
- Accessibility identifiers (aria-label, role overrides)
- Style variants chosen at design time (CVA variant dimensions)
- Viewport breakpoints, layout modes
These properties are the same for every user, every tenant, every deployment. Changing them requires a code change and a release. They never change during the component’s lifetime, can be heavily memoized, and define the “shape” of the component.
Decision test: “Can a tenant admin or user setting change this value without a code release?” If no → StaticConfig.
<ComponentName>InitConfig
Section titled “<ComponentName>InitConfig”User/tenant configuration — properties determined at mount time by user preferences, tenant settings, or runtime context. They are read once when the component initializes and remain stable for the session.
- Locale, timezone, number/date formatting preferences
- Display names derived from tenant localization (e.g., entity type names)
- Visible columns, column order, field visibility/order in forms
- Preferred renderer for a data type (e.g., checkbox vs. toggle for booleans)
- Feature flags fetched at mount
- Async data-fetching functions (
getInitialX) - Initial values for uncontrolled mode
onMountcallbacks if needed
Different users or tenants may see different values for these properties.
They are typically sourced from a settings context, locale provider, or
tenant configuration API. Include InitConfig when the component has
properties that vary by user/tenant context or has mount-time behavior.
Purely presentational components may only need StaticConfig.
Decision test: “Can a tenant admin or user setting change this value without a code release?” If yes → InitConfig. “Does this change while the component is on screen?” If no (stable for the session) → InitConfig.
<ComponentName>RuntimeConfig
Section titled “<ComponentName>RuntimeConfig”Runtime updates — properties that change during the component’s lifetime based on application state.
- Dynamic state from parent (
required,disabled,loading) - Validation functions
- Controlled values (for the controlled/uncontrolled pattern)
- Event handlers (
onChange,onBlur, etc.) - Server-side validation errors
- Conditional rendering flags
These properties drive re-renders and are tracked in useEffect dependency
arrays. Omit RuntimeConfig for entirely static (display-only) components.
Combined Props Interface
Section titled “Combined Props Interface”interface <ComponentName>Props extends <ComponentName>StaticConfig, <ComponentName>InitConfig, <ComponentName>RuntimeConfig {}Component Implementation Pattern
Section titled “Component Implementation Pattern”export function ComponentName({ // Static staticProp1, staticProp2,
// Init getInitialData,
// Runtime dynamicProp1, validate, onChange,}: ComponentNameProps) { // Init-time state const [initData, setInitData] = useState<Type>(defaultValue);
// Runtime-managed state const [internalState, setInternalState] = useState<Type>(defaultValue);
// Initialization effect: empty deps array — runs once at mount useEffect(() => { const result = getInitialData(); if (result instanceof Promise) { result.then(setInitData); } else { setInitData(result); } }, []);
// Runtime effects: explicit dependency tracking useEffect(() => { // React to runtime prop/state changes }, [dynamicProp1, internalState]);
// Render using all three config types appropriately}Controlled/Uncontrolled Pattern
Section titled “Controlled/Uncontrolled Pattern”const value = controlledValue ?? internalValue;const isControlled = controlledValue !== undefined;Validation Pattern
Section titled “Validation Pattern”- Parent provides validation logic via a function prop.
- The component owns visual rendering state.
- Validation runs in
useEffecton value changes.
Accessibility
Section titled “Accessibility”- Use
useId()for unique DOM IDs. - Include proper ARIA attributes throughout.
- Map validation state to
aria-invalidandaria-describedby.
Storybook Integration
Section titled “Storybook Integration”Meta Configuration
Section titled “Meta Configuration”Every story file must set meta.component to enable auto-generated
Controls in the Storybook Controls panel. Without this, Storybook cannot infer
prop types and the Controls panel will be empty.
For atom gallery stories that follow the Display/Editor/Interactive pattern,
set meta.component to the Interactive component so Controls reflect the
full interactive API.
Organize argTypes using table: { category } to group props by lifecycle
phase: 'Static', 'Initialization', 'Runtime', 'Events'.
Controls must match prop types:
| Prop type | Control |
|---|---|
| Enum/union string | control: 'select' with options: [...] |
| Boolean flag | control: 'boolean' |
| Numeric | control: 'number' |
| String | control: 'text' |
| Object/array | control: 'object' (use sparingly) |
| Callbacks, complex objects | control: false |
import type { Meta, StoryObj } from '@storybook/react';import { fn } from '@storybook/test';import { ComponentName } from './ComponentName';
const meta: Meta<typeof ComponentName> = { component: ComponentName, title: 'Components/<Category>/<ComponentName>', args: { onChange: fn(), // spy for play function assertions }, argTypes: { staticProp: { description: 'Static configuration: [purpose]', control: 'text', table: { category: 'Static' }, }, variant: { description: 'Visual variant of the component.', control: 'select', options: ['primary', 'secondary', 'outline'], table: { category: 'Static' }, }, getInitialData: { control: false, description: 'Initialization-time: [purpose]. Runs once at mount.', table: { category: 'Initialization' }, }, runtimeProp: { description: 'Runtime dynamic: [purpose]. Can change during lifecycle.', table: { category: 'Runtime' }, }, disabled: { description: 'Whether the component is disabled.', control: 'boolean', table: { category: 'Runtime' }, }, onChange: { action: 'changed', table: { category: 'Events' }, }, }, // Do NOT use tags: ['autodocs'] when an MDX doc file exists — it conflicts. parameters: { docs: { description: { component: 'Comprehensive description of component purpose and usage.', }, }, },};
export default meta;type Story = StoryObj<typeof ComponentName>;Required Stories
Section titled “Required Stories”Every component needs at least these story types:
Default state — minimal required props showing default behavior.
Static variations — one story per meaningful static configuration variant.
Runtime state variations — required, disabled, error state, loading, etc:
export const Required: Story = { args: { required: true },};
export const WithValidation: Story = { args: { validate: (value) => ({ isValid: value.length >= 3, message: value.length < 3 ? 'Minimum 3 characters' : undefined, }), },};Async initialization (if InitConfig exists):
export const AsyncInitialization: Story = { args: { getInitialData: async () => { await new Promise(resolve => setTimeout(resolve, 1000)); return mockData; }, },};Interactive scenarios using play functions — at least two per component.
Actions (Event Spies)
Section titled “Actions (Event Spies)”Every event handler prop must be configured with both an action in argTypes
and an fn() spy in meta.args. This serves two purposes:
- Actions panel logging: the
action: 'eventName'entry causes events to appear in the Storybook Actions panel during manual testing. - Play function assertions: the
fn()spy creates a mock function that play functions can assert against.
import { fn } from '@storybook/test';
const meta: Meta<typeof ComponentName> = { component: ComponentName, argTypes: { onClick: { action: 'clicked', table: { category: 'Events' } }, onChange: { action: 'changed', table: { category: 'Events' } }, onBlur: { action: 'blurred', table: { category: 'Events' } }, }, args: { onClick: fn(), onChange: fn(), onBlur: fn(), },};Play Functions
Section titled “Play Functions”Every story file must have at least one story with a play function that
exercises the component using @storybook/test assertions.
import { expect, fn, userEvent, within } from '@storybook/test';
export const ClickTest: Story = { args: { label: 'Submit' }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: 'Submit' }); await userEvent.click(button); await expect(args.onClick).toHaveBeenCalledTimes(1); },};
export const ValidationFlow: Story = { args: { validate: (value) => ({ isValid: value.length >= 3, message: value.length < 3 ? 'Minimum 3 characters' : undefined, }), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const input = canvas.getByRole('textbox');
await userEvent.type(input, 'ab'); await expect(canvas.getByRole('alert')).toBeInTheDocument();
await userEvent.type(input, 'c'); await expect(canvas.queryByRole('alert')).not.toBeInTheDocument(); },};Play function best practices:
- Use
within(canvasElement)to scope queries to the story’s rendered output. - Prefer accessibility-friendly queries (
getByRole,getByText,getByLabelText) overgetByTestId. - Access
argsfrom the play function context to reference spy functions.
Required test scenarios per component:
- Initial render — verify default state
- User interaction — simulate typing, clicking, focusing
- Validation flow — test valid and invalid states
- Async initialization — verify loading and loaded states (if applicable)
- Dynamic prop changes — use
stepto update args and verify re-renders - Accessibility — test focus management, ARIA attributes
- Event assertions — verify event handler props are called with expected arguments
Playground Story Pattern
Section titled “Playground Story Pattern”For atom components that use gallery stories (Display/Editor/Interactive
pattern), gallery stories typically use custom render: functions to showcase
all variants in a single view. These stories do not respond to the Controls
panel because render: ignores args.
To enable interactive Controls exploration, add a Playground story with no
custom render: function:
/** * Interactive playground: use the Controls panel to experiment * with all props. */export const Playground: Story = { args: { label: 'Click me', variant: 'primary', disabled: false, },};The Playground story should be the last exported story in the file and
provide sensible default values for all configurable props. See
button.stories.tsx for the canonical example.
MDX Documentation
Section titled “MDX Documentation”Do not use markdown tables in .mdx files — use HTML <table> elements
instead. Use numeric HTML entities (— not —) and avoid bare
curly braces in text content.
import { Canvas, Meta, ArgTypes, Stories } from '@storybook/blocks';import * as ComponentStories from './ComponentName.stories';
<Meta of={ComponentStories} />
# ComponentName
Brief description of component purpose and primary use cases.
## Configuration Lifecycle
### Static Configuration (Design Time)Properties that define the component's structure and don't change.
### Initialization Configuration (Mount Time)Properties that execute once when the component mounts. Note async behavior.
### Runtime Configuration (Dynamic)Properties that respond to application state changes. Note validation patterns.
## Usage
### Basic Example<Canvas of={ComponentStories.Default} />
### With Validation<Canvas of={ComponentStories.WithValidation} />
## Props<ArgTypes of={ComponentStories} />Delivery Checklist
Section titled “Delivery Checklist”When creating a component, deliver all of the following:
- Component file with separated interface definitions (Static; Init if needed; Runtime if needed)
- Implementation following the controlled/uncontrolled pattern
- Proper accessibility attributes (
useId,aria-invalid,aria-describedby) - Storybook stories file with:
meta.componentsetargTypesfor all configurable props with controls and categoriesfn()spies inmeta.argsfor all event handler props- Default story
- Static variation stories
- Runtime state stories
- Async initialization story (if
InitConfigexists) - At least two interactive play function scenarios
- Playground story (args-only, no render function) for gallery-style atoms
- MDX documentation file
- Proper TypeScript types throughout
- Comments explaining non-obvious design decisions
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”- Mixing lifecycle concerns in a single interface without clear separation.
- Exposing imperative methods via
refunless absolutely necessary (DOM-like operations only). - Managing parent state inside the component — maintain a single source of truth.
- Skipping accessibility attributes.
- Creating stories for interactive behaviors without play functions.
- Omitting the rationale for non-obvious implementation choices.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved