Skip to main content

Worklets

With react-native-worklets installed (it ships with Reanimated 4), three things run on the UI thread while the native input handles the keystroke, before a frame is drawn: a transform that rewrites the edit, any event handler marked 'worklet', and the shared values useNitroInputState keeps current from those handlers. None of them touch the JS thread, so a mask written in JS applies with no flicker and an animation can follow the field without a render. The worklet props work the same whether the field reflows or not.

A mask written in JS​

import { NitroInput, type NitroInputTransform } from 'react-native-nitro-input'

// Lowercase, no symbols, always led by "@".
const usernameTransform: NitroInputTransform = ({ text }) => {
'worklet'
const cleaned = text.replace(/[^0-9a-zA-Z_]/g, '').toLowerCase()
return { text: cleaned ? '@' + cleaned : '' }
}

<NitroInput placeholder="@username" autoCapitalize="none" transform={usernameTransform} />

transform receives { text, previousText, selection, previousSelection } (code point offsets) and returns { text?, selection? }, or null to keep the edit as is. Without a selection the caret keeps its place relative to the edit. In mode="number" it runs after the native formatter. Create it once, at module scope or in useCallback: a new function re-registers the worklet on every render.

For a fixed pattern, mode="mask" does this natively with no worklets at all. transform is for what a pattern cannot express.

Every event can be a worklet​

Mark any handler 'worklet' and it runs on the UI thread instead of the JS thread: onChangeText, onChangeValue, onFocus, onBlur, onSelectionChange, onSubmitEditing, onEndEditing and onKeyPress. It is opt-in per handler, and a worklet handler is not also called on the JS thread, so nothing fires twice. Plain functions keep working as before.

import { useSharedValue } from 'react-native-reanimated'

const progress = useSharedValue(0)
const focused = useSharedValue(false)

<NitroInput
mode="number"
onChangeValue={(value) => {
'worklet'
progress.value = Number.isNaN(value) ? 0 : value / 10
}}
onFocus={() => {
'worklet'
focused.value = true
}}
onBlur={() => {
'worklet'
focused.value = false
}}
/>

Worklet handlers get the same event objects as the JS ones, except target is always 0, and eventCount is 0 where native does not send one: both are JS-thread bookkeeping with nothing to read on the UI runtime. The shapes are exported as WorkletTextEvent, WorkletFocusEvent, WorkletSelectionEvent and WorkletKeyPressEvent.

The field's state as shared values​

useNitroInputState wires those worklet handlers into shared values, so an animation can read the field on the UI thread without a single re-render. Reanimated is not a dependency of the library: pass its useSharedValue in, which keeps the import out of the bundle of anyone who does not animate.

import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
import { NitroInput, useNitroInputState } from 'react-native-nitro-input'

function Field() {
const field = useNitroInputState(useSharedValue)
const ring = useAnimatedStyle(() => ({
borderColor: withTiming(field.focused.value ? '#16a34a' : 'transparent'),
}))
return (
<Animated.View style={[styles.ring, ring]}>
<NitroInput variant="outlined" label="Worklet driven" {...field.handlers} />
</Animated.View>
)
}

field.text, field.value, field.focused and field.selection are shared values; field.handlers are the worklets that keep them current (onChangeText, onChangeValue, onFocus, onBlur and onSelectionChange). Typing in that field runs nothing on the JS thread and re-renders nothing.

Each prop takes one handler, so an onChangeText of your own passed after the spread replaces the worklet and field.text stops updating. Read the shared value on the UI thread instead, or reach the JS thread from it with Reanimated's useAnimatedReaction and runOnJS.

Reaching a library from a worklet​

A worklet can only reach what it closes over, unless worklets run in Bundle Mode, which gives them the whole bundle. That is what lets a transform use a real library, here libphonenumber-js formatting a number as it is typed:

import { AsYouType } from 'libphonenumber-js/min'

const phoneTransform: NitroInputTransform = ({ text }) => {
'worklet'
return { text: new AsYouType('US').input(text) }
}

<NitroInput placeholder="(555) 555-5555" keyboardType="phone-pad" transform={phoneTransform} />

Bundle Mode needs three things, and the example app in the repo has all of them (example/babel.config.js, example/metro.config.js, patches/):

  • The Babel plugin as ['react-native-worklets/plugin', { bundleMode: true, importForwarding: { moduleNames: ['libphonenumber-js/min'] } }]. Every library a worklet imports has to be listed by its exact module name, or it is captured as a remote function the UI thread cannot call.
  • getBundleModeMetroConfig(config) from react-native-worklets/bundleMode in metro.config.js.
  • The small Metro patch Software Mansion publishes next to it. The Babel plugin writes worklet modules while bundling, and unpatched Metro fails with "Failed to get the SHA-1".

strictGlobal: true works too: the library's own worklets name nothing they do not bind but UI runtime globals, and the example app runs with it on.

Without worklets​

Everything degrades cleanly. Without react-native-worklets the transform and the worklet handlers are ignored with one console warning, plain handlers keep working, and the native code compiles without it.

How it runs​

This is the same mechanism as react-native-transformer-text-input: the worklets UI runtime is handed to native once, and the worklet is called with runSync inside the edit, so there is no bridge hop and no caret flicker.