Compile-time styling to kill runtime cost (Tamagui & NativeWind extraction)

If you’re still pushing style objects around at runtime in React Native, you’re paying a tax you don’t need to. Inline objects and ad-hoc merges inflate the JS heap, trigger garbage collection, and sabotage memoization because object identity changes on every render. The antidote: compile-time styling. With Tamagui’s compiler and NativeWind’s className transform, your styles become precomputed, atomic/native constants — so your components render faster and your app’s JS stays calm.

Below is a practical, opinionated guide to switch your project to compile-time styling, what to watch out for, and how to structure a future-proof design system.


Why compile-time styling

What “extraction” means in practice


Do this with NativeWind (Expo-friendly)

Install + wire it up

npm i nativewind react-native-reanimated@~3 \
  react-native-safe-area-context
npm i -D tailwindcss prettier-plugin-tailwindcss
npx tailwindcss init

tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./App.{js,jsx,ts,tsx}',
    './components/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: { extend: {} },
  plugins: [],
  // optional: ensure rarely-hit classes are always generated
  safelist: ['bg-red-600','bg-blue-600','px-4','py-2']
}

babel.config.js

module.exports = function (api) {
  api.cache(true)
  return {
    presets: [
      ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
      'nativewind/babel',
    ],
  }
}

metro.config.js

const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')

const config = getDefaultConfig(__dirname)
module.exports = withNativeWind(config, { input: './global.css' })

global.css

@tailwind base;
@tailwind components;
@tailwind utilities;

These steps enable the build-time className→StyleSheet extraction and wire Metro to include the generated CSS for web targets.

Use static classnames, keep conditionals enumerated

Bad (blocks extraction, forces runtime work):

// DO NOT: string interpolation that the compiler
// can’t see at build time
<Text className={`text-${size} bg-${color}-600`} />

Good (enumerate branches with complete strings):

<Text className={isLg ? 'text-lg' : 'text-base'} />

<Pressable
  className={clsx(
    'px-4 py-2 rounded',
    kind === 'primary' ? 'bg-blue-600' : 'bg-gray-600',
    disabled && 'opacity-50'
  )}
/>

Why so strict? Tailwind/NativeWind compilers scan complete, static strings; dynamic string building often isn’t detected, so the style never gets generated/extracted. If you must be dynamic, safelist the full set or map from known tokens.

Keep truly dynamic values in style, not in classes

Some values are genuinely data-driven (e.g., a bar’s width). Keep the base styles extracted, and pass the unextractable bit via style:

<View className="h-2 rounded bg-green-600"
  style={{ width: progress * 2 }} />

This keeps 95% of styles compiled while only a tiny numeric prop changes at runtime.


Do this with Tamagui (RN + Web, design-system forward)

Tamagui shines when you want typed tokens, variants, and shared RN/Web components. The compiler can flatten your trees and precompute variants for you.

Add the compiler

npm i tamagui @tamagui/babel-plugin

babel.config.js

module.exports = {
  plugins: [
    ['@tamagui/babel-plugin', {
      config: './tamagui.config.ts',
      components: ['tamagui'],
      // faster DX if you disable in dev; enable for production
      disableExtraction: process.env.NODE_ENV === 'development',
      // try this on recent versions if you rely on theme tokens
      // on native:
      // experimentalFlattenThemesOnNative: true,
    }],
  ],
}

On RN, the Babel plugin is optional, but turning it on for production is where you harvest the wins. It lowers styled usage to native primitives and precomputes styles. ďżź

Co-locate tokens & variants (your design system)

tamagui.config.ts

import { createTamagui } from 'tamagui'

const tokens = {
  color: {
    primary: '#2563eb',
    gray1: '#111827',
    gray5: '#6b7280',
  },
  space: {0: 0, 2: 8, 3: 12, 4: 16},
  radius: {sm: 8, md: 12},
  size: {sm: 32, md: 40},
}

const themes = {
  light: {background: 'white', color: 'gray1'},
  dark: {background: '#0b0b0b', color: 'gray5'},
}

export default createTamagui({
  tokens,
  themes,
  fonts: { /* … */},
})

