Compile-time styling to kill runtime cost (Tamagui & NativeWind extraction)
25 Aug 2025If 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
- No style object churn: Inline objects (
style={{ padding: 12, color }}) allocate new memory every render. That breaksReact.memo/PureComponentshallow comparisons and pressures the GC. - Smaller JS heap: Extracted styles are deduplicated and referenced by small numeric IDs (via
StyleSheet.create) or precompiled style constants. Less allocation, less GC. - Faster renders: Less work in the render phase and fewer needless re-renders once styles become stable constants.
What âextractionâ means in practice
- NativeWind: at build time it compiles Tailwind utility strings into
StyleSheet.createobjects and precomputes conditional logic, leaving a minimal runtime to apply them. - Tamagui: a compiler analyzes your JSX/styled usage and lowers it to platform-native primitives, flattening view trees and extracting styles (atomic on the web, native constants on RN).
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)
- Dynamic string classnames block extraction: Avoid
bg-${color}-600,px-+ size, or computed utility names. Prefer enumerated branches (ternaries,clsxwith static keys) or safelist the exact final class names intailwind.config.js. This keeps NativeWind compiling to constants instead of punting to runtime. - 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.
- 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. - 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>
)
}
Rollout checklist (my recommended order)
- Turn on extraction (NativeWind Babel preset + Metro; Tamagui Babel plugin): Ship it behind a branch and profile a few hot screens.
- Kill inline objects by replacing them with compile-time classes/props. Keep any truly dynamic numeric values in
style. - Introduce variants for repeated patterns (
tone,size,state): Co-locate tokens so theyâre compiler-visible. - Safelist edge utilities that only appear in rare branches (loading/error) to ensure theyâre generated.
- 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.