Skip to main content

File structure

A component can be structured in many ways. The most common way is to have a folder for each component, and inside that folder, have the component file, the interface file, and the styling file, the test file, and any other files related to that component.

The Component file

A component is a basic function that returns a React element. The modern approach is to use a function component, which is a simple JavaScript function that returns a React element.

File: Button.tsx

import type { ButtonInterface } from './Button.interface'
const Button = ({ label, onClick, type }: ButtonInterface) => {
return (
<div onClick={onClick}
data-button-type={ type && 'primary' }
className={'Button'}>
{label}
</div>
)
}

export default Button

Using "private" functions

If the component has some complex logic, it can be useful to define some "private" functions inside the component file. These functions are not exported and are only used within the component. This helps to keep the component file organized and makes it easier to read.

I usually tend to prefix these functions with a double underscore (__) to indicate that they are private and not meant to be used outside of the component. This is just a convention, but it helps to make it clear that these functions are not part of the public API of the component.

For example, analyze the code presented below:

import type { ButtonInterface } from './Button.interface'
import { ButtonSizes, ButtonTypes, IconSizes, LabelTypes } from '@/components/_types_'
import { Icon, Label } from '@/components/atoms'

const Button = ({ testID, type, size, icon, text, action, disabled }: ButtonInterface) => {

const __handleAction = () => action && action()
const __renderIcon = () => icon && <Icon icon={icon} />
const __renderText = () => text && <Label text={text} type={LabelTypes.BUTTON} />
const __renderNext = () => {
if (type === ButtonTypes.NEXT) {
return <Icon icon={'arrowRight'} size={IconSizes.LARGE} />
}
}

return (
<div
data-testid={testID}
data-object-type={type ?? ButtonTypes.PRIMARY}
data-object-size={size ?? ButtonSizes.MEDIUM}
data-disabled={disabled ? 'true' : 'false'}
className={`Button`}
onClick={() => __handleAction()}
>
{__renderIcon()}
{__renderText()}
{__renderNext()}
</div>
)
}

export default Button

Can you explain what happens here? Explained code

The Interface file

The Interface file is used to define the props that the component will receive. This is the contract of the component, and it helps to ensure that the component is used correctly.

File: Button.interface.tsx

export interface ButtonInterface {
label: string;
type?: 'primary' | 'secondary';
onClick: () => void;
}

The Styling

Styling can be done in many ways, but the most common way is to use CSS or SCSS files. Modern styling approaches include CSS Modules, Styled Components, or Tailwind CSS. Although I like the declarative way of Tailwind CSS, I don't like the way how it clutters up the component code. I prefer to use SCSS for its flexibility and familiarity. And it is of course up to you to decide which one you prefer. The most important thing is to be consistent in your project.

info

When using a Sass/SCSS compiler is a common practice to prefix the styling files with an underscore (_) to indicate that they are partials. This means that they are not meant to be compiled on their own, but rather imported into other SCSS files. This helps to keep the file structure organized and makes it clear which files are meant to be used as styles for components.

File: _Button.style.scss

.button {
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;

&[data-button-type="primary"] {
background-color: blue;
}

&[data-button-type="secondary"] {
background-color: gray;
}

&:hover {
background-color: darkblue;
}
}

When using Tailwind CSS, you can combine Tailwind and SCSS to make a more readable and maintainable Component code. See this example:

.Button {
@apply outline-none border-none
py-2 px-3 gap-2 hover:opacity-80
rounded-md flex items-center justify-center
object-contain;

/// Tailwind has no alternative
width: fit-content;
block-size: fit-content;

& > span {
@apply text-white;
}
}

Which will compile fine to CSS and is still fully Tailwind compatible. This way, you can use Tailwind for the common styles and SCSS for the more specific styles that Tailwind doesn't cover.

The Mock file

The Mock file is used to define mock data for the component. This is useful for testing and storybook, as it allows you to easily create different scenarios for the component.

File: Button.mock.ts

import type { ButtonInterface } from './Button.interface'

export const ButtonMock: ButtonInterface = {
label: 'Click me',
onClick: () => alert('Button clicked!')
}

export const ButtonPrimaryMock: ButtonInterface = {
...ButtonMock,
type: 'primary',
}

export const ButtonSecondaryMock: ButtonInterface = {
...ButtonMock,
type: 'secondary'
}

The Test file

The Test file is used to define unit tests for the component. This is useful for ensuring that the component works as expected and for catching any bugs early on.

File: Button.test.tsx

import { describe, it, expect } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
// import { ButtonInterface } from './Button.interface'
import Button from './Button'
import { ButtonMock, ButtonPrimaryMock } from './Button.mock'
const testID = 'Button-' + Math.floor(Math.random() * 90000) + 10000

describe('Button', () => {

it('Can render Button', () => {
const rendered = renderToStaticMarkup(<Button testID={testID} {...ButtonMock} />)
expect(rendered).toContain(`data-testid="${testID}"`)
})

it('Can render a Primary Button', () => {
const rendered = renderToStaticMarkup(<Button testID={testID} {...ButtonPrimaryMock} />)
expect(rendered).toContain(`data-testid="${testID}"`)
})
})

The Storybook file

The Storybook file is used to define stories for the component. This is useful for demonstrating the component in isolation and for creating a living style guide for the project.

File: Button.stories.tsx

import { type Meta, type StoryObj } from '@storybook/react-vite'
import ButtonSrc from './Button'
// import type { ButtonInterface } from './Button.interface'
import { ButtonMock, ButtonPrimaryMock } from './Button.mock'
import { ButtonSizes, ButtonTypes } from '@/components/_types_'

const ButtonMeta: Meta<typeof ButtonSrc> = {
title: 'atoms/Button',
component: ButtonSrc,
argTypes: {
testID: { table: { disable: true } },
disabled: { control: 'boolean' },
type: {
control: 'select',
options: Object.values(ButtonTypes)
},
size: {
control: 'select',
options: Object.values(ButtonSizes)
}
}
}

type Story = StoryObj<typeof ButtonSrc>
export const Button: Story = {args: { ...ButtonMock }}
export const ButtonPrimary: Story = {args: { ...ButtonPrimaryMock }}

The Barrel files

The Barrel files are used to export the component and its related files for easy imports. This is useful for keeping the import statements clean and for avoiding long relative paths.

File: index.tsx

export { default as Button } from './Button'

File: _index.scss

@use './_Button.style';