Skip to content

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 names: kebab-case (badge.tsx, item-card.tsx), not PascalCase.
  • Component names: PascalCase with Arda prefix (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

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.

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.

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
  • onMount callbacks 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.

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.

interface <ComponentName>Props extends
<ComponentName>StaticConfig,
<ComponentName>InitConfig,
<ComponentName>RuntimeConfig {}
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
}
const value = controlledValue ?? internalValue;
const isControlled = controlledValue !== undefined;
  • Parent provides validation logic via a function prop.
  • The component owns visual rendering state.
  • Validation runs in useEffect on value changes.
  • Use useId() for unique DOM IDs.
  • Include proper ARIA attributes throughout.
  • Map validation state to aria-invalid and aria-describedby.

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 typeControl
Enum/union stringcontrol: 'select' with options: [...]
Boolean flagcontrol: 'boolean'
Numericcontrol: 'number'
Stringcontrol: 'text'
Object/arraycontrol: 'object' (use sparingly)
Callbacks, complex objectscontrol: 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>;

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.

Every event handler prop must be configured with both an action in argTypes and an fn() spy in meta.args. This serves two purposes:

  1. Actions panel logging: the action: 'eventName' entry causes events to appear in the Storybook Actions panel during manual testing.
  2. 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(),
},
};

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) over getByTestId.
  • Access args from the play function context to reference spy functions.

Required test scenarios per component:

  1. Initial render — verify default state
  2. User interaction — simulate typing, clicking, focusing
  3. Validation flow — test valid and invalid states
  4. Async initialization — verify loading and loaded states (if applicable)
  5. Dynamic prop changes — use step to update args and verify re-renders
  6. Accessibility — test focus management, ARIA attributes
  7. Event assertions — verify event handler props are called with expected arguments

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.

Do not use markdown tables in .mdx files — use HTML <table> elements instead. Use numeric HTML entities (&#8212; not &mdash;) 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} />

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.component set
    • argTypes for all configurable props with controls and categories
    • fn() spies in meta.args for all event handler props
    • Default story
    • Static variation stories
    • Runtime state stories
    • Async initialization story (if InitConfig exists)
    • 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
  1. Mixing lifecycle concerns in a single interface without clear separation.
  2. Exposing imperative methods via ref unless absolutely necessary (DOM-like operations only).
  3. Managing parent state inside the component — maintain a single source of truth.
  4. Skipping accessibility attributes.
  5. Creating stories for interactive behaviors without play functions.
  6. Omitting the rationale for non-obvious implementation choices.

Copyright: (c) Arda Systems 2025-2026, All rights reserved