Multipart Namespace Components: Addressing RSC and Dot Notation Issues
Ivica Batinić
Note (Updated 24.08.2024): After further investigation and prototyping, I discovered some important limitations regarding the tree-shaking capabilities of bundlers like Webpack when using dot-notation (namespace) syntax. I have detailed these findings in the latter part of this post. If you're primarily interested in this aspect, I recommend jumping to the update section here.
Introduction
In React development, organising components well is crucial for maintainability and performance. One approach is using namespaced components, also known as dot notation, where related parts are grouped together. Recently, a significant issue with React Server Components (RSC) has been raised, especially in the discussions "Dot notation client component breaks consuming RSC" and "Issue #58776: Challenges with Namespaced Components in RSC”.
This blog post will show how we can solve this problem effectively using a Card
component with parts like Card.Header
, Card.Body
, and Card.Footer
. We’ll look at some old methods, their problems, and a modern solution.
Understanding Old Patterns and Their Downsides
The Function Assignment Pattern
One old method is the function assignment pattern.
Here’s how it looks:
function Card() {}
Card.Header = function Header() {}
Card.Body = function Body() {}
Card.Footer = function Footer() {}
export default Card;
Downsides:
- Serialisation Issues: This pattern isn’t compatible with React Server Components (RSC) because it can’t be serialised.
- tree shaking Issues: All parts get included in the bundle, which is inefficient.
Tree shaking is a form of dead code elimination that removes unused code from the bundle. For tree shaking to work effectively:
- Static Analysis: The optimiser must be able to statically analyse the code. This usually works well when modules are ES modules(using
import
/export
).- Side Effects: The code being removed must not have side effects that the optimiser can't safely remove.
This example is a bit tricky for tree-shaking because Card.Header
and Card.Footer
are properties assigned directly to a function, not independent imports. Some optimisers might struggle to identify these as unused, particularly if they are part of the same file or are bundled together without clear module boundaries.
The Object.assign Pattern
Another method is using Object.assign
:
export default Object.assign(Card, {
Header,
Body,
Footer,
});
Downsides:
- Same Serialization Issue: This method also doesn’t work well with RSC.
- Same tree shaking Issue: It still pulls in all parts when you might only need one.
This example can still have similar tree-shaking issues as the first one.
Tree shaking works best when the code is structured in a way that allows the optimiser to statically analyse which parts of the code are unused. When we use Object.assign
, we're dynamically attaching properties (Header
, Body
, and Footer
) to the Card
function. This makes it harder for many tree-shaking algorithms to determine that Header
and Footer
are unused because:
- Dynamic Property Assignment:
Object.assign
dynamically assigns properties toCard
. Most tree-shaking tools rely on static analysis, and they might not "see through" the dynamic nature of this assignment. - Module Boundaries: Tree shaking works more effectively when each function or component is imported separately. By bundling these components together using
Object.assign
, you're reducing the optimiser's ability to detect unused parts.
Modern Solution to Namespaced Components
Introduction to the Modern Approach
I found this pattern in the Chakra UI v3 source code. The solution is both smart and modern. It addresses serialisation and code-splitting problems associated with the dot notation pattern and utilises ESM modules.
Implementing the Solution
Here’s the step-by-step breakdown:
- Named Exports for Each Part:
// card.tsx
export function CardRoot() {}
export function CardHeader() {}
export function CardBody() {}
export function CardFooter() {}
Using named exports for each component (CardRoot
, CardHeader
, CardBody
, CardFooter
) enables tree-shaking at the module level. Each component is now its own export, making it easier for the bundler to detect and eliminate unused components.
- Create a Namespace Module:
// namespaces.ts
export {
CardBody as Body,
CardRoot as Root,
CardFooter as Footer,
CardHeader as Header,
} from "./card";
This step allows you to create a "namespace" while still leveraging the named exports. By re-exporting the components with aliases (e.g., CardRoot
as Root
), you maintain the desired naming convention in your API without sacrificing tree-shaking capabilities.
- Collect and Expose the Parts:
// index.tsx
export * as Card from "./namespace";
This line gathers all the named exports under a Card
namespace, which you can then import and use as a single module:
import { Card } from './src/ui/card';
export function App() {
return (
<Card.Root>
<Card.Header></Card.Header>
<Card.Body></Card.Body>
<Card.Footer></Card.Footer>
</Card.Root>
);
}
Advantages of the Modern Approach:
- No Runtime Evaluations: Everything is static, making it easy to analyse.
- Better Serialisation: Works well with RSC.
- Improved Tree Shaking: Only the parts you import get included in the bundle.
Why This Helps with Tree Shaking
- Static Analysis: The use of named exports allows bundlers like Webpack, Rollup, or tools like SWC to perform static analysis more effectively. The bundler can now see exactly which parts of the
Card
module are being used. - Module Isolation: Since each component (
Root
,Body
,Header
,Footer
) is exported separately, the optimiser can eliminate unused components more easily, even when they are imported through a namespace. - Avoiding Dynamic Properties: By avoiding the use of
Object.assign
or similar dynamic property assignment methods, you ensure that the properties ofCard
are statically analysable, which is crucial for effective tree shaking.
A Trade-off for Better Optimisation
When it comes to the Root
component, the new syntax might initially feel like a step down:
// Previous Syntax
<Card>
<Card.Header>Header</Card.Header>
<Card.Body>Body</Card.Body>
<Card.Footer>Footer</Card.Footer>
</Card>
// New Syntax
<Card.Root>
<Card.Header>Header</Card.Header>
<Card.Body>Body</Card.Body>
<Card.Footer>Footer</Card.Footer>
</Card.Root>
While the new approach may seem less elegant, it offers significant advantages. By avoiding the use of Object.assign
, function assignments, and runtime evaluations, this method ensures that all components are statically analysable and fully tree-shakeable. The result is more efficient code that is easier for bundlers to optimise, leading to smaller and faster-loading bundles.
Libraries Using Old vs. New Patterns
Here’s a brief overview of some popular libraries that exemplify these approaches:
Old Pattern:
- Headless UI: https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/dialog/dialog.tsx#L588
- Ant Design: https://github.com/ant-design/ant-design/blob/master/components/menu/index.tsx#L62
- Mantine: https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/core/src/components/Modal/Modal.tsx#L105
- React-Bootstrap: https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Modal.tsx#L512
- Evergreen: https://github.com/segmentio/evergreen/blob/master/src/menu/src/Menu.js#L109
New Pattern:
- Chakra UI v3: https://github.com/chakra-ui/chakrui/tree/main/packages/react/src/components/dialog
- Radix UI: https://github.com/radix-ui/primitives/tree/main/packages/react/dialog/src
Conclusion
Switching to this modern approach for namespaced components makes your React code more efficient and maintainable. By avoiding old patterns' issues, you can take full advantage of React’s capabilities, especially with server components and tree shaking. Try this method in your projects to see the benefits firsthand.
Update: Insights on Bundler Limitations with Dot Notation
After further investigation and prototyping with the help of my colleague @BulicJakov, it turns out that bundlers aren't as smart as I initially thought. Unfortunately, it seems that Webpack, as used in Next.js, cannot tree-shake components when using dot notation (namespace) syntax. This is quite disappointing, given my expectations for more efficient bundling.
Demo app repository: dot-notation-issues
This limitation is a significant downside of using dot-notation. If you aim to import just a single part of a component, I highly recommend directly importing CardBody
instead. This approach is one reason why libraries like Chakra UI v3 offer individual exports for each component part. In most practical scenarios, you'll likely end up using around 80% of the parts of a component, so this limitation might not be as problematic as it seems at first.
import {
CardRoot,
CardBody,
} from "@/components/ui/card";
export function App() {
return (
<CardRoot>
<CardBody>BODY</CardBody>
</CardRoot>
);
}
However, it still raises an important question: why can't bundlers fully support tree-shaking with dot-notation, especially since it's based on ES Modules (ESM)? I was really hoping that modern bundlers would be smart enough to optimize this. But, it seems we're not quite there yet, despite the popularity of dot-notation.