@redis-ui/components
v42.8.0
Published
This guide covers best practices, conventions, and patterns for creating new components in the `@redislabsdev/redis-ui-components` library.
Readme
Creating New Components Guide
This guide covers best practices, conventions, and patterns for creating new components in the @redislabsdev/redis-ui-components library.
Table of Contents
Radix UI Foundation
This component library is built on top of Radix UI, a low-level UI component library that provides unstyled, accessible primitives for building high-quality design systems and web applications.
Note: Not all components need Radix. Simple presentational components (like Button, Badge, Card) can be built from scratch using styled-components.
Note: When using Radix, or any other external library for that matter, make sure you don't expose Radix types in public API. Always wrap them in our own types.
Component Structure
Basic Component Anatomy
Every component should follow this folder structure:
src/ComponentName/
├── ComponentName.tsx # Main component implementation
├── ComponentName.types.ts # Types and interfaces
├── ComponentName.style.ts # Styles
├── ComponentName.test.tsx # Unit tests
├── index.ts # Public exports
└── components/ # Sub-components (if needed)
└── SubComponent/
├── SubComponent.tsx
├── SubComponent.types.ts
└── SubComponent.style.tsThe tree is recurisve, so you can have sub-components inside sub-components, etc.
Example: Simple Component
// Card.tsx
import * as S from './Card.style';
import { CardProps } from './Card.types';
const Card = (props: CardProps) => <S.Card {...props} />;
export default Card;
File Organization
1. Component File (ComponentName.tsx)
✅ DO:
- Use
React.forwardRefwhen the component should allow access to the DOM element underneath- Use
forwardRefWithGenerics, if component has generics params (also works as standardforwardRef)
- Use
- Provide default props for optional parameters
- Import styled components as
* as Sfor namespacing - Keep the main component focused and delegate to sub-components
- Extract complex logic into separate hooks, separate different logic to separate hooks
❌ DON'T:
- Mix business logic with presentation
- Hardcode theme values
2. Types File (ComponentName.types.ts)
Conventions:
- Always use separate file for types
- Extend HTML element attributes when wrapping native elements
- Use generics for flexible, reusable components
- If there are sub-components, create separate types file for each of them and compose the styles at the root level
- Use fully qualified names for exported types
- It is preferable to split component props into
OwnandRestprops, which can simplify property composition and their use in special cases.- Use
Own*Propsprimarily to define actively processed props and props for direct inheritance - Use
Rest*Propsfor other props that you plan to pass as-is to inner components (e.g. HTMLAttributes<>)
- Use
// Button.types.ts
import { ButtonHTMLAttributes } from 'react';
export const buttonSizes = ['large', 'medium', 'small'] as const;
export const buttonVariants = ['primary', 'destructive', 'secondary-fill'] as const;
export type ButtonSizes = (typeof buttonSizes)[number];
export type ButtonVariants = (typeof buttonVariants)[number];
export type OwnButtonProps = {
size?: ButtonSizes;
variant?: ButtonVariants;
disabled?: boolean;
};
export type RestButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
export type ButtonProps = OwnButtonProps & RestButtonProps;3. Style File (ComponentName.style.ts)
✅ DO:
- Access theme values via
useTheme().components.componentName - Use token maps for consistent styling (
styleFromTokens) - Use fully qualified names for exported styled components (simplifies component debugging with React DevTools)
// Button.style.ts
import styled, { css } from 'styled-components/macro';
import { getFocusStyle, useTheme } from '@redislabsdev/redis-ui-styles';
import { ButtonProps } from './Button.types';
export const baseButtonStyle = css`
border: 0;
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
${getFocusStyle()};
&:disabled {
cursor: not-allowed;
}
`;
export const Button = styled.button<Required<Pick<ButtonProps, 'size' | 'variant'>>>`
${baseButtonStyle};
gap: ${() => useTheme().components.button.gap};
padding: ${({ size }) => useTheme().components.button.sizes[size].padding};
background-color: ${({ variant }) => useTheme().components.button.variants[variant].bgColor};
`;❌ DON'T:
- Hardcode colors, spacing, or other design tokens
- Use inline styles
4. Index File (index.ts)
Conventions:
- Export the default component
- Export all types using
export * - Export utility hooks/functions if applicable
- Do not export styled components
Component Composition
Note: It’s best to discuss whether composition is appropriate early on, ideally during the design phase. By the time a PR is under review, changing this decision will involve substantial changes. Feel free to reach out to FE Infra team for advice.
Benefits:
- Allows both simple and advanced usage patterns.
- Note that composed components should also provide simple props for non-advanced usage.
- Provides flexibility for custom layouts
- Maintains clean API surface
- Supports core Design System and allows custom application styling
Object.assign Pattern
For components with sub-components and composition hierarchy, use Object.assign:
// Tabs.tsx
import { TabsProps } from './Tabs.types';
import ContentPane from './components/ContentPane/ContentPane';
import TabBar from './components/TabBar/TabBar';
import Compose from './components/Compose/Compose';
const Tabs = Object.assign(
({ tabs, variant, ...restProps }: TabsProps) => (
<Tabs.Compose {...restProps}>
<Tabs.TabBar tabs={tabs} variant={variant} />
<Tabs.ContentPane tabs={tabs} />
</Tabs.Compose>
),
{ Compose, TabBar, ContentPane }
);
export default Tabs;Benefits:
- The
assign()function works better with TypeScript and code completion than direct assignment.
Context Pattern
For sharing state and props between component and descendants.
Create component contexts in separate file: ComponentName.context.ts(x)
For most cases you can use standard component context generator with first two returned values: context hook and provider
import { createNullableContext } from '/Helpers';
export const [useMyComponentContext, MyComponentContextProvider, MyComponentContext] =
createNullableContext<MyComponentContextType>({
forComponentName: 'MyComponent'
});There is also primitive value state context generator (see usages for details.). It generates 3 things (each of them allows update state):
- Context hook, returning the state
- Context Provider, holding the state
- TransProvider, which acts as the Provider if there is no ascending Provider, or becomes transparent if there is one.
import { createPrimitiveContextState } from '/Helpers';
export const [useFieldStatus, FieldStatusProvider, FieldStatusTransProvider] =
createPrimitiveContextState<FieldStatus>();If none of the generators meets the requirements, a custom context can be created based on the regular React Context. In this case, export the Provider and a hook that retrieves, validates, processes the context values and returns the result.
// Component.context.ts
import { createContext, useContext } from 'react';
export const ComponentContext = createContext<ComponentContextParams>({
variant: 'default',
size: 'medium'
});
export const useComponentParams = (
customParams: Partial<ComponentContextParams> = {}
): ComponentContextParams => {
const context = useContext(ComponentContext);
return customParams ? assignWith({}, context, customParams, skipUndefined) : context;
};✅ DO:
- Use context for deeply nested component communication
- Memoize context values to prevent unnecessary re-renders
- Provide sensible defaults
❌ DON'T:
- Overuse context for simple prop drilling
- Forget to memoize context values
Compose pattern
Start component life from pure composition tree.
<Tabs.Compose value={value} onChange={onChange}>
<Tabs.TabBar.Compose>
{tabs.map(({ value, label, ...rest }) => (
<Tabs.TabBar.Tab key={value} value={value} {...rest}>
{label}
</Tabs.TabBar.Tab>
))}
<Tabs.TabBar.Marker/>
</Tabs.TabBar.Compose>
<Tabs.ContentPane.Compose>
{tabs.map(({ value, content }) => (
<Tabs.ContentPane.Content key={value} value={value}>
{content}
</Tabs.ContentPane.Content>
))}
</Tabs.ContentPane.Compose>
</Tabs.Compose>Each non-leaf node in the tree should be Compose node.
Compose component receives configuration props, defines states, contexts and core logic and optionally adds container, styled with DS.
Leaf components remain pure renderers, reading context and their own props.
Place components in directory tree, where each node component has its own directory and all sub-components are placed in a components directory within the component directory
When composition is ready, wrap each Compose component and sub-components of the level with node component, which exposes simple configuration API
Compose and nested components should be assigned to the node component (and used in composition in it).
const Tabs = Object.assign(
({ tabs, ...restProps }: TabsProps) => (
<Tabs.Compose {...restProps}>
<Tabs.TabBar tabs={tabs} />
<Tabs.ContentPane tabs={tabs} />
</Tabs.Compose>
),
{ Compose, TabBar, ContentPane }
);✅ DO:
- Define context state and logic (policy/behavior/rules) only in
Compose - Keep nested and especially leaf components focused
- Keep node components simple (they should only contain simple composition using
Composeand nested components) - When using Radix library, use their node components in
Composeand their leaf components in leaf - Extract and separate complex logic into separate hooks
❌ DON'T:
- Add logic, contexts, states to node components
- Make leaf components overcomplicated
- Overload composition hierarchy
Benefits:
- Configuration logic doesn’t leak
- Nested components stay focused (“lego bricks”)
- Flexibility
- Each NodeComponent works as configuration entry point
- Each node can be extracted/rearranged at any level safely
- Simple by default, powerful when needed
- node components limit configuration options to what’s most necessary
- if you need more tuning — go to composition
- DS-based by default, restylable via composition
- Consumers can build custom composition nodes that still respect invariants
Form Fields
If you're developing another form component that can be used with the FormField component,
be sure to connect it with the FormField contexts (those that are applicable)
- SharedId
- FieldStatus
- FieldRequired
- FieldDisabled
- FieldReadonly
- FieldAdditionText
Testing
Test File Structure
// Component.test.tsx
import { render, screen } from '@testing-library/react';
import Component from './Component';
describe('Component', () => {
describe('Basic rendering', () => {
it('should render with default props', () => {
render(<Component>Test content</Component>);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
});
describe('Variants', () => {
it('should render primary variant', () => {
render(<Component variant="primary">Primary</Component>);
expect(screen.getByText('Primary')).toBeInTheDocument();
});
});
describe('Composition mode', () => {
it('should render with composition components', () => {
render(
<Component.Compose>
<Component.Header>Header</Component.Header>
<Component.Body>Body</Component.Body>
</Component.Compose>
);
expect(screen.getByText('Header')).toBeInTheDocument();
});
});
});Testing Library Queries
Prefer queries in this order:
getByRole(most accessible)getByLabelTextgetByPlaceholderTextgetByTextgetByTestId(last resort)
You can find more info in Testing Library docs.
Tips & Tricks
1. Transient Props in Styled Components
Use $ prefix for transient props:
// ✅ Good - won't pass $variant to DOM
const Button = styled.button<{ $variant: string }>`
color: ${({ $variant }) => $variant === 'primary' ? 'blue' : 'gray'};
`;
// ❌ Bad - will pass variant to DOM and can cause warnings
const Button = styled.button<{ variant: string }>`
color: ${({ variant }) => variant === 'primary' ? 'blue' : 'gray'};
`;2. Theme Type Safety
Create theme types for your component in packages/styles:
// packages/styles/src/themes/types/theme/components/myComponent.types.ts
export type MyComponentTheme = {
padding: string;
gap: string;
variants: {
primary: { bgColor: string; textColor: string };
secondary: { bgColor: string; textColor: string };
};
};Then add to all theme files (light, dark, light2, dark2).
3. Composition Helpers
Use helper types from Helpers:
import { ComposeElementProps, ChildFree } from '../Helpers';
// ComposeElementProps: Standard props for composition components
// ChildFree: Omits children from props
type MyProps = ChildFree<ComposeElementProps>;4. Storybook Integration
Create stories in docs/stories/ComponentName/:
// ComponentName.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import { ComponentName } from '@redislabsdev/redis-ui-components';
const meta: Meta<typeof ComponentName> = {
component: ComponentName,
title: 'Components/ComponentName'
};
export default meta;
type Story = StoryObj<typeof ComponentName>;
export const Playground: Story = {
args: {
variant: 'primary',
size: 'medium'
}
};Note: For composition components, create a separate
ComponentNameCompose.mdxfile.
