Anatomy of a React component

Anatomy of a React component

103

8 min.

Anatomy of a React Component

Most teams have an idea of what a "proper" component should look like, but it usually lives in heads and code reviews, not in writing. Here I will try to pin down one specific component structure in full — from the folder it lives in to the export line.

Folder and Props File

A component occupies its own folder with two files:

product-card/
  product-card.tsx
  product-card.props.ts

Prop types live separately from the implementation, and there are two reasons for that.

The first is size: a composite Props built from Pick, intersections and query types easily grows to dozens of lines and starts to overshadow the component itself when kept in the same file. The second is imports: the component's contract is needed not only by the component itself, it gets pulled in by tests, wrappers and the props of parent components, and all of them only need the lightweight type file, without the implementation file and its dependencies.

The type itself is simply called Props, the file name already contains the component name, so ProductCardProps inside product-card.props.ts would add nothing.

import type { Props } from './product-card.props';

This naming has a downside too: if you open several props files side by side, you cannot tell them apart by the type name alone — you have to look at the tab, and when importing several Props into one file the names collide, which is solved by renaming via asimport type { Props as CardProps } from ....

Props Are Assembled, Not Written

A props type almost never consists of fields invented from scratch, it is usually glued together from several sources — native HTML attributes and types generated from the API schema.

type Props = Pick<ComponentProps<'a'>, 'className'> &
  Pick<ComponentProps<'img'>, 'fetchPriority'> & {
    product: ProductQuery['products'][number];
    onSelect?: ((id: string) => void) | undefined;
  };

Pick<ComponentProps<'a'>, 'className'> instead of className?: string looks bulky, but it saves you from manually repeating types that are already described in React, and fields taken from the generated query type change together with the schema — if the backend renames a field, the compiler will show every place affected.

A separate detail is the ((id: string) => void) | undefined notation instead of a plain ?, it is required with exactOptionalPropertyTypes enabled, the flag distinguishes "the field is absent" from "the field is present but equals undefined", and the second case constantly arises when props are passed through via spread. Without the explicit | undefined such calls do not compile, and it is easier to write it longer once than to scatter conditional spreads across every call site.

And the main rule, which matters more than the syntax: similar components should have their own narrow types — if a product card, an article card and a review card differ in their data, that is three components with three Props, not one component with a type where half the fields are optional. A type where rating?, author? and videoUrl? sit next to each other with no connection no longer describes a contract — it describes the union of all cases. This is essentially the Interface Segregation Principle from SOLID applied to props — a component should not declare fields that only one of its scenarios needs.

Order Inside the Body

Imports at the top and the component declaration right after — nothing special here, the interesting part starts inside the body. It has a fixed order of blocks too, thanks to which a specific kind of logic is found in a known place rather than by reading the whole file.

const Dropdown = (props: Props) => {
  // 1. props destructuring
  const { onChange, options, value } = props;

  // 2. react hooks
  const [isOpen, setIsOpen] = useState(false);

  const listRef = useRef<HTMLUListElement>(null);

  // 3. variables
  const selected = options.find((option) => option.value === value);
  const displayValue = selected?.label ?? 'Select';

  // 4. functions
  const handleSelect = (nextValue: string) => {
    onChange(nextValue);
    setIsOpen(false);
  };

  // 5. useEffect and useLayoutEffect — last, right before return
  useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = () => setIsOpen(false);

    document.addEventListener('click', handleClickOutside);

    return () => document.removeEventListener('click', handleClickOutside);
  }, [isOpen]);

  return (/* ... */);
};

export { Dropdown };

Destructuring → hooks → variables → functions → effects. The order is not arbitrary, each following block may rely on the previous ones, but not the other way around: a variable uses a hook, a function uses a variable and a hook, an effect uses everything above it.

The reverse order would not even work in JS (const cannot be used before its declaration), but a shuffled order works just fine, and it is exactly the shuffled order that most unreadable components have, where you have to jump up and down the file to understand the dependencies of an effect in the middle of it.

useEffect and useLayoutEffect form a separate, last block instead of sitting with the other hooks at the top, because effects are usually tied to what is declared further down the body — to handlers and computed variables, and reading an effect before the things it references is inconvenient.

Variables from the third block are everything that is computed from props or state and gets a name before JSX.

const isHighPriority = fetchPriority === 'high';
const isOutOfStock = product.stock === 0;

The goal is for the markup below to read as a list of decisions already made, not as the place where they are made, the condition product.stock === 0 inside JSX forces the reader to parse the logic on the spot, isOutOfStock does not.

Variants — Through an Object, Not Through if

When a component has visual variants, the temptation is to hang conditions all over the markup, but it is better to describe the variants with a table — it does not grow the Cognitive Complexity, no matter how many variants get added.

const VARIANT_CONFIG = {
  primary: 'bg-accent text-white rounded-md',
  outline: 'border border-accent text-accent',
  ghost: 'text-black',
} satisfies Record<NonNullable<Props['variant']>, string>;

The table is declared at module level, between the imports and the component, not inside its body, because inside the component the object would be recreated on every render, while at module level it is created once when the file loads.

Everything that does not depend on props or state goes there as well.

What remains in JSX is VARIANT_CONFIG[variant] — the single point where the variant is mentioned at all.

The satisfies at the end does two things. First, it checks completeness — if a new value is added to the variant type and a row in the object is forgotten, the code will not compile. Second, unlike the annotation const CONFIG: Record<...> = {...}, it does not erase the literal types of the values, they stay concrete strings rather than an abstract string.

Such a table is the Open/Closed Principle in action, a new variant is added as a row in the object, the existing markup does not change. The opposite example is a component with flags like isCompact, isFeatured, isInline scattered across the body, where every piece of JSX checks them in different combinations, such a component cannot be changed locally, editing one variant cuts through the markup of all the others.

Shared Code Is Extracted With Slots

When several components share a piece of markup, you want to extract it, what matters is how — the shared component must not know about its consumers, so slots are used instead of flags.

const CardCover = (props: Props) => {
  const { badge, image, overlay } = props;

  return (
    <div className='relative aspect-square'>
      {overlay}
      {image}
      {badge}
    </div>
  );
};

One consumer puts an "out of stock" label into badge, another puts a view counter, a third passes nothing. CardCover knows nothing about inventory or views, does not grow when new consumers appear, and it keeps a single reason to change — the layout of the cover itself.

The criterion for extraction is strict though, what gets extracted is what matches literally, not what "looks similar". Two pieces of markup that are similar in shape but evolve for different reasons start getting in each other's way after being merged, and half a year later the shared component hosts a tangle of conditions — the very thing the extraction was meant to eliminate.

The server/client boundary in Next.js belongs here too, 'use client' goes on the specific file that needs events or state, in a pair of a presentational wrapper and an interactive link the directive sits only on the latter, the former keeps rendering on the server.

Styles

Classes are assembled with the cn utility (a wrapper around clsx and tailwind-merge), each condition is passed as a separate argument rather than glued into one string with template literals.

className={cn(
  'flex flex-col rounded-md bg-white',
  isOutOfStock && 'opacity-60',
  className,
)}

The external className goes last, that way tailwind-merge lets the calling side override the component's styles rather than only append to them.

Export at the End

The component is declared with const, the export is a separate line at the end of the file, always named.

const ProductCard = (props: Props) => {
  // ...
};

export { ProductCard };

A named export instead of default is needed so that the component name is identical at every import site and survives automatic refactoring, and the export at the end, rather than in the declaration, so that the public surface of the file is visible in one place.

Both conventions are small, and the benefit of each on its own is minor, the point emerges from them being followed everywhere.

Why All This

None of the points above is new, what is notable is something else — together they cover half of SOLID: narrow props instead of one fat type — interface segregation, the variant table — open/closed, slots with no knowledge of consumers — single responsibility.

Except it is achieved not with class hierarchies, but with discipline of form.

When every file is structured the same way — props separate, destructuring first, effects before return, export at the end — in a project of fifty components you stop reading the code and start recognizing it, open the file, spot the layer you need, fix it.

The shape of a component becomes as predictable as its behavior, and that is what saves time in the long run.

Similar categories:

Similar articles

  • SSF-U - Single Standard for Fullscreen

    The SSF-U standard defines requirements for the semantics, accessibility, and logic of fullscreen

    33

    2 min.

  • SSA - Single Standard for Accordion

    The SSA standard defines requirements for the semantics, accessibility, and logic of accordion

    50

    2 min.

  • Bad Practices for Websites

    An Analysis of Critical Web Design Mistakes. Why Sliders, Autoplay, and Slow-Loading Pages Reduce Conversion Rates and Rankings on Google and Yandex

    46

    2 min.

  • SSP - Single Standard for Pagination

    The SSP standard defines requirements for the semantics, accessibility, and logic of pagination

    56

    1 min.

  • SSPS - Single Standard for Project Structure

    The SSPS standard defines requirements for the structure and naming of files and folders in a project

    165

    2 min.

  • SSG - Single Standard for Git

    The SSG standard defines requirements for the semantics, accessibility, and logic of Git

    48

    3 min.

  • All articles

Contact me