Technical Deep Dive
A technical tour of the Text and View wrappers, the render-time tax they impose, and how React Native Boost removes it at build time.
This page is the long version of How It Works. It walks through what the
Text and View wrappers actually do on every render, traces a single element from JSX all the way to
a native shadow node, and then shows exactly how the Babel plugin and its runtime helpers collapse that
work safely.
This has last been updated for React Native 0.85 and React Native Boost 1.3.0.
Text and View are not host components
It's tempting to think of Text and View as native platform primitives. Surprisingly (even to many senior RN developers), they aren't. They are ordinary JavaScript function components that each render a
lower-level component underneath:
TextrendersNativeText(the host componentRCTText), orNativeVirtualText(RCTVirtualText) when nested inside anotherText.ViewrendersViewNativeComponent(the host componentRCTView).
Those underlying components are the real primitives. At runtime they resolve to the plain string 'RCTText' and 'RCTView' respectively, and React reconciles that to a host component. It builds the shadow node directly, with no JavaScript component fiber in between.
const ViewNativeComponent: HostComponent<Props> =
NativeComponentRegistry.get<Props>('RCTView', () => ({
uiViewClassName: 'RCTView',
}));NativeComponentRegistry.get(...) registers the view config and returns the name string. ViewNativeComponent is literally 'RCTView', the same is true of NativeText:
export const NativeText: HostComponent<NativeTextProps> =
createReactNativeComponentClass('RCTText', () =>
createViewConfig(textViewConfig),
) as any;The wrapper exists to translate ergonomic, cross-platform props into the props the host component actually
understands, and to manage some JS-side state (specifically, TextAncestorContext and pressability). The crucial
detail is that all of that translation runs in JavaScript, on every render, even when your element uses none
of it. They are rare edge cases producing real overhead for every element.
Itemizing this tax
Text
The Text wrapper is a single 550+ line file. Each time React renders a <Text>, its function body runs top to bottom.
Even a bare <Text>Hello</Text> pays for:
- A function-component invocation and the React fiber that backs it.
- Destructuring ~35 named props out of
props; every one a property read, most resolving toundefined. aria-*→accessibility*translation: anaria-labelcoalesce, plus a five-fieldaccessibilityStatemerge that allocates a fresh object whenever anyaria-*state prop is set.disabled↔accessibilityState.disabledreconciliation.- A
Platform.selectto resolve the defaultaccessiblevalue. - An
isPressablecomputation andaccessibilityRole/rolelink defaulting. processColor(selectionColor)when a selection color is set.- A
numberOfLinesclamp (with a dev-onlyconsole.errorfor negatives). - A
flattenStylewalk of thestyleprop, followed byfontWeightnumber→string conversion,userSelectlookup, andverticalAlign→textAlignVerticalmapping, each potentially allocating anoverridesobject and a new[style, overrides]array:
let processedStyle = flattenStyle<TextStyleProp>(_style);
if (processedStyle != null) {
let overrides: ?{...TextStyleInternal} = null;
if (typeof processedStyle.fontWeight === 'number') {
overrides = overrides || ({} as {...TextStyleInternal});
overrides.fontWeight =
// $FlowFixMe[incompatible-type]
String(processedStyle.fontWeight) as TextStyleInternal['fontWeight'];
}
if (processedStyle.userSelect != null) {
_selectable = userSelectToSelectableMap[processedStyle.userSelect];
overrides = overrides || ({} as {...TextStyleInternal});
overrides.userSelect = undefined;
}
if (processedStyle.verticalAlign != null) {
overrides = overrides || ({} as {...TextStyleInternal});
overrides.textAlignVertical =
verticalAlignToTextAlignVerticalMap[processedStyle.verticalAlign];
overrides.verticalAlign = undefined;
}
if (overrides != null) {
// $FlowFixMe[incompatible-type]
_style = [_style, overrides];
}
}- A
useContext(TextAncestorContext)subscription, used to decide whether to render asRCTTextorRCTVirtualText. Once a fiber subscribes to a context, React must re-render it whenever that context value changes. - A decision about whether to wrap the output in a
<TextAncestorContext value={true}>provider which, when installed, is itself an extra fiber with its own context push/pop in the commit phase:
if (children == null) {
return nativeText;
}
// If the children do not contain a JSX element it would not be possible to have a
// nested `Text` component so we can skip adding the `TextAncestorContext` context wrapper
// which has a performance overhead. Since we do this for performance reasons we need
// to keep the check simple to avoid regressing overall perf. For this reason the
// `children.length` constant is set to `3`, this should be a reasonable tradeoff
// to capture the majority of `Text` uses but also not make this check too expensive.
if (Array.isArray(children) && children.length <= 3) {
let hasNonTextChild = false;
for (let child of children) {
if (child != null && typeof child === 'object') {
hasNonTextChild = true;
break;
}
}
if (!hasNonTextChild) {
return nativeText;
}
} else if (typeof children !== 'object') {
return nativeText;
}
return <TextAncestorContext value={true}>{nativeText}</TextAncestorContext>;Importantly, none of this is expensive individually. The problem is the multiplier. A list row might contain a dozen
Text elements, a screen a few hundred. And the tax is paid by
every single one, on every single UI commit.
View
The View wrapper is a lot leaner. Still, on each render it subscribes to TextAncestorContext, destructures 17 named props, runs a series of
aria-* translations, and decides whether to flip the context back to false for its descendants:
component View(ref?: React.RefSetter<ViewInstance>, ...props: ViewProps) {
const hasTextAncestor = use(TextAncestorContext);
const {
accessibilityState,
accessibilityValue,
'aria-busy': ariaBusy,
'aria-checked': ariaChecked,
'aria-disabled': ariaDisabled,
'aria-expanded': ariaExpanded,
'aria-hidden': ariaHidden,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-live': ariaLive,
'aria-selected': ariaSelected,
'aria-valuemax': ariaValueMax,
'aria-valuemin': ariaValueMin,
'aria-valuenow': ariaValueNow,
'aria-valuetext': ariaValueText,
id,
tabIndex,
...otherProps
} = props;From JSX to pixels: the call graph
Consider <Text>Hello</Text>. Here is the path it travels, with and without Boost.
Without Boost, React sees a function type and renders a component fiber. It has to run everything in the
section above before the wrapper can even return the RCTText element. With Boost, React skips straight to the host fiber. Everything between "wrapper fiber" and "host fiber" disappears.
The host side is identical either way. A single <Text>Hello</Text> always produces two shadow
nodes: a ParagraphShadowNode and a RawTextShadowNode child for the string (mounted as the RCTText and RCTRawText views).
Boost doesn't touch any of that. It only removes the JavaScript that ran before the host node was ever
created.
The same is true of the React tree. Boost deletes the wrapper fiber (and, where the wrapper would have
added one, the TextAncestorContext provider fiber):
Fewer fibers means less for React to build, diff, and commit on every frame (something our React tree node benchmark measures).
What Boost emits
Boost's core move is to swap the JSX element's type from Text/View to NativeText/NativeView
(imported from react-native-boost/runtime), after reproducing (at build time) whatever inescapable
work the wrapper would have done for the specific element.
Text
The simplest case bakes in the defaults the wrapper would have applied, too:
// in
<Text>Hello</Text>
// out
import { getDefaultTextAccessible as _getDefaultTextAccessible, NativeText as _NativeText } from 'react-native-boost/runtime';
import { Text } from 'react-native';
<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}>Hello</_NativeText>;accessible is
platform-specific (true on iOS, false on Android, omitted on web). When Metro tells Boost which
platform it's bundling for at compile time, the literal is inlined directly (accessible={true}); only when it doesn't (though it usually does!) it falls
back to the tiny getDefaultTextAccessible() runtime helper shown above. allowFontScaling and ellipsizeMode are simply the wrapper's defaults, inlined.
A fully static style is normalized at build time and emitted as a plain object with no runtime call at all:
// in
<Text style={{ color: 'red' }} />
// out (style normalized at build time)
<_NativeText style={{ color: 'red' }} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()} />;A dynamic style is routed through the processTextStyle runtime helper instead, where a reference
cache and a single StyleSheet.flatten are the win (more on that below):
// in
<Text style={dynamicStyle} />
// out
<_NativeText {..._processTextStyle(dynamicStyle)} allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()} />;Accessibility and aria-* props (and disabled) are collected into a single processAccessibilityProps
call; a negative numberOfLines literal is rewritten to 0; an id is renamed to nativeID; a static
userSelect is lifted out of the style into a top-level selectable prop.
View
View optimizes even with a style prop, since the wrapper passes style through unchanged:
<View /> → <_NativeView />
<View style={{ width: 1 }} /> → <_NativeView style={{ width: 1 }} />
<View id="x" /> → <_NativeView nativeID="x" />
<View tabIndex={0} /> → <_NativeView focusable={true} />
<View aria-live="off" /> → <_NativeView accessibilityLiveRegion="none" />Static aria-*/tabIndex/id props are translated into their native counterparts at build time.
Dynamic values, or aria-* state/value groups that the wrapper merges, are routed through the
processViewAccessibilityProps helper:
// in
<View aria-label={label} />
// out
<_NativeView {..._processViewAccessibilityProps(Object.assign({}, { 'aria-label': label }))} />;For the complete matrix of what's optimized, translated, and skipped, see Coverage & Bailouts.
Inside the plugin
The plugin is a single Babel visitor on JSXOpeningElement. For each element it runs the Text
optimizer and the View optimizer; each follows the same shape.
Proving the element is really react-native's
Two gates run first, and neither can be overridden:
isValidJSXComponentwalks from the JSX tag back to its binding and checks the imported name, soimport { Text as RNText } from 'react-native'still resolves toText.isReactNativeImportverifies the binding's import source is literally'react-native'. A localconst Text = SomeLib.Text, or an import from an internal deep path, does not qualify.
The bailout checks
Each optimizer defines a list of bailout checks. If any fires (and the line isn't marked
@boost-force), the element is left untouched and logged as skipped. The checks encode "for this prop
or shape, the wrapper does something the host can't, or that Boost can't prove equivalent". This could be blacklisted
props, an unresolvable spread, non-primitive Text children, an unsafe ancestor, and so on.
A subtle one worth highlighting: Text children must be provably primitive (a string or number). A
non-string child could smuggle in a nested element, including another <Text>, which would break the
TextAncestorContext invariant. So <Text>{name}</Text> is optimized only when name provably resolves
to a string/number; <Text>{maybeJSX()}</Text> is not.
Ancestor classification
The most intricate check is shared by both optimizers. An element nested inside a Text must render as
the inline host (RCTVirtualText), not the block host — so before optimizing, the plugin walks up
the tree and classifies the ancestor chain as one of:
safe— noTextancestor anywhere up the chain → optimize.text— areact-nativeTextis an ancestor → skip.unknown— an ancestor is a component the plugin can't resolve → skip, unless you opt in.
The walk is more than a parent scan. It resolves JSX member expressions (RN.View), follows aliased
identifiers, recurses into local function components (including memo()/forwardRef() wrappers and
props.children render paths), and uses WeakSets to break cycles in mutually-recursive components. When
it can't prove safety, it returns unknown and bails. False-positives (a missed optimization) are, as everywhere in the plugin, preferred over
false-negatives (a regression).
The unknown case is often safe in practice (third-party components rarely wrap children in Text), but there are still cases where optimizing components with an unknown ancestor could genuinely cause regressions. Therefore, Boost provides
explicit opt-in escape hatches: dangerouslyOptimizeViewWithUnknownAncestors and
dangerouslyOptimizeTextWithUnknownAncestors (see Configuration).
Rewriting and import injection
Once an element passes, the optimizer rewrites its props (the build-time translations above), then swaps the JSX type and injects the needed import. Imports are cached per file on the Babel file object, so even though the visitor fires thousands of times, each runtime symbol is imported exactly once.
The runtime helpers
react-native-boost/runtime is the small library the generated code calls into. For reference, its full
API lives on the Runtime Library page. The most load-bearing pieces are:
NativeText/NativeViewresolveunstable_NativeText/unstable_NativeViewfromreact-nativeat module load, and gracefully fall back to the standardText/Viewon web or any runtime where these exports are missing.processTextStyle(style)does the same flatten-and-normalize work as the wrapper, with one small difference: it caches by reference in aWeakMap. When you pass aStyleSheet.createreference, the first call flattens it and every later call returns the cached result. The wrapper re-flattens on every render. (Only stable references hit the cache; an inlinestyle={{…}}is a fresh reference each render, so it re-flattens either way.)processAccessibilityProps(props)mirrorsText'saria-*translation,accessibilityStatemerge,disabledreconciliation, and platformaccessibledefault. It runs only when the element actually has accessibility props.processViewAccessibilityProps(props)does the same forView's ARIA cluster (aria-labelledbysplit, live-region mapping, state/value aggregation,tabIndex→focusable).
Why it's safe
Boost is built on one strict contract: never change rendered output, layout, or the accessibility tree. Skipping a Text that could have been optimized just leaves
performance on the table; optimizing one that needed the wrapper would be a correctness bug with potential UI or behavior regressions.
Two escape hatches let you override the analysis when you know more than the plugin can prove:
@boost-force on a single element, or the
dangerouslyOptimize*WithUnknownAncestors options for whole-project ancestor resolution. Both are named
to signal that you've taken ownership of the correctness argument.
React Native's own roadmap
The wrappers are not meant to live forever. React Native's core team has clearly stated the goal is to reduce the JS overhead as much as possible, making direct use of the native host components unnecessary:
// Additional note: Our long term plan is to reduce the overhead of the <Text>
// and <View> wrappers so that we no longer have any reason to export these APIs.The first example for this is the
reduceDefaultPropsInText feature flag: introduced in RN 0.82, it made the
Text wrapper assign derived accessibility/aria-* props only when defined (and spread the rest)
instead of always passing a fixed set of named props, so that prop keys whose value was undefined no longer
crossed the JS→native boundary. The feature flag graduated to default behavior in RN 0.85.
It's a real improvement. However, the wrapper continues to destructure every prop, flatten styles, subscribe to context, and run as its own fiber on every render.
So, React Native core is optimizing the runtime, while Boost patches the call site at build-time. At some point in the future, in an ideal scenario, the JS wrapper components could become performant enough to make Boost genuinely unnecessary. However, today, it still offers a genuine performance improvement for a lot of apps.