Control SwiftUI and Compose State Synchronously with Worklets in Expo UI

Published: (May 28, 2026 at 04:20 PM EDT)
3 min read
Source: Dev.to

Source: Dev.to

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 (
    
       {
          'worklet';
          // Runs synchronously on the UI thread, on every keystroke.
          console.log('[UI thread] typed:', value);
        }}
      />
    
  );
}

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:

  1. useNativeState creates an ObservableState—a SharedObject that lives in native code and is observed by both SwiftUI and Compose.

    • iOS: maps to an ObservableObject.
    • Android: maps to a MutableState.

    Both platforms watch this state and re‑render when it changes.

  2. Worklet callbacks (e.g., onChangeText) run directly on the UI thread when the native view fires its event, eliminating any bridge crossing.

Putting them together: each keystroke in the TextInput updates the shared text state, executes your worklet, and triggers SwiftUI and Compose to re‑render—all on the UI thread, within the same frame.

If you know SwiftUI, the pattern feels familiar. 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)
      }
  }
}

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

Input masking without flicker

The immediate win is input masking that actually works. Because 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.

Example: a 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 text = useNativeState('');

  return (
    
       {
          'worklet';
          const digits = value.replace(/\D/g, '').slice(0, 16);
          const masked = digits.replace(/(.{4})/g, '$1 ').trim();
          text.value = masked;
        }}
      />
    
  );
}

The formatting happens instantly on the UI thread. The same pattern can be applied to phone numbers, dates, currency formatting, or any scenario where displayed text differs from raw input.

Beyond input masking

Worklet integration does more than solve input‑related problems. It gives Expo UI a path to expose synchronous alternatives alongside existing async APIs, letting you choose the approach that fits your interaction needs.

The 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 now supports sync callbacks, 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.

0 views
Back to Blog

Related posts

Read more »