The Pitfalls of Universal Components
Whether you use libraries or build your own universal components, you will likely encounter these challenges:
-
Moving too far ahead on one platform, only to realize that on the other platforms, it looks or feels wrong, or worse, it doesn't work at all.
-
Telling apart universal props from platform-specific ones: For example, the
tintColor
and thecolors
props on theRefreshControl
are platform specific. One is only for iOS while the other is only for Android. Who would have guessed? -
Too many props: Trying to accommodate every platform in a single component often leads to bloated APIs with props that are hard to understand and maintain.
-
Too much conditional logic: You end up with a mess of
Platform.select
, if statements, and overrides, making the component harder to debug. -
Web and Native don’t always align: For example, there's a Radix UI-style menu called Zeego. It works well and it's very close to delivering a web experience on native. However, you have to follow special rules. For instance, the
Item
component requires you to provide the same content for both itstextValue
prop and itschildren
. Thechildren
are only used on the web, while the native platforms rely on thetextValue
prop.
These challenges make it clear that a one-size-fits-all approach doesn’t always work as we would like. Instead of forcing universal components to fit every platform, we should take a different approach.
Platform First, Then Universal
I often think about the line where universal and native feeling meet which has led me to the Platform-First Approach. This approach now drives my work on rn-primitives v2 which will shape both React Native Reusables and NativeWindUI.
What does Platform-First, Universal mean?
Platform-First, Universal means building components that work everywhere but feel natural on each platform by considering platform differences from the start. The best way to achieve this is by first building platform-specific components and then creating a universal component that works across all platforms.
A universal, platform-first button component that supports iOS, Android, and Web needs to have the iOS button, Android button, and Web button components before creating the universal button component.
Let’s use this button as an example:
How is it consumed?
From the consumer's perspective, the universal Button
component is the starting point as it should meet most of their needs.
my-button.tsx
1import { Button } from "platform-first-universal-button";
This universal component includes only the props supported by every platform (with one exception, which we'll cover later). This means that features only available on iOS, Android, or the web are not supported.
my-button.tsx
1function onPress(){
2 console.log("Pressed")
3}
4
5return (
6 <Button onPress={onPress}>
7 <Text>Universal Button</Text>
8 </Button>
9)
It accepts a ref and only exposes methods and properties that are supported across all platforms.
my-button-with-ref.tsx
1const ref = React.useRef(null)
2
3function onLongPress(){
4 // Allows programmatic control
5 ref.current?.press()
6}
7
8return (
9 <Button ref={ref} onLongPress={onLongPress}>
10 <Text>Universal Button</Text>
11 </Button>
12)
What about platform-specific functionalities?
The first option
This is the exception mentioned earlier. The universal component includes platform-specific props named after each platform, such as ios
, android
, and web
. They keep platform-specific functionality separate while maintaining flexibility, allowing you to pass platform-specific properties that take priority over any overlapping universal props.
my-button-with-ios-action.tsx
1function onPress(){
2 console.log("For all except iOS")
3}
4
5function onIosAction(){
6 console.log("Overrides the universal onPress prop")
7}
8
9return (
10 <Button onPress={onPress} ios={{ action: onIosAction }}>
11 <Text>Universal Button</Text>
12 </Button>
13)
The second option
Use the platform-specific components.
my-button.tsx
1import {
2 ButtonIos,
3 ButtonAndroid,
4 ButtonWeb,
5} from "platform-first-universal-button";
The ButtonIos
accepts the same props as the ios
prop in the universal component (from the first option). The same concept applies to the other platform components and they only render on their intended platforms, returning null elsewhere.
my-button-with-ios-specific-button.tsx
1function onPress(){
2 console.log("For all but iOS")
3}
4
5function onIosAction(){
6 console.log("Overrides the universal prop")
7}
8
9if (Platform.OS === 'ios'){
10 return (
11 <ButtonIos action={onIosAction} role="destructive">
12 <Text>iOS Button</Text>
13 </ButtonIos>
14 )
15}
16
17return (
18 <Button onPress={onPress}>
19 <Text>Universal Button</Text>
20 </Button>
21)
The Platform-First Approach preserves the full power of each platform while remaining flexible. Of course, this is easier said than done. Let's explore how this will shape rn-primitives v2, although it won't be exactly the same.
rn-primitives v2
There will be universal components, web components, and native components (there will not be a component for each native platform). The native components are based on existing React Native components like View
and Text
, while web components use radix-ui/primitives, which are built on top of native HTML elements like div
and p
.
It will provide great defaults while offering more flexibility. For example, an accordion consists of five components: five universal components, five web components, and five native components, all designed to work together with their specific roles.
accordion-example.tsx
1function AccordionExample() {
2 const ref = React.useRef<AccordionTriggerRef>(null);
3 return (
4 <>
5 <Button
6 onPress={() => {
7 // Allows programmatic control of the accordion
8 ref.current?.trigger?.();
9 }}
10 >
11 <Text>External Trigger Item 1</Text>
12 </Button>
13 <Accordion type='single'>
14 <AccordionItem
15 value='item-1'
16 // Native only props
17 native={{ onLayout: () => console.log('onLayout Native Only') }}
18 >
19 <AccordionTrigger ref={ref}>
20 {/* Universal AccordionTrigger accepts Pressable children universally */}
21 {({ pressed }) => <Text>Is it accessible? {pressed ? 'pressed' : 'not pressed'}</Text>}
22 </AccordionTrigger>
23 <PlatformSpecificAccordionContent />
24 </AccordionItem>
25 </Accordion>
26 </>
27 );
28}
29
30function PlatformSpecificAccordionContent() {
31if (Platform.OS === 'web') {
32 // ✅ Strings can be used as children in web
33 return <AccordionContentWeb>Web Content</AccordionContentWeb>;
34}
35return (
36 <AccordionContent>
37 <Text>Yes. It adheres to the WAI-ARIA design pattern.</Text>
38 </AccordionContent>
39);
40}
The implementation details can be found the @zach/v2 branch on the rn-primitives GitHub repo (still a WIP). If you are reading this a few months later, it may already be in the main branch.
This is just the beginning, and there is still plenty of work ahead. I am excited to see where this leads. If this resonates with you, I would love to hear your thoughts or see how you use it in your projects.