Webion

Compound components: implementation guide

Scopri come implementare i compound components in React: criteri, esempi e best practice per creare widget complessi con API leggibili e manutenibili.

S
Saad Medhaton September 29, 2025
G22

Within our applications, we often use the compound components pattern to build rich widgets while keeping a compositional and readable API.
In this article, I’ll outline the criteria, examples, and best practices adopted in the project for those who need to write or extend complex components without sacrificing developer experience.

Why choose compound components

  • They prevent the proliferation of props on monolithic components.
  • They allow shared internal state (loading, variant, interactions) without prop drilling.
  • They make it easier to combine optional parts (header, actions, indicators) while maintaining fallbacks.

Pattern anatomy

  1. Private context and access hook:
    Each component family exposes an internal context that holds the shared state.
    Providing a useComponentContext hook with a clear error message helps developers immediately understand if they’ve forgotten the root wrapper.


TYPESCRIPT
1// apps/app/components/QuoteMobileCard.tsx
2const QuoteMobileCardContext = createContext<QuoteMobileCardContextProps | undefined>(undefined);
3
4const useQuoteMobileCardContext = () => {
5 const context = useContext(QuoteMobileCardContext);
6
7 if (!context) {
8 throw new Error('QuoteMobileCard compound components must be used within a QuoteMobileCard provider');
9 }
10
11 return context;
12};


  1. A Root component that encapsulates the provider and layout:
    The root receives common props (loading, variant, click handlers) and defines the base markup, including the context provider.
TYPESCRIPT
1// apps/app/components/QuoteMobileCard.tsx
2function QuoteMobileCardRoot({
3 children,
4 isLoading = false,
5 onClick,
6}: QuoteMobileCardRootProps) {
7 return (
8 <QuoteMobileCardContext.Provider value={{ isLoading }}>
9 <Stack
10 component={ButtonBase}
11 onClick={onClick}
12 >
13 {children}
14 </Stack>
15 </QuoteMobileCardContext.Provider>
16 );
17}


  1. Sub-components aware of shared state:
    Each sub-component uses the context hook to read flags and determine its rendering — for example, showing a skeleton when isLoading is active.
TYPESCRIPT
1// apps/app/components/QuoteMobileCard.tsx
2const QuoteMobileCardTitle = ({ children }: { readonly children: React.ReactNode }) => {
3 const { isLoading } = useQuoteMobileCardContext();
4 return isLoading
5 ? <Skeleton variant="text" width="40%" />
6 : <Typography variant="h6">{children}</Typography>;
7};


  1. Final API: object with entry points:
    At the end of the file, we export an object that groups the Root and its child components. This allows for a clean, fluent, and self-documented syntax.
TYPESCRIPT
1// apps/app/components/QuoteMobileCard.tsx
2export const QuoteMobileCard = {
3 Root: QuoteMobileCardRoot,
4 Main: QuoteMobileCardMain,
5 Title: QuoteMobileCardTitle,
6 Subtitle: QuoteMobileCardSubtitle,
7};


Concrete examples in the app


Below is a practical example of optional composition built using the same building blocks described above.
Each block can be included or omitted depending on the current view, while maintaining a declarative API.

TYPESCRIPT
1<QuoteMobileCard.Root isLoading={isLoading}>
2 <QuoteMobileCard.Title>
3 {quoteTitle}
4 </QuoteMobileCard.Title>
5
6 {showSubtitle && (
7 <QuoteMobileCard.Subtitle>
8 Aggiornato il {lastUpdate}
9 </QuoteMobileCard.Subtitle>
10 )}
11
12 <QuoteMobileCard.Main
13 name={storeCode}
14 infoString={description}
15 />
16</QuoteMobileCard.Root>


To introduce the optional element QuoteMobileCard.Subtitle, simply define a new sub-component that reads from the same context and handles its own fallback during the loading phase.


TYPESCRIPT
1const QuoteMobileCardTitle = ({ children }: { readonly children: React.ReactNode }) => {
2 const { isLoading } = useQuoteMobileCardContext();
3 return isLoading
4 ? <Skeleton variant="text" width="40%" sx={{ fontSize: '1rem' }} />
5 : <Typography variant="h6">{children}</Typography>;
6};
7
8const QuoteMobileCardSubtitle = ({ children }: { readonly children: React.ReactNode }) => {
9 const { isLoading } = useQuoteMobileCardContext();
10 return isLoading
11 ? <Skeleton variant="text" width="60%" sx={{ fontSize: '0.75rem' }} />
12 : <Typography variant="body2" color="text.secondary">{children}</Typography>;
13};
14
15export const QuoteMobileCard = {
16 Root: QuoteMobileCardRoot,
17 Main: QuoteMobileCardMain,
18 Title: QuoteMobileCardTitle,
19 Subtitle: QuoteMobileCardSubtitle,
20};

This way, the developer using the component decides which blocks to mount, without propagating additional props or scattering conditional logic across the layout.

Best practices we maintain

  • Explicit errors: every context hook throws a descriptive error message.
  • Centralized loading: the root component decides when to display skeletons; children simply query the context.

How to build a new compound component

  1. Define the context with the minimal payload (e.g., { isLoading }).
  2. Write the Root component wrapping the Provider and base markup.
  3. Expose a useSomethingContext hook with a clear error message.
  4. Create sub-components that read from the context.
  5. Export everything in an organized literal object.

Conclusion


The compound components pattern allows us to keep complex layouts consistent across the entire application, reducing prop drilling and improving readability.
By following the steps outlined above, we can introduce new composite widgets while maintaining the same developer experience (DX).


Saad Medhat

La vecchia porta la sbarra