Compound Components: Advance React Patterns 👩🏻‍💻
shelly agarwal

shelly agarwal @shelly_agarwal_19

About: 5+ years expertise on Frontend. Hands-on ReactJS, React Native, etc. | Build cool projects with me Instagram profile: https://www.instagram.com/code_yourself_?igsh=MWZmcXNlOHVjYzZrbQ%3D%3D&u

Location:
Udaipur, Rajasthan
Joined:
Mar 3, 2025

Compound Components: Advance React Patterns 👩🏻‍💻

Publish Date: May 8
3 0

1. What are Advanced React Patterns?
In advanced React development, design patterns help you write more maintainable, reusable, and testable code. These patterns abstract common logic, improve scalability, and offer flexibility when building complex apps.

2. What are Compound Components?
Related components share state implicitly via a parent.

3. Why Compound components matter in real-world apps?
✅ 1. Improves Component API Ergonomics
It allows you to design components that behave like native HTML elements with children—such as with . Consumers can use your components in a way that feels natural:

<PizzaBuilder>
  <PizzaBuilder.SizeSelector />
  <PizzaBuilder.ToppingSelector />
  <PizzaBuilder.Preview />
</PizzaBuilder>
Enter fullscreen mode Exit fullscreen mode

✅ 2. Enables Shared State Without Prop Drilling
All subcomponents share context from the parent component (PizzaBuilder), so you don’t need to pass down props manually through multiple layers.

📉 Without compound:

<ToppingSelector toppings={toppings} toggle={toggleTopping} />

📈 With compound:

<PizzaBuilder.ToppingSelector />

✅ 3. Highly Composable & Flexible
Consumers can choose what parts to render, in any order. You’re not forcing a specific UI layout or coupling logic and markup tightly.

✅ 4. Encapsulates Logic While Exposing Control
State is managed internally in the parent, but child components expose controlled interfaces to change it—kind of like useReducer but through UI.

✅ 5. Ideal for Custom Layouts and Dynamic UIs
This pattern is excellent for building things like:

  1. Wizards / Steppers
  2. Forms with multiple sections
  3. Tab panels
  4. Charts with tooltips and legends
  5. Builders/editors (e.g., a pizza builder!)

✅ 6. Used in Real Libraries
You’ll see this pattern used in libraries like:

🧱 Radix UI
🧭 React Aria
📦 Headless UI

They use compound components to let developers build fully customized but accessible UI primitives.

Use Cases: Tabs, accordions, dropdowns.

Examples -

<Tabs>
  <Tabs.Tab label="One" />
  <Tabs.Tab label="Two" />
</Tabs>

----------------

<select>
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
</select>
Enter fullscreen mode Exit fullscreen mode

Without Compound Components

This version hardcodes the structure and logic inside a single component. It's rigid and harder to scale or customize.

