Back to Blog
DevelopmentJune 23, 202611 min read

Atomic Design in React Native: From Atoms to Screens

Atomic Design splits the UI into atoms, molecules, and organisms. In large projects it works well. In agile ones, it can become premature abstraction. When each approach makes sense, and when feature-based is the better call.

IM
Ignacio MelendezFull-Stack & Game Developer
Atomic Design in React Native: From Atoms to Screens

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:

Code
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 logic

Each 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

See project: Mobile Banking App →

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:

  1. Decide whether it's an atom, molecule, or object.
  2. Check whether something similar already exists across any of those layers.
  3. Create the component in the right folder.
  4. 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.

Code
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.

See project: Car Metrics App →

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.

Code
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.ts

Atomic 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.

CriterionAtomic DesignFeature-based
TeamMultiple developersOne or two
DurationLong-term, live productShort sprint or fixed scope
Design systemExists or will be builtNo shared design with other apps
DomainsMany intertwined domainsWell-delimited domains
OnboardingRequires learning the taxonomyIntuitive: 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.