Component Dot Notation with TypeScript
Spencer Miskoviak
September 8, 2018 • Last updated on June 11, 2020
Photo by The Creative Exchange
In a previous post and React meetup, I shared several patterns and tools for managing complex features with React and TypeScript. Many of the code samples were using component dot notation, and I briefly mentioned it but did not go in depth about the advantages of using this approach.
This post will dive into those advantages when using component dot notation, highlight a few gotchas, and provide some examples.
What is component dot notation?
As the name suggests, it uses a "dot" to access the property of an object, more commonly referred to as dot notation. However, since this is at the component level (which are still just objects), I prefer "component dot notation" for clarity. A quick example of this is React Context.
const ThemeContext = React.createContext("light");
class App extends React.Component {
render() {
return (
<ThemeContext.Provider value="dark">
<ThemedButton />
</ThemeContext.Provider>
);
}
}
function ThemedButton(props) {
return (
<ThemeContext.Consumer>
{theme => <Button {...props} theme={theme} />}
</ThemeContext.Consumer>
);
}
In this example, ThemeContext
is created and is the top-level component. Both
the Provider
and Consumer
are sub-components of ThemeContext
accessed using
dot notation.
Definitions
These terms will be used throughout the remainder of the post.
- Top-level component: the actual component that is imported
(eg:
ThemeContext
orFlex
). There is only one per set of components. - Sub-component: any component accessed using dot notation
(eg:
ThemeContext.Provider
orFlex.Item
). There is one or more per set of components. - Component dot notation: accessing sub-components from a top-level component using dot notation.
Why use component dot notation?
There are a few key benefits I’ve experienced when using component dot notation to both maintain and consume a set of components.
✏️ Namespacing
As a result of using component dot notation, all sub-components are inherently
namespaced by the top-level component. Let’s take a Flex
component that wraps
CSS flexbox
as an example. The top-level component is named Flex
with one sub-component:
Flex.Item
.
import { Flex } from "flex";
function User() {
return (
<Flex align="center">
<Flex.Item shrink={0} grow={0}>
<Avatar />
</Flex.Item>
<Flex.Item shrink={1} grow={1}>
<UserInfo />
</Flex.Item>
</Flex>
);
}
It does not enforce or stop usage of using Flex.Item
outside of Flex
, but
since it is a sub-component, it does imply to any developer that may be using it
that it should only be used as a child of Flex
.
🚢 Single Imports
With this technique there is only a single entry point to use the flex
components. It doesn’t matter if the Flex.Item
component definition and logic
is in the same file as Flex
, in a sibling file, or in a nested directory. The
underlying implementation and file structure can be changed at any time because
the only public contract is the export of Flex
. This reduces the "public" API
surface area as compared to importing every component individually where a
change in implementation or file structure will break existing usages.
As a feature evolves over time and pieces are added and removed due to changing requirements, the import can remain unchanged which can reduce noise in changes to imports.
🔍 Discoverability
If there are “n” components in a set, a developer will have to memorize all “n” of those component names to know which to import or go file spelunking to find the component they need. However, with component dot notation, only the top-level component needs to be remembered and all component options will be suggested following the dot! There’s no need to memorize. This also improves discoverability of all components available that may not have been known.
Examples
There are various practical examples when component dot notation works well.
For example, wrapper components like Flex
with Flex.Item
as a sub-component.
class Flex extends React.Component<Props> {
public static Item = FlexItem;
public render() {
// ...
}
}
Or slightly more complex components in a design system that maybe have several
building blocks. For example, a Table
component that has many sub-components
such as Table.Row
,Table.Cell
, and Table.Head
that can be used as children
only within Table
.
class Table extends React.Component<Props> {
public static Body = TableBody;
public static Cell = TableCell;
public static Controls = TableControls;
public static Head = TableHead;
public static Header = TableHeader;
public static Row = TableRow;
public render() {
// ...
}
}
And lastly, it works great for large or complex sets of components, like a
Search
feature, which has a variety of filter components, pagination, results,
etc.
<Search category="Users">
<Search.Filters>
<Search.Query title="Search" placeholder="Enter a keyword..." />
<Search.Facet title="Status" />
<Search.DateRange title="Application Date" />
</Search.Filters>
<Search.PaginationCounter />
<Search.Sort />
<Search.Results
component={UsersCard}
renderEmpty={UsersNoResults}
renderLoading={UsersLoading}
/>
</Search>
Gotchas
There are a few "gotchas" you may stumble across that are worth being aware of when using component dot notation.
Higher Order Components
It can be tricky using a higher order component, such as connect
from
react-redux
, on the top-level component. Specifically when using connect
,
it will hoist all static attributes
to the wrapping component (most higher order components do this), but the correct
typings will not be preserved. In this case, the higher order component will need
to be casted, or if possible, avoid using a higher order component with the
top-level component.
Component Display Names
As discussed above, the underlying implementation of the sub-components does not
matter. In the case of Flex
the Flex.Item
component implementation itself
could be named NeverCallThisComponentDirectly
. This is fine, but the only
downside is that in React Devtools,
it will be shown as NeverCallThisComponentDirectly
, which may be very
confusing because it was never called directly.
One way around this is to set the
displayName
on
the component to match how it will be used. In this case, the component name
remains NeverCallThisComponentDirectly
, but now has a display name of Flex.Item
.
class NeverCallThisComponentDirectly extends React.Component<Props> {
public static displayName = "Flex.Item";
public render() {
// ...
}
}
The underlying implementation has not changed at all, but now the component is
both used as Flex.Item
and correctly seen in React Devtools as Flex.Item
.
Typing Function Components
All of the examples up to this point are using class components but this same approach can be used with function components. However, it requires explicitly declaring the sub-component in the type declaration.
const Flex: React.FC<Props> & { Item: typeof FlexItem } = () => {
// ...
};
Flex.Item = FlexItem;
This type declaration uses an intersection to combine the standard React function
component type with a type that declares the Item
property. This then allows
assigning and later using Flex.Item
in the same fashion as the class
components from above.
Tree Shaking
One disadvantage of this approach is that it can "break" tree shaking. At a
high level, tree shaking works by removing code that is not imported nor used.
Since the top-level Search
component imports and exposes all sub-components,
they will all be included even if never used. However, if this is an actual
problem it probably suggests an overuse of component dot notation or the
set of components are not related.
Final thoughts
Component dot notation can be a useful technique when working with a set of components. It minimizes the API surface area to a single export, keeps the import simple and improves the discoverability of available sub-components.
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course