Organizing react components with dot-notation and why I often resort to this method
The component approach is fundamental when building applications with react. Components are the main building blocks that, in their composition, help to realize complex systems. At the heart of each component, ideally, there is always some separate set of functionality, a kind of micro-solution to a micro-problem.
There are several different ways to organize components, and each of them can be good in a particular situation. The thing is that the components are different, as well as the tasks they solve. It turns out that depending on the functionality of the component, its purpose, you need to choose the appropriate design for its implementation.
Today I would like to share with you one of my favorite patterns for organizing complex react components, talk about its strengths and weaknesses (yes, there are some disadvantages). However, first, in order to appreciate the strengths of this approach, you need to dive into the process of developing a new react component and the problems that the approach helps to solve.
Imagine that we are faced with the task of implementing the following component:

Initial project structure:
├── shared/
│ └── components/
│ ├── EventCard.tsx /** наш компонент, с которым будем работать */
│ └── ...other components
├── App.tsx
└── index.ts
We, like simple hard workers, immediately get to work. The component seems simple to us, so why complicate its implementation? We put everything we need inside and get something like this result:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** Здесь не просто string, а ReactNode, т.к. string слишком узкий тип, он может связывать руки при использовании компонента */
title: ReactNode;
description: ReactNode;
onShare: () => void;
onMore: () => void;
onRemove: () => void;
onLike: () => void;
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
<footer className="action-bar">
<MoreInfoBtn onClick={onMore} />
<RemoveBtn onClick={onRemove} />
<LikeBtn onClick={onLike} />
</footer>
</article>
);
};
import { EventCard } from "./components/EventCard";
/** src/App.tsx */
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/* .... */
return (
<div className="App">
<EventCard
title={title}
description={description}
onLike={handleEventLike}
onRemove={handleEventRemove}
onMore={handleEventMore}
onShare={handleEventShare}
/>
</div>
);
}
Phew, wipe the sweat off your forehead, task accomplished. However, what is it? Has the designer updated the layouts with examples of our component?

Well, it doesn’t look complicated, it looks like the action bar is not required for our component. Edit for 5 minutes. We can fix the situation either by using a prop flag to hide/show our action bar, or we can make callback props for actions inside the component optional. The second option looks better, let’s do it like this:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** ... */
onMore?: () => void;
onRemove?: () => void;
onLike?: () => void;
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare
}) => {
const showActionBar = onMore || onRemove || onLike;
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{showActionBar && (
<footer className="action-bar">
{onMore && <MoreInfoBtn onClick={onMore} />}
{onRemove && <RemoveBtn onClick={onRemove} />}
{onLike && <LikeBtn onClick={onLike} />}
</footer>
)}
</article>
);
};
The task seems to be solved again (although suspicions about the code are already starting to appear), but bad luck, the layout has been updated again and now it is clear that the area where the action bar used to be located may look completely different for our component.