function PizzaBuilder() {
  const [size, setSize] = useState('medium');
  const [toppings, setToppings] = useState([]);

  const toggleTopping = (toppin) =>
    setToppings((prev) =>
      prev.includes(toppin) ? prev.filter((x) => x !== toppin) : [...prev, toppin]
    );

  return (
    <div>
      <h2>Select Size</h2>
      {['small', 'medium', 'large'].map((s) => (
        <button key={s} onClick={() => setSize(s)}>{s}</button>
      ))}

      <h2>Select Toppings</h2>
      {['cheese', 'pepperoni', 'mushrooms'].map((t) => (
        <label key={t}>
          <input
            type="checkbox"
            checked={toppings.includes(t)}
            onChange={() => toggleTopping(t)}
          />
          {t}
        </label>
      ))}

      <h3>Pizza: {size} with {toppings.join(', ') || 'no toppings'}</h3>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

❌ Not very reusable. What if you want to separate size and topping selectors?

Let's build a Pizza Builder 🍕 using Compound Component pattern so that you can understand better.

Note: I’ve added more logic so you can see the pizza in the making!

Here are the screenshots of what this small project will look like -

Image__1

image_2

import React, {
    createContext,
    useContext,
    useState,
    type ReactNode,
} from 'react'

// Define context type
type PizzaContextType = {
    size: string
    setSize: (size: string) => void
    toppings: string[]
    toggleTopping: (t: string) => void
}

type ToppingConfig = {
    name: string
    image: string
    positions: {
        top?: string
        bottom?: string
        left?: string
        transform?: string
    }[]
    style?: React.CSSProperties
}

type Position = {
    top?: string
    left?: string
    right?: string
    bottom?: string
    transform?: string
}

const POSITION_PRESETS = {
    center: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' },
    mushroomCenter: {
        top: '50%',
        left: '40%',
        transform: 'translate(-50%, -50%)',
    },
    topLeft: { top: '25%', left: '15%' },
    topRight: { top: '25%', right: '20%' },
    cheeseTopLeft: { top: '25%', left: '22%' },
    cheeseTopRight: { top: '25%', right: '25%' },
    bottomLeft: { bottom: '25%', left: '20%' },
    bottomRight: { bottom: '25%', right: '20%' },
    mushroomBottomLeft: { bottom: '30%', left: '25%' },
    mushroomBottomRight: { bottom: '30%', right: '25%' },
    midLeft: { top: '50%', left: '5%', transform: 'translateY(-50%)' },
    midRight: { top: '50%', right: '16%', transform: 'translateY(-50%)' },
    topCenter: { top: '10%', left: '50%', transform: 'translateX(-50%)' },
    mushroomTopCenter: { top: '22%', left: '50%', transform: 'translateX(-50%)' },
    bottomCenter: { bottom: '10%', left: '50%', transform: 'translateX(-50%)' },
} satisfies Record<string, Position & { transform?: string }>

// Create context with correct type (use null + type guard for safety)
const PizzaContext = createContext<PizzaContextType | null>(null)

// 🛠️ Hook to safely use context
const usePizza = () => {
    const ctx = useContext(PizzaContext)
    if (!ctx)
        throw new Error('Pizza components must be used inside <PizzaBuilder>')
    return ctx
}

// Main wrapper
function PizzaBuilder({ children }: { children: ReactNode }) {
    const [size, setSize] = useState('medium')
    const [toppings, setToppings] = useState<string[]>([])

    const toggleTopping = (toppin: string) =>
        setToppings((prev) =>
            prev.includes(toppin)
                ? prev.filter((x) => x !== toppin)
                : [...prev, toppin],
        )

    const value: PizzaContextType = { size, setSize, toppings, toggleTopping }

    return (
        <PizzaContext.Provider value={value}>
            <div className="pizza-builder">{children}</div>
        </PizzaContext.Provider>
    )
}

// Compound component: SizeSelector
PizzaBuilder.SizeSelector = function SizeSelector({
    options,
}: {
    options: string[]
}) {
    const { size, setSize } = usePizza()
    return (
        <div>
            <h2>Select Size</h2>
            {options.map((option) => (
                <button
                    key={option}
                    onClick={() => setSize(option)}
                    style={{ fontWeight: option === size ? 'bold' : 'normal' }}
                >
                    {option}
                </button>
            ))}
        </div>
    )
}

// Compound component: ToppingSelector
PizzaBuilder.ToppingSelector = function ToppingSelector({
    options,
}: {
    options: string[]
}) {
    const { toppings, toggleTopping } = usePizza()
    return (
        <div>
            <h2>Select Toppings</h2>
            {options.map((option) => (
                <label key={option} style={{ display: 'block' }}>
                    <input
                        type="checkbox"
                        checked={toppings.includes(option)}
                        onChange={() => toggleTopping(option)}
                    />
                    {option}
                </label>
            ))}
        </div>
    )
}

// Compound component: Preview
PizzaBuilder.Preview = function Preview() {
    const { size, toppings } = usePizza()
    const isSmall = size === 'small'
    const isLarge = size === 'large'
    const isMedium = size === 'medium'

    const renderWidth = () => {
        if (isSmall) {
            return 150
        }
        if (isMedium) {
            return 250
        }
        if (isLarge) {
            return 350
        }
    }

    const renderPizza = () => {
        const getPizzaSize = () => {
            if (isSmall) return { width: 150 };
            if (isMedium) return { width: 250 };
            if (isLarge) return { width: 350, marginLeft: -17 };
            return null;
        };

        const sizeStyle = getPizzaSize();

        if (!sizeStyle) return null;

        return (
            <img
                src="/images/largePizza.png"
                alt="Pizza"
                style={sizeStyle}
            />
        );
    };

    const TOPPING_CONFIGS: ToppingConfig[] = [
        {
            name: 'cheese',
            image: '/images/cheese.png',
            positions: [
                POSITION_PRESETS.cheeseTopLeft,
                POSITION_PRESETS.cheeseTopRight,
                POSITION_PRESETS.bottomCenter,
            ],
            style: { width: 20 },
        },
        {
            name: 'mushrooms',
            image: '/images/mushrooms.png',
            positions: [
                POSITION_PRESETS.mushroomTopCenter,
                POSITION_PRESETS.midLeft,
                POSITION_PRESETS.midRight,
                POSITION_PRESETS.mushroomBottomLeft,
                POSITION_PRESETS.mushroomBottomRight,
                POSITION_PRESETS.mushroomCenter,
            ],
            style: { width: 22, borderRadius: 11 },
        },
        {
            name: 'tomato',
            image: '/images/tomato.png',
            positions: [
                POSITION_PRESETS.center,
                POSITION_PRESETS.topLeft,
                POSITION_PRESETS.topRight,
                POSITION_PRESETS.bottomLeft,
                POSITION_PRESETS.bottomRight,
                POSITION_PRESETS.topCenter,
            ],
            style: { width: 30, borderRadius: 10 },
        },
    ]

    const renderImages = (
        positions: Position[],
        style?: React.CSSProperties,
        image?: string,
        name?: string,
    ) => {
        return positions.map((pos, index) => (
            <div
                key={index}
                style={{
                    position: 'absolute',
                    ...pos,
                    ...(pos.transform ? { transform: pos.transform } : {}),
                }}
            >
                <img src={image} alt={name} style={style} />
            </div>
        ))
    }

    const renderToppings = () => {
        return TOPPING_CONFIGS.map(
            ({ name, image, positions, style }) =>
                toppings.includes(name) && (
                    <div key={name}>{renderImages(positions, style, image, name)}</div>
                ),
        )
    }

    return (
        <h3>
            Pizza: {size} with {toppings.length ? toppings.join(', ') : 'no toppings'}
            <div style={{ position: 'relative', width: renderWidth() }}>
                <div>
                    {renderPizza()}
                    {renderToppings()}
                </div>
            </div>
        </h3>
    )
}

export default PizzaBuilder

Enter fullscreen mode Exit fullscreen mode

✅ Flexible layout
✅ Fully decoupled concerns
✅ Easy to reuse or extend (e.g., add <PizzaBuilder.CrustSelector>)

Feel free to use your creativity to expand the functionality and improve the UI.

The main objective is for you to understand how we're applying Advanced React Patterns.

If this article helped you, I would greatly appreciate your support in the form of claps ❤️.
Try implementing it yourself!

Comments 0 total

    Add comment