When you start a React Native project from scratch, the question comes fast: how do I organize my components?
There's no single right answer. Two different projects (one a large-scale banking app, the other a vehicle metrics app built from zero) led me to completely different answers, and both were correct in their context. This article explores those two philosophies: Atomic Design and feature-based organization.
1. What Is Atomic Design
Atomic Design is a methodology proposed by Brad Frost for building modular design systems. The core idea is that interfaces aren't monolithic pages, but sets of reusable pieces organized in levels of increasing complexity.
The analogy is chemistry: just as matter is made of atoms that form molecules, which in turn form more complex structures, UI components are built by combining smaller, simpler pieces.
The five original levels are:
- Atoms: the smallest level. A button, a text input, an icon, a badge. No dependencies on other components.
- Molecules: simple combinations of atoms with a concrete purpose. A form field (label + input + error message), a list item with icon and text.
- Organisms: Complete and relatively independent sections. A screen header, a transaction list, a login form.
- Templates: The screen structure without real data. Defines the layout of organisms.
- Pages: Templates with real data. In React Native, this typically maps directly to navigator screens.
Atomic Design was originally designed for the web. In React Native projects, variations appear: some teams separate primitives (design system wrappers) and objects (components with embedded business logic) as additional layers.
2. Atomic Design in Practice: The Primitives and Objects Variant
In a large-scale banking project I worked on (an app with over a dozen domains, multiple build environments, and bilingual support), the team adopted a variant of Frost's model that adds two layers: primitives and objects.
The UI component folder structure looked like this:
1src/
2└── components/
3 ├── primitives/ # Design system wrappers
4 ├── atoms/ # Elementary components
5 ├── molecules/ # Simple combinations
6 ├── organisms/ # Complex sections
7 └── objects/ # Components with business logicEach layer has a clear responsibility.
Primitives: the design system encapsulated
Primitives are wrappers over React Native's built-in components that encapsulate the design system: typography, spacing, colors, shadows. They're not UI components per se. They're the building blocks that guarantee visual consistency.
1// primitives/Typography.tsx
2type TypographyVariant = 'heading1' | 'heading2' | 'body' | 'caption' | 'label'
3
4interface TypographyProps {
5 variant: TypographyVariant
6 children: React.ReactNode
7 color?: string
8}
9
10const styles: Record<TypographyVariant, TextStyle> = {
11 heading1: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
12 heading2: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
13 body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
14 caption: { fontSize: 12, fontWeight: '400', lineHeight: 18 },
15 label: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
16}
17
18export function Typography({ variant, children, color }: TypographyProps) {
19 return (
20 <Text style={[styles[variant], color ? { color } : undefined]}>
21 {children}
22 </Text>
23 )
24}The advantage of having primitives is that updating the design system doesn't mean touching hundreds of components. Change Typography in one place and the update propagates automatically.
Atoms: no dependencies, maximum reuse
Atoms are the simplest components: they don't compose other app components, they have a single responsibility, and they're completely domain-agnostic.
1// atoms/Badge.tsx
2type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral'
3
4interface BadgeProps {
5 variant: BadgeVariant
6 label: string
7 size?: 'sm' | 'md'
8}
9
10const variantColors: Record<BadgeVariant, { bg: string; text: string }> = {
11 success: { bg: '#E8F5E9', text: '#2E7D32' },
12 warning: { bg: '#FFF8E1', text: '#F57F17' },
13 error: { bg: '#FFEBEE', text: '#C62828' },
14 info: { bg: '#E3F2FD', text: '#1565C0' },
15 neutral: { bg: '#F5F5F5', text: '#616161' },
16}
17
18export function Badge({ variant, label, size = 'md' }: BadgeProps) {
19 const { bg, text } = variantColors[variant]
20 const padding = size === 'sm' ? { paddingHorizontal: 6, paddingVertical: 2 }
21 : { paddingHorizontal: 10, paddingVertical: 4 }
22 return (
23 <View style={[styles.container, { backgroundColor: bg }, padding]}>
24 <Typography variant="caption" color={text}>{label}</Typography>
25 </View>
26 )
27}Molecules: combinations with purpose
A molecule combines two or more atoms to solve a specific UI problem. The key criterion: no business logic, presentation only.
1// molecules/FormField.tsx
2interface FormFieldProps {
3 label: string
4 value: string
5 onChangeText: (text: string) => void
6 error?: string
7 placeholder?: string
8 secureTextEntry?: boolean
9}
10
11export function FormField({
12 label, value, onChangeText, error, placeholder, secureTextEntry,
13}: FormFieldProps) {
14 return (
15 <View style={styles.container}>
16 <Typography variant="label">{label}</Typography>
17 <Spacer size="xs" />
18 <TextInput
19 value={value}
20 onChangeText={onChangeText}
21 placeholder={placeholder}
22 secureTextEntry={secureTextEntry}
23 style={[styles.input, error ? styles.inputError : undefined]}
24 />
25 {error && (
26 <>
27 <Spacer size="xs" />
28 <Typography variant="caption" color={colors.error}>{error}</Typography>
29 </>
30 )}
31 </View>
32 )
33}Objects: components with business logic
This is the layer Frost doesn't include in his original model, and it turns out to be particularly useful in domain-heavy apps. Objects are components that know the domain: they know what a transaction is, what date formats the app uses, what states an account can be in.
1// objects/TransactionItem.tsx
2interface Transaction {
3 id: string
4 concept: string
5 amount: number
6 currency: 'EUR'
7 date: Date
8 status: 'completed' | 'pending' | 'failed'
9}
10
11interface TransactionItemProps {
12 transaction: Transaction
13 onPress: (id: string) => void
14}
15
16export function TransactionItem({ transaction, onPress }: TransactionItemProps) {
17 const sign = transaction.amount >= 0 ? '+' : ''
18 const formattedAmount = `${sign}${transaction.amount.toFixed(2)} ${transaction.currency}`
19 const badgeVariant = transaction.status === 'completed' ? 'success'
20 : transaction.status === 'pending' ? 'warning'
21 : 'error'
22
23 return (
24 <Pressable onPress={() => onPress(transaction.id)} style={styles.container}>
25 <View style={styles.content}>
26 <Typography variant="body">{transaction.concept}</Typography>
27 <Typography variant="caption" color={colors.textSecondary}>
28 {formatDate(transaction.date)}
29 </Typography>
30 </View>
31 <View style={styles.right}>
32 <Typography variant="body">{formattedAmount}</Typography>
33 <Spacer size="xs" />
34 <Badge variant={badgeVariant} label={transaction.status} size="sm" />
35 </View>
36 </Pressable>
37 )
38}Keeping objects separate from molecules prevents molecules from knowing about the domain. If FormField knows nothing about transactions or accounts, you can reuse it across any module without dragging business dependencies with it.
3. The Cost of Abstraction
Atomic Design shines in large teams and long-lived projects. But it has a cost that doesn't always get mentioned: navigational complexity.
To add a new button to a screen, a junior developer on an atomic project needs to:
- Decide whether it's an
atom,molecule, orobject. - Check whether something similar already exists across any of those layers.
- Create the component in the right folder.
- Import it at the level where it's used.
That process is reasonable when the codebase has hundreds of shared components. But in a 3-4 month project with a single developer, the same operation can be pure overhead with no real benefit.
Atomic Design isn't free. It introduces a taxonomy the whole team must learn and maintain. If the project doesn't have enough scale to amortize that cost, you can end up building a design system nobody else is going to use.
4. Feature-Based: The Pragmatic Alternative
In a vehicle metrics app I built from scratch (with OBD-II data reading over Bluetooth, real-time charts, and a native Kotlin module for BLE management), I chose a completely different organization: feature-based.
The idea is simple: instead of organizing by component type (atoms, molecules...), you organize by functionality. Everything belonging to a feature lives together: screens, components, hooks, services, types.
1src/
2├── features/
3│ ├── bluetooth/
4│ │ ├── components/ # BLE-specific components
5│ │ │ ├── DeviceList.tsx
6│ │ │ ├── ConnectionStatus.tsx
7│ │ │ └── ScanButton.tsx
8│ │ ├── hooks/
9│ │ │ ├── useBleScanner.ts
10│ │ │ └── useDeviceConnection.ts
11│ │ ├── screens/
12│ │ │ └── BluetoothSetupScreen.tsx
13│ │ └── services/
14│ │ └── bleService.ts
15│ ├── metrics/
16│ │ ├── components/
17│ │ │ ├── MetricGauge.tsx
18│ │ │ ├── SpeedChart.tsx
19│ │ │ └── MetricCard.tsx
20│ │ ├── hooks/
21│ │ │ └── useObdMetrics.ts
22│ │ └── screens/
23│ │ └── DashboardScreen.tsx
24│ └── diagnostics/
25│ ├── components/
26│ │ └── DiagnosticCode.tsx
27│ └── screens/
28│ └── DiagnosticsScreen.tsx
29└── shared/
30 ├── components/ # Genuinely shared components
31 ├── hooks/
32 └── utils/Why it worked here
The metrics app had three well-delimited domains: the BLE connection, real-time data visualization, and the diagnostics module. Each was autonomous enough to live in its own folder.
The immediate benefit was change localization. If I needed to modify how an OBD-II command is processed, I knew exactly where to look: features/metrics/hooks/useObdMetrics.ts. No need to trace across layers.
1// features/bluetooth/hooks/useBleScanner.ts
2export function useBleScanner() {
3 const [devices, setDevices] = useState<BleDevice[]>([])
4 const [isScanning, setIsScanning] = useState(false)
5
6 const startScan = useCallback(async () => {
7 setIsScanning(true)
8 setDevices([])
9
10 BleManager.scan([], 10, false).then(() => {
11 BleManager.addListener('BleManagerDiscoverPeripheral', (device) => {
12 setDevices(prev => {
13 if (prev.some(d => d.id === device.id)) return prev
14 return [...prev, device]
15 })
16 })
17 })
18 }, [])
19
20 useEffect(() => {
21 return () => { BleManager.stopScan() }
22 }, [])
23
24 return { devices, isScanning, startScan }
25}Colocation (keeping a component and its hook in the same folder) reduces orientation time when you come back to a module after weeks away. You don't need to remember which layer each piece lives in.
The best architecture is the one that lets a new developer (or yourself three months later) understand what the code does without needing a map.
5. The Boundary Between Both Approaches: shared/
The Achilles' heel of feature-based organization is the shared folder. It starts small and disciplined: a Button, a custom Text, some utility hook. Over time, without vigilance, shared becomes a junk drawer where components that "don't really know where they belong" end up.
The practical rule that works: a component moves to shared only when at least two different features use it. If only one feature uses it, it lives in that feature even if it looks generic.
1shared/
2├── components/
3│ ├── Button.tsx # Used by bluetooth + metrics + diagnostics
4│ ├── EmptyState.tsx # Used by bluetooth + diagnostics
5│ └── LoadingOverlay.tsx # Used in every screen
6├── hooks/
7│ ├── useAppTheme.ts # Accesses global theme
8│ └── usePermissions.ts # System permissions, cross-feature
9└── utils/
10 ├── formatters.ts
11 └── validators.tsAtomic Design handles this problem differently: atoms and molecules are by definition reusable, and objects encapsulate domain logic. The taxonomy comes built-in.
6. When to Use Each Approach
There's no universally correct answer. The choice depends on three variables: team size, project duration, and visual design complexity.
| Criterion | Atomic Design | Feature-based |
|---|---|---|
| Team | Multiple developers | One or two |
| Duration | Long-term, live product | Short sprint or fixed scope |
| Design system | Exists or will be built | No shared design with other apps |
| Domains | Many intertwined domains | Well-delimited domains |
| Onboarding | Requires learning the taxonomy | Intuitive: search by functionality |
Atomic Design works particularly well when there's an active design system, when designers and developers share the same component language. In that context, the taxonomy isn't overhead: it's the shared vocabulary. A designer who talks about "molecules" and a developer who has a /molecules folder are working with the same map.
Feature-based works better when delivery speed is the priority, the team is small, or the app's domains are independent enough. It doesn't require the team to know a taxonomy. Anyone who has worked with features knows where to look.
They're not mutually exclusive. You can have a feature-based structure with a shared/ layer that follows Atomic Design principles internally. What matters is that the convention is explicit and the team shares it.
7. What I Learned Working on Both
Working on both projects with these two philosophies left me with some clear takeaways.
On the banking project, the investment in Atomic Design paid off. When I arrived at a new domain (authentication, transfers, session management), the UI components were already there, documented and consistent. The Bizum confirmation screen and the biometric authentication screen share the same palette of atoms and molecules without duplication. That doesn't happen by accident.
On the metrics app, the feature-based organization was the right call from day one. The focus was the Bluetooth communication layer and OBD-II data processing. The UI was instrumental, not the product. Investing in a complete atomic hierarchy would have meant building infrastructure nobody was going to need.
The lesson isn't "Atomic Design is better" or "feature-based is better." It's that component architecture should follow the project's scale, not anticipate it. A good architecture for a team of ten on a five-year product can be a complexity trap for a team of two on a three-month project.
Start simple. Introduce structure when the pain is real: when you find duplication, when nobody knows where to add a new component, when onboarding a new developer takes longer than it should. That's the moment to formalize, not before.