Button.tsx

import { styled, Stack, Text } from 'tamagui'

export const Button = styled(Stack, {
  name: 'Button',
  ai: 'center',
  jc: 'center',
  br: '$md',
  px: '$4',
  h: '$md',
  variants: {
    size: {
      sm: {h: '$sm', px: '$3'},
      md: {h: '$md', px: '$4'},
    },
    tone: {
      primary: {bg: '$primary'},
      neutral: {bg: '$gray5'},
    },
  } as const,
  defaultVariants: {size: 'md', tone: 'primary'},
})

export function ButtonText(props) {
  return <Text col = "$color" { ...props } />
}

By defining tokens and variants in one place, the compiler can “see” them and extract efficiently. Tamagui’s design-system docs emphasize keeping optimized components in known modules so the compiler can statically analyze them. 

Usage:

<Button tone="neutral">
  <ButtonText>Save</ButtonText>
</Button>

Expo/Metro notes

If you’re on Expo, follow the Tamagui Expo guide and add the Babel plugin as above; extraction can be disabled in dev for faster HMR and enabled in CI/releases.


Pitfalls (and how to dodge them)

  1. Dynamic string classnames block extraction: Avoid bg-${color}-600, px- + size, or computed utility names. Prefer enumerated branches (ternaries, clsx with static keys) or safelist the exact final class names in tailwind.config.js. This keeps NativeWind compiling to constants instead of punting to runtime.
  2. Hiding tokens from the compiler: In Tamagui, if you define styled components deep in app code that the compiler doesn’t track, you’ll miss optimizations. Keep your primitives and DS components in a package or folder referenced by the compiler’s components option, and whitelist any constants files (colors, spacing) that should be read at build time.
  3. Mixing too many one-off dynamic styles: If everything is data-driven, you’ll fall back to runtime anyway. Consolidate into variants: tone, size, state (pressed/disabled). Leave only unavoidable values (e.g., width tied to progress) as inline numeric styles.
  4. Forgetting web: Both stacks are great on RN, but the real beauty is RN+Web. NativeWind reuses Tailwind’s CSS on web; Tamagui extracts atomic CSS and flattens the tree. If you care about SSR and TTI on web, this is free performance.

Before vs after (micro example)

Before (runtime churn):

function Card({ color }) {
  // new object each render, breaks memo
  return (
    <View style={{padding: 16, borderRadius: 12,
      backgroundColor: color}}>
        <Text style={{fontWeight: '600'}}>Hello</Text>
    </View>
  )
}

After (extracted): NativeWind

function Card({ tone = 'primary' }) {
  const bg = tone === 'primary' ? 'bg-blue-600' : 'bg-gray-600'
  return (
    <View className={clsx('p-4 rounded-xl', bg)}>
      <Text className="font-semibold">Hello</Text>
    </View>
  )
}

After (extracted): Tamagui

import { Stack, Text } from 'tamagui'

function Card({ tone = 'primary' }) {
  return (
    <Stack p="$4" br="$md"
      bg={tone === 'primary' ? '$primary' : '$gray5'}>
        <Text fontWeight="600">Hello</Text>
    </Stack>
  )
}

  1. Turn on extraction (NativeWind Babel preset + Metro; Tamagui Babel plugin): Ship it behind a branch and profile a few hot screens.
  2. Kill inline objects by replacing them with compile-time classes/props. Keep any truly dynamic numeric values in style.
  3. Introduce variants for repeated patterns (tone, size, state): Co-locate tokens so they’re compiler-visible.
  4. Safelist edge utilities that only appear in rare branches (loading/error) to ensure they’re generated.
  5. Measure: with React DevTools Profiler + Flipper Memory, compare JS heap peaks and commit times before/after. You should see fewer re-renders and smaller GC spikes.

Bottom line: compile-time styling is the cleanest path to removing style churn in React Native. NativeWind gives you Tailwind ergonomics with constant-time styles; Tamagui gives you a typed design system with a smart compiler. Use them together or separately—but stop creating style objects on every render. Your app (and your users) will feel the difference.