Skip to content
fearchitect
Architecture & Composition

Component Architecture & Project Structure

Structuring components so composition beats configuration.

By Abas TurabliReviewed

Summary

Good component architecture separates concerns along the right seams: behavior from markup, state from display, and shared logic from specific usage. The patterns — compound components, headless components, polymorphic `as` props, container/presentational — all serve one goal: keep each unit small enough to reason about and replaceable without ripple effects.

Jump to the interview angle

Composition over configuration means a component exposes slots and children, not a growing prop list, so callers control structure rather than encoding every variant as a prop.

Compound components (e.g. <Select>/<Select.Option>) share state implicitly via context — the parent owns state, children consume it. No prop drilling and no rigid single-element API.

Headless components (or hooks) own behavior and state but render nothing; the caller owns the markup. Radix UI is headless by default.

Polymorphic components accept an as prop so callers decide the rendered element (button, a, router Link) while keeping styles and behavior. The trick: generic over ElementType so prop types stay in sync.

Composition patterns in practice

  • Compound components put shared state in React context; the parent creates it, children read it — no prop threading.
  • Headless components are hooks returning state and handlers; the caller spreads them onto any markup with zero styling opinions.
  • The safe polymorphic TS pattern: `C extends ElementType` merged with `ComponentPropsWithoutRef<C>` keeps native props (href, type, aria-*) typed.
  • Feature-based colocation beats layer-based past ~20 components — deleting a feature is one folder removal.
  • A prop crossing 3+ layers untouched belongs in context; split contexts or memo consumers to avoid broad rerenders.

Compound component using React 19 `use(Context)`

The parent <Select> creates a context and owns the value; <Select.Option> reads it via React 19's use(Ctx) instead of useContext. No prop drilling; callers compose children freely.

Compound component with shared contexttsx
import { createContext, use, useState } from "react";

interface SelectCtx {
  value: string;
  onChange: (v: string) => void;
}

const Ctx = createContext<SelectCtx | null>(null);

function Select({
  defaultValue = "",
  children,
}: {
  defaultValue?: string;
  children: React.ReactNode;
}) {
  const [value, onChange] = useState(defaultValue);
  return <Ctx value={{ value, onChange }}>{children}</Ctx>;
}

function Option({ value, label }: { value: string; label: string }) {
  const ctx = use(Ctx)!;
  return (
    <button
      aria-pressed={ctx.value === value}
      onClick={() => ctx.onChange(value)}
    >
      {label}
    </button>
  );
}

Select.Option = Option;
export { Select };

Parent holds state in context; Select.Option reads it via React 19's use(Ctx). No prop drilling; callers compose freely.

Type-safe polymorphic `as` prop

Generic C extends ElementType drives prop inference. When as="a", TypeScript requires href; when omitted, the default "button" applies. Omit<ComponentPropsWithoutRef<C>, "as"> prevents the as key from conflicting.

Type-safe polymorphic componenttsx
import type { ComponentPropsWithoutRef, ElementType } from "react";

type ButtonProps<C extends ElementType = "button"> = {
  as?: C;
  variant?: "primary" | "ghost";
} & Omit<ComponentPropsWithoutRef<C>, "as">;

export function Button<C extends ElementType = "button">({
  as,
  variant = "primary",
  ...rest
}: ButtonProps<C>) {
  const Tag = as ?? "button";
  return (
    <Tag
      className={`btn btn--${variant}`}
      {...rest}
    />
  );
}

// Usage — TypeScript enforces href only on the anchor variant:
// <Button as="a" href="/home">Home</Button>
// <Button onClick={fn}>Submit</Button>

ComponentPropsWithoutRef<C> ensures href only appears when as="a". The generic defaults to "button" so plain usage stays ergonomic.

Tradeoffs

Pros

  • Compound components eliminate prop explosion; callers control structure.
  • Headless components are independently testable and style-agnostic.
  • Polymorphic `as` prop covers button, anchor, and router link with full TS safety.
  • Feature-based folders make feature deletion a single `rm -rf` with no orphaned files.
  • Explicit context boundaries make data flow auditable without a global store.

Cons

  • Compound components add a context per family; many families mean many contexts.
  • Polymorphic TS typing is verbose and trips up newer TypeScript developers.
  • Headless components shift all styling responsibility to every consumer.
  • Feature folders can duplicate code when two features share a primitive.
  • Context with frequent updates causes broad rerenders without careful memoization.

Interview angle

Interviewers probe whether you know when each pattern pays off. Compound components for behavior-sharing; headless for style-agnostic reuse; polymorphic for element flexibility; context only when prop drilling exceeds 2–3 layers.

Soundbite: "Composition beats configuration — expose slots and children, not a prop for every variant."

Key terms

Compound component
A family of components sharing state via context; parent owns, children consume.
Headless component
A hook or renderless component owning behavior but no markup or styles.
Polymorphic `as` prop
A generic prop letting callers choose the rendered HTML element with correct TS types.
Feature-based colocation
Grouping all files for one feature together; enables atomic feature deletion.
Container/presentational
Pattern separating data-fetching (container) from pure rendering (presentational).

Further reading

Search fearchitect

Jump to a topic, mode, or action.