DEV Community

Cover image for Control SwiftUI and Compose State Synchronously with Worklets in Expo UI
Dan for Expo

Posted on • Originally published at expo.dev

Control SwiftUI and Compose State Synchronously with Worklets in Expo UI

React Native developers have long dealt with the friction of bridging JavaScript with native UI threads. Every time you need to update native state, you send a message across the bridge, wait for the round-trip, and hope the user doesn't notice the delay. Expo UI in SDK 56 changes this with worklet integration.

You can now control SwiftUI and Compose state directly on the UI thread, with zero JavaScript round-trips. Here's what that looks like:

import { Host, TextInput, useNativeState } from '@expo/ui';

export default function Screen() {
  const value = useNativeState('');

  return (
    <Host matchContents>
      <TextInput
        value={value}
        placeholder="Type something"
        onChangeText={(value) => {
          'worklet';
          // Runs synchronously on the UI thread, on every keystroke.
          console.log('[UI thread] typed:', value);
        }}
      />
    </Host>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: you'll need react-native-reanimated and react-native-worklets installed in your project for this to work.

How this actually works

Two pieces make this possible:

useNativeState creates an ObservableState - a SharedObject that lives in native code and gets observed by both SwiftUI and Compose. On iOS, it maps to an ObservableObject. On Android, it's a MutableState. Both platforms watch this state and re-render when it changes.

Worklet callbacks like onTextChange run directly on the UI thread when the native view fires its event. No bridge crossing required.

Put them together: each keystroke in the TextField updates the shared text state, executes your worklet, and triggers SwiftUI and Compose to re-render. All on the UI thread, all in the same frame.

If you know SwiftUI, this pattern should click immediately. The TypeScript above translates almost directly:

struct Screen: View {
  @State var text = ""

  var body: some View {
    TextField("Type something", text: $text)
      .onChange(of: text) { _, newValue in
        print("[UI thread] typed:", newValue)
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

useNativeState acts like @State, text={text} works like TextField(text: $text), and the worklet onTextChange behaves like .onChange(of:). Compose developers will recognize the same shape with mutableStateOf and onValueChange.

Input masking without flicker

The immediate win here is input masking that actually works. Since the worklet can modify text.value in the same frame as the keystroke, users never see the unmasked character. No async delays, no visible corrections.

Take this credit card field that formats 4242424242424242 into 4242 4242 4242 4242 as you type:

import { Host, TextInput, useNativeState } from '@expo/ui/swift-ui';

export default function CardNumberField() {
  const value = useNativeState('');

  return (
    <Host matchContents>
      <TextInput
        value={value}
        placeholder="Card number"
        onChangeText={(value) => {
          'worklet';
          const digits = value.replace(/\\D/g, '').slice(0, 16);
          const masked = digits.replace(/(.{4})/g, '$1 ').trim();
          text.value = masked;
        }}
      />
    </Host>
  );
}
Enter fullscreen mode Exit fullscreen mode

The formatting happens instantly on the UI thread. You can use this pattern for phone numbers, dates, currency formatting, or any scenario where display text needs to differ from raw input.

Beyond input masking

Worklet integration does more than solve input problems. It gives Expo UI a path to expose synchronous alternatives alongside existing async APIs. You pick the approach that fits your interaction needs.

This same native state + worklet pattern opens up much more of SwiftUI and Compose's state-driven APIs for React Native. Input masking is just the beginning.

Getting started

Worklet support works in both @expo/ui/swift-ui and @expo/ui/jetpack-compose. It shipped in SDK 56. TextInput supports sync callbacks now, with more form controls coming in future releases.

This post is based on content from the Expo blog. Follow @expo for more React Native content.

Top comments (0)