The solution, as usual, is not one. First, we can implement a second action bar next to the previous one, inside the component. It might look something like this:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
/** ... */
onMore?: () => void;
onRemove?: () => void;
onLike?: () => void;
currentBar: "status" | "action";
statusBarSettings?: {
status: "active" | "disabled";
percent: 35;
step: "day" | "month" | "year";
};
};
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onLike,
onMore,
onRemove,
onShare,
currentBar,
statusBarSettings
}) => {
const showStatusBar = Boolean(statusBarSettings);
const showActionBar = onMore || onRemove || onLike;
const actionBar = showActionBar ? (
<footer className="action-bar">
{onMore && <MoreInfoBtn onClick={onMore} />}
{onRemove && <RemoveBtn onClick={onRemove} />}
{onLike && <LikeBtn onClick={onLike} />}
</footer>
) : null;
const statusBar = showStatusBar ? (
<footer className="status-bar">
<StatusTag status={statusBarSettings!.status} />
<StatView
percent={statusBarSettings!.percent}
step={statusBarSettings!.step}
/>
</footer>
) : null;
const currentBarRender = currentBar === "action" ? actionBar : statusBar;
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{currentBarRender}
</article>
);
};
However, in the end it turned out not very beautiful and not clear enough (despite a number of assumptions, because this is just an example). The component interface becomes confusing, the props are not obvious. Further development of the component in this vein will only create more problems and a whole bunch of bugs. Most likely, colleagues will not approve such code for review. Looks like it’s time for decomposition.
Here the second option can help us, namely, moving the logic for displaying this area outside, and the component itself will only accept a prop, which we will display in the right place:
/** src/shared/components/EventCard.tsx */
type EventCardProps = {
title: ReactNode;
description: ReactNode;
onShare: () => void;
/** Теперь 1 проп отвечает полностью за рендер контента футера */
footer: ReactNode
};
/** Вынесли наружу всю логику связанную с контентом футера и компонент стал сразу намного очевиднее */
export const EventCard: React.FC<EventCardProps> = ({
title,
description,
onShare,
footer
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{/** Сложная логика проверки и выбора текущего футера упразднилась в пользу простого рендера */}
{footer}
</article>
);
};
/** src/App.tsx */
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Использование с action-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<footer className="action-bar">
<MoreInfoBtn onClick={handleEventMore} />
<RemoveBtn onClick={handleEventRemove} />
<LikeBtn onClick={handleEventLike} />
</footer>
}
/>
{/** Использование со status-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<footer className="status-bar">
<StatusTag status={"active"} />
<StatView percent={35} step={"month"} />
</footer>
}
/>
</div>
);
}
It seems that we shifted the problem from a sick head to a healthy one, but this is not entirely true, because we managed to get rid of branching when selecting the current footer inside the component. It is also important to note that with this approach, it is very good for us if the markup created outside the component can be organized using ready-made widgets (as in our case, I organized already existing components inside the markup), and also will not be reused in within other application pages. Otherwise, the situation will worsen dramatically. Especially if the display of markup elements is unique within the application, and widgets are not and are not expected, or the logic of user interaction with this element becomes significantly more complicated (there are widgets, but we work with their grouping, organizing them into a complex scenario, plus not forgetting about reusing the result !).
In the above circumstances, we need to move such pieces of code into separate components:
/** src/shared/components/EventCardActionBar.tsx */
type EventCardActionBarProps = {
/**
* Все наши пропы теперь могут быть обязательными,
* ведь мы работаем с отедльным компонентом
* и можем проектировать его апи как нам удобно
*/
onMore: () => void;
onRemove: () => void;
onLike: () => void;
};
/** Длинное составное имя выглядит ужасно, но скоро мы это исправим */
export const EventCardActionBar: React.FC<EventCardActionBarProps> = ({
onMore,
onRemove,
onLike
}) => {
return (
<footer className="action-bar">
<MoreInfoBtn onClick={onMore} />
<RemoveBtn onClick={onRemove} />
<LikeBtn onClick={onLike} />
</footer>
);
};
/** src/shared/components/EventCardStatusBar.tsx */
type EventCardStatusBarProps = {
status: "active" | "disabled";
percent: 35;
step: "day" | "month" | "year";
};
export const EventCardStatusBar: React.FC<EventCardStatusBarProps> = ({
status,
percent,
step
}) => {
return (
<footer className="status-bar">
<StatusTag status={status} />
<StatView percent={percent} step={step} />
</footer>
);
};
But such components, alas, are not independent. Without a parent component, they have no meaning (we can’t even give them a name without the parent component’s prefix, because suddenly we need a common ActionBar component), they won’t be used out of context (this happens most often), so allocate a place for them among the we don’t really want a real shared component like Button or Input. So what we’ll do is organize them as sub-components of our original component:
├── shared/
│ └── components/
│ ├── EventCard/
│ │ ├── StatusBar.tsx (так как подкомпоненты теперь не на уровне shared/components, то можем дать упрощенное имя имя)
│ │ ├── ActionBar.tsx
│ │ ├── EventCard.tsx
│ │ └── index.ts (отсюда будет делать ре-экспорт, чтобы создать публичный апи компонента)
│ └── ...other components
├── App.tsx
└── index.ts
Excellent! Already good, but not perfect yet. Now we can reuse subcomponents without pain. We have a compound component (compound-component):
import { EventCard } from "./shared/components/EventCard";
/** Обратите внимание на импорт, мы залезли во внутренности EventCard и выдернули необходимое. Выглядит это посредственно */
import { ActionBar } from "./shared/components/EventCard/ActionBar";
import { StatusBar } from "./shared/components/EventCard/StatusBar";
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Использование с action-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={
<ActionBar
onLike={handleEventLike}
onMore={handleEventMore}
onRemove={handleEventRemove}
/>
}
/>
{/** Использование со status-bar */}
<EventCard
title={title}
description={description}
onShare={handleEventShare}
footer={<StatusBar percent={35} status={"active"} step={"month"} />}
/>
</div>
);
}
But pay attention to what the import looks like. We are accessing entities that are not part of our component’s public API (importing not from the top level, but from within the component’s implementation, which is not a good practice). In doing so, I would like to emphasize the connection between our subcomponents and their parent component.
Now we have just come to a situation in which the organization of components through dot-notation looks as attractive as possible. Let’s reorganize our code a bit:
/** src/shared/components/EventCard.tsx */
/** Интерфейс, который расширит наш компонент, давая ему возможность содержать внутри себя виджеты */
type EventCardExtensions = {
ActionBar: typeof ActionBar;
StatusBar: typeof StatusBar;
/** И любые другие виджеты нашего компонента */
};
type EventCardProps = {
title: ReactNode;
description: ReactNode;
onShare: () => void;
/**
* Этот момент по желанию, можно добавить немного строгости,
* определив конкретные компоненты, которые могут стать футером
* или же разрешить рендер любой ноды
*/
footer: typeof ActionBar | typeof StatusBar;
// footer: ReactNode
};
/** К пропсам добавляем расширения компонента */
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
title,
description,
onShare,
footer
}) => {
return (
<article>
<header>
<h3>{title}</h3>
<ShareBtn onClick={onShare} />
</header>
<div className="content">{description}</div>
{footer}
</article>
);
};
/** Зашьем внутри нашего компонента, виджеты расширяющие его функционал */
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
Now our components have received an explicit connection, they are in a common space, but separated from entities that are not related to them. It turned out a kind of encapsulation, if you can call it that.
It already looks very cool, but I think it can be improved a little more. In our case, the original component contains several props, each of which is responsible for rendering the corresponding widget. The display order of widgets is strictly wired, because we fixed it in the markup, but if necessary, we can give developers the opportunity to determine the order of widgets inside the component (for example, display the ActionBar not at the bottom of the component, but immediately below the title). To do this, we’ll replace the widget-specific props with just the generic children. Now the order can be controlled from the outside, and this is additional flexibility, if necessary
type EventCardExtensions = {
ActionBar: typeof ActionBar;
StatusBar: typeof StatusBar;
Title: typeof Title;
Content: typeof Content;
/** И любые другие виджеты нашего компонента */
};
type EventCardProps = {
children: ReactNode;
};
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
children
}) => {
return <article>{children}</article>;
};
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
EventCard.Title = Title;
EventCard.Content = Content;
/** Импортируем мы теперь только целевой компонент */
import { EventCard } from "./shared/components/EventCard";
/** Как выглядит интерфейс нашего компонента при использовании */
export default function App() {
/** ... */
return (
<div className="App">
{/** Доступна любая комбинация, любой порядок группировки */}
<EventCard>
<EventCard.Title label={title} onShare={handleEventShare} />
<EventCard.ActionBar
onLike={handleEventLike}
onMore={handleEventMore}
onRemove={handleEventRemove}
/>
<EventCard.Content text={description} />
</EventCard>
</div>
);
}
Now there is nothing to scare us. And if a completely unexpected part appears in the design of our component and it is not a self-sufficient element of our ui-kit, we know how to organize it. And the composition of a component of this kind

or like this

does not cause embarrassment. We clearly know how and where to organize the most convenient code with clear links and hierarchy, a common space and an adequate interface.
And when a state appears for our already composite component, it can move to a wrapper, which will also be a provider for the context and all dependent components will be able to access it
/** src/shared/components/EventCard.tsx */
const EventCardContext = React.createContext({});
export const EventCard: React.FC<EventCardProps> & EventCardExtensions = ({
children
}) => {
const [state, setState] = useState();
return (
<article>
<EventCardContext.Provider
value={{ value: state, changeValue: setState }}
>
{children}
</EventCardContext.Provider>
</article>
);
};
EventCard.ActionBar = ActionBar;
EventCard.StatusBar = StatusBar;
EventCard.Title = Title;
EventCard.Content = Content;
What are the downsides? You may have difficulty with tree-shaking for obvious reasons, so really huge hierarchies are still best decomposed (or do some magic on how tree-shaking works for you?)
As a result, we got:
Break a complex component into multiple widgets and arrange them as a composition
Emphasize the relationship between the main and dependent components
Hide non-self parts behind the public interface
Implement all of the above without damaging the project structure
Create space for future expansion of functionality, if necessary