Reanimated Worklets verstehen: UI-Thread vs. JS-Thread
AI generated
RN
native
React Native · Reanimated · Worklets
Reanimated Worklets verstehen
UI-Thread und JS-Thread sauber trennen

Worklets sind das Fundament von Reanimated: kleine JavaScript-Funktionen, die per Babel-Plugin so transformiert werden, dass sie direkt auf dem UI-Thread laufen, unabhängig von der Auslastung des JS-Thread. Wer verstehen will, warum Reanimated so performant ist und wo runOnJS sowie runOnUI wirklich hingehören, muss dieses Ausführungsmodell im Detail kennen, nicht nur die fertigen Hooks.

12 Min. Lesezeit Worklets runOnJS vs. runOnUI Reanimated 3.x

1. Zwei Ausführungskontexte, ein Rendering-Ziel

React Native trennt seit jeher die App-Logik vom eigentlichen Zeichnen der Oberfläche. Der JS-Thread führt Komponenten-Rendering, State-Updates und Business-Logik aus, während der UI-Thread (auf iOS der Main-Thread, auf Android der native UI-Thread) für das tatsächliche Layout und Zeichnen der nativen Views zuständig ist. Vor JSI musste jede Kommunikation zwischen beiden Seiten über die asynchrone, serialisierende Bridge laufen, was für Animationen mit 60 Bildern pro Sekunde ein spürbares Zeitbudget-Problem war.

Reanimated löst dieses Problem, indem Animationslogik nicht mehr als serialisierte Nachricht über die Bridge geschickt wird, sondern als kompilierter Code direkt auf dem UI-Thread ausgeführt wird. Das gelingt über JSI, das synchrone Funktionsaufrufe zwischen JavaScript und nativem Code erlaubt. Der Effekt: Eine Animation bleibt flüssig, selbst wenn der JS-Thread gerade eine große Liste neu rendert oder eine teure Berechnung durchführt, weil der UI-Thread davon komplett entkoppelt ist.

2. Die worklet-Direktive und das Babel-Plugin

Eine Funktion wird zum Worklet, indem sie entweder implizit von einer Reanimated-API wie useAnimatedStyle behandelt wird oder explizit mit der Direktive 'worklet' als erster Zeile im Funktionskörper markiert wird. Das Babel-Plugin von Reanimated erkennt diese Markierung zur Build-Zeit, extrahiert den Funktionskörper und hängt eine kompilierte, auf dem UI-Thread ausführbare Repräsentation an die Funktion an.

Damit dieser Mechanismus zuverlässig greift, muss der Plugin-Eintrag in babel.config.js als letztes Element im plugins-Array stehen, weil er den Code nach allen anderen Transformationen analysieren muss. Fehlt er oder steht er an falscher Stelle, kompiliert die App zwar, wirft aber zur Laufzeit kryptische Fehler über fehlende Funktionen auf dem UI-Thread, sobald ein Worklet tatsächlich ausgeführt wird.


// babel.config.js
module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [
    // Muss als LETZTES Element stehen, damit alle anderen
    // Transformationen bereits durchgelaufen sind.
    'react-native-reanimated/plugin',
  ],
};

3. Was beim Kompilieren eines Worklets technisch passiert

Zur Build-Zeit extrahiert das Babel-Plugin den Funktionskörper eines Worklets als Quelltext-String, ermittelt die verwendeten Closure-Variablen und hängt an die Funktion Metadaten wie einen Worklet-Hash und die serialisierten Anfangswerte der Closure an. Zur Laufzeit kompiliert die Reanimated-Runtime diesen String auf dem UI-Thread einmalig zu ausführbarem Code, meist über Hermes, das auf beiden Threads läuft und dadurch identisches Sprachverhalten garantiert.

Aus dieser Funktionsweise ergibt sich eine wichtige Einschränkung: Ein Worklet kann nicht beliebige Module importieren oder komplexe Klasseninstanzen aus dem umgebenden Scope nutzen, weil nur das serialisiert werden kann, was die JSI-Brücke abbilden kann, also im Wesentlichen primitive Werte, Arrays, Objekte und Shared Values. Der Versuch, eine große externe Bibliothek direkt in einem Worklet zu verwenden, scheitert deshalb meist schon beim Kompilieren.

4. Closures in Worklets: Werte werden kopiert, nicht referenziert

Wenn ein Worklet erzeugt wird, kopiert Reanimated die zu diesem Zeitpunkt sichtbaren Closure-Variablen in die serialisierte Repräsentation. Ein normaler React-State- oder Prop-Wert, der im Worklet gelesen wird, zeigt deshalb nur den Stand vom letzten Render, in dem das Worklet neu erzeugt wurde, und aktualisiert sich nicht automatisch, wenn sich der State danach ändert. Genau das überrascht viele Entwickler, die aus der klassischen React-Welt kommen und Closures als lebende Referenzen gewohnt sind.

Ein Shared Value verhält sich anders, weil nicht der Wert selbst, sondern das Shared-Value-Objekt mit seiner .value-Eigenschaft in die Closure kopiert wird. Da dieses Objekt auf beiden Threads dieselbe zugrunde liegende Speicherstelle referenziert, liefert der Zugriff auf .value innerhalb des Worklets immer den aktuellen Wert, unabhängig davon, wann das Worklet ursprünglich erzeugt wurde. Die Faustregel lautet deshalb: alles, was sich während der Lebensdauer eines Worklets ändern soll, gehört in einen Shared Value.


function Example({ threshold }: { threshold: number }) {
  // Falsch: 'threshold' wird beim Erstellen des Worklets kopiert.
  // Ändert sich die Prop später, sieht das Worklet den alten Wert.
  const staleStyle = useAnimatedStyle(() => {
    return { opacity: offset.value > threshold ? 1 : 0 };
  });

  // Richtig: Grenzwert selbst als Shared Value führen.
  const thresholdSV = useSharedValue(threshold);
  useEffect(() => {
    thresholdSV.value = threshold;
  }, [threshold]);

  const liveStyle = useAnimatedStyle(() => {
    return { opacity: offset.value > thresholdSV.value ? 1 : 0 };
  });

  return <Animated.View style={liveStyle} />;
}

5. runOnJS: sicher vom UI-Thread zurück zum JS-Thread wechseln

Sobald ein Worklet auf dem UI-Thread etwas auslösen soll, das nur auf dem JS-Thread existiert, etwa ein React-State-Update, eine Navigation oder ein Analytics-Aufruf, kommt runOnJS ins Spiel. Es plant den übergebenen Funktionsaufruf asynchron auf der Warteschlange des JS-Threads ein und serialisiert dabei die Argumente, ähnlich einer strukturierten Kopie, damit sie den Thread-Wechsel unbeschadet überstehen.

Wichtig ist, dass runOnJS keinen Rückgabewert an den aufrufenden Worklet-Code liefert und die Ausführung nicht synchron erfolgt. Wer runOnJS in jedem einzelnen Animationsframe aufruft, etwa bei jedem Pixel Scroll-Offset, flutet die JS-Thread-Warteschlange und untergräbt damit genau den Performance-Vorteil, den Reanimated eigentlich bringen soll. In der Praxis gehört runOnJS deshalb an Ereignis-Enden, nicht in laufende Animationsschleifen.


const gesture = Gesture.Pan()
  .onUpdate((event) => {
    translateX.value = event.translationX;
  })
  .onEnd(() => {
    translateX.value = withSpring(0);
    // Nur EINMAL am Gestenende auf den JS-Thread wechseln,
    // nicht bei jedem onUpdate-Aufruf.
    runOnJS(trackSwipeCompleted)();
  });

6. runOnUI: gezielt Code auf den UI-Thread schicken

runOnUI ist die Umkehrung von runOnJS: JS-Thread-Code, etwa ein Button-Handler oder ein Effect, schickt damit gezielt einen Worklet-Aufruf auf den UI-Thread. Das ist seltener nötig als runOnJS, weil die meisten Animationen bereits automatisch über Änderungen an Shared Values ausgelöst werden, aber es ist essenziell für imperative APIs wie das Messen einer View mit measure() oder das gezielte Ausführen einer Animation außerhalb des normalen Render-Zyklus.

Ein typischer Anwendungsfall ist eine Layout-Messung nach einem Tastendruck, bei der die aktuelle Position und Größe einer View auf dem UI-Thread benötigt wird, bevor eine darauf aufbauende Animation gestartet werden kann. Da measure() selbst ein Worklet ist, muss der Aufruf entweder aus einem bestehenden Worklet-Kontext oder explizit über runOnUI erfolgen.


const animatedRef = useAnimatedRef<Animated.View>();

function measureAndAnimate() {
  runOnUI(() => {
    'worklet';
    const layout = measure(animatedRef);
    if (layout === null) {
      return;
    }
    scale.value = withTiming(layout.width > 200 ? 1.2 : 1);
  })();
}

7. Typische Fehler: JS-Werte im Worklet lesen

Der häufigste Fehler ist der Zugriff auf ein Ref-Objekt (etwa einen normalen useRef) oder einen Zustand aus useState innerhalb eines Worklets, in der Erwartung, dass sich Änderungen live widerspiegeln. Weil beides nicht über die JSI-Brücke synchronisiert wird, sieht das Worklet entweder einen eingefrorenen Anfangswert oder wirft beim Zugriff auf ein current-Feld, das im Worklet-Kontext gar nicht existiert, einen Laufzeitfehler.

Ein zweiter klassischer Fehler ist der Aufruf einer importierten Hilfsfunktion, etwa aus einer Utility-Datei oder einer Bibliothek, die selbst nicht als Worklet kompiliert wurde. Reanimated meldet dann zur Laufzeit, dass die Funktion nicht synchron auf dem UI-Thread aufgerufen werden kann. Die Lösung ist, die betroffene Hilfsfunktion ebenfalls mit der Direktive 'worklet' zu versehen oder die Logik in eine bereits als Worklet erkannte Reanimated-Utility zu verlagern.

Ein dritter, subtilerer Fehler ist die Annahme, dass console.log innerhalb eines Worklets sofort und in Reihenfolge mit JS-Thread-Logs erscheint. Tatsächlich wird die Ausgabe über einen Proxy zurück auf den JS-Thread gespiegelt und kann dadurch zeitlich versetzt oder in anderer Reihenfolge im Terminal auftauchen, was beim Debuggen zu falschen Schlussfolgerungen über die tatsächliche Ausführungsreihenfolge führen kann.

8. Worklets debuggen: Logger, Sourcemaps und Fehleranalyse

Reanimated bringt einen konfigurierbaren Logger mit, der über configureReanimatedLogger Warnstufen und das Verhalten bei typischen Fallstricken steuert, etwa dem direkten Lesen eines Shared Values während eines normalen React-Renders statt innerhalb eines Worklets. Diese Warnungen sind in der Entwicklung Gold wert, weil sie genau die Fälle abdecken, die sonst erst als schwer reproduzierbarer Bug in der App auffallen würden.

Fehler, die innerhalb eines Worklets geworfen werden, liefern in aktuellen Reanimated-Versionen einen brauchbar symbolisierten Stacktrace, der über Metro aufgelöst wird. Bei älteren Setups oder Production-Builds ohne Sourcemaps bleibt oft nur ein minifizierter Stacktrace übrig, weshalb es sich lohnt, kritische Animationslogik zunächst im Development-Build mit aktivierter Symbolisierung zu testen, bevor sie in ein Release-Build wandert.

9. Praxis-Pattern: Scroll-Handler ganz ohne Bridge-Overhead

Ein realistisches Beispiel für sauber getrennte Verantwortlichkeiten ist ein Scroll-Handler, der die Kopfzeile beim Scrollen ein- und ausblendet, dabei aber nur am Ende der Scroll-Geste ein Analytics-Event auf dem JS-Thread auslöst. Der Offset selbst bleibt vollständig auf dem UI-Thread, während runOnJS gezielt nur an der einen Stelle eingesetzt wird, an der er tatsächlich nötig ist.

Dieses Muster, möglichst viel Logik im Worklet zu belassen und den JS-Thread nur an klar definierten Übergängen einzubeziehen, ist der Kern jedes performanten Reanimated-Codes. Wer stattdessen bei jeder kleinen Änderung zwischen den Threads hin und her springt, verliert genau den Vorteil, den das gesamte Worklet-Modell überhaupt erst bringt.


const headerVisible = useSharedValue(true);
let lastOffset = 0;

const scrollHandler = useAnimatedScrollHandler({
  onScroll: (event) => {
    const current = event.contentOffset.y;
    headerVisible.value = current < lastOffset || current < 20;
    lastOffset = current;
  },
  onEndDrag: () => {
    runOnJS(trackScrollSettled)(lastOffset);
  },
});

const headerStyle = useAnimatedStyle(() => ({
  transform: [{ translateY: withTiming(headerVisible.value ? 0 : -80) }],
}));
Aufgabe Läuft auf Empfohlenes API Typischer Stolperstein
Style aus Shared Value ableiten UI-Thread useAnimatedStyle State direkt statt über .value lesen
State-Update nach Geste JS-Thread runOnJS(setState) runOnJS vergessen, App stürzt ab
Bestehende Instanzmethode aufrufen UI-Thread runOnUI(fn)() Zielfunktion ist kein Worklet
Scroll-Position live auswerten UI-Thread useAnimatedScrollHandler Klassischen onScroll statt Worklet nutzen
Analytics-Event nach Animation senden JS-Thread runOnJS(trackEvent) Netzwerk-Aufruf direkt im Worklet versuchen
Layout einer View messen UI-Thread measure(animatedRef) Messung im JS-Thread mit veraltetem Layout

Mironsoft

React-Native-App-Entwicklung und Magento-Anbindung

Eine mobile App zum Magento-Shop, die wirklich rund läuft?

Wir entwickeln React-Native-Apps, die sauber an die Magento REST- oder GraphQL-API angebunden sind, von der ersten Codezeile bis zur Veröffentlichung im App Store und bei Google Play.

App-Konzeption

Architektur und Feature-Umfang einer Magento-angebundenen App gemeinsam planen.

Magento-API-Integration

Produktkatalog, Warenkorb und Checkout sauber an die Shop-API anbinden.

Store-Veröffentlichung

App Store- und Google-Play-Freigabeprozess ohne Stolperfallen begleiten.

10. Zusammenfassung

Reanimated Worklets

Kernidee

Worklets laufen kompiliert direkt auf dem UI-Thread und sind dadurch unabhängig von JS-Thread-Last.

runOnJS

Schickt einen Aufruf asynchron vom UI-Thread zurück zum JS-Thread, etwa für State-Updates.

runOnUI

Schickt einen Worklet-Aufruf gezielt vom JS-Thread auf den UI-Thread, etwa für imperative Messungen.

Größte Falle

Closures kopieren Werte bei Erstellung, State und Props ohne Shared Value bleiben im Worklet eingefroren.

11. FAQ: Reanimated Worklets

1Was genau ist ein Worklet in Reanimated?
Ein Worklet ist eine JavaScript-Funktion, die das Babel-Plugin von Reanimated zur Build-Zeit so transformiert, dass sie zusätzlich zur normalen JS-Ausführung auch direkt auf dem UI-Thread laufen kann, ohne bei jedem Aufruf über die Bridge zu kommunizieren.
2Muss ich die Direktive worklet immer manuell schreiben?
Nein. Viele Reanimated-APIs wie useAnimatedStyle, useDerivedValue oder useAnimatedScrollHandler markieren ihre Callback-Funktion automatisch als Worklet. Manuell nötig ist die Direktive vor allem bei eigenen Hilfsfunktionen, die von mehreren Worklets aus aufgerufen werden.
3Warum sieht mein State im Worklet einen veralteten Wert?
Weil Worklets Closures bei ihrer Erstellung kopieren, nicht als lebende Referenz halten. Ein normaler State- oder Prop-Wert wird deshalb nur mit dem Stand zum Zeitpunkt der letzten Render-Erstellung im Worklet sichtbar. Für live aktuelle Werte muss ein Shared Value verwendet werden.
4Wann brauche ich runOnJS statt runOnUI?
runOnJS wird verwendet, wenn Code, der aktuell auf dem UI-Thread läuft, etwas auf dem JS-Thread auslösen soll, zum Beispiel ein State-Update, eine Navigation oder ein Analytics-Aufruf. runOnUI ist der umgekehrte Fall: JS-Thread-Code, das einen Worklet-Aufruf gezielt auf den UI-Thread schickt.
5Kann runOnJS einen Rückgabewert liefern?
Nein, runOnJS plant den Aufruf asynchron ein und liefert selbst keinen Rückgabewert an den aufrufenden Worklet-Code zurück. Wer ein Ergebnis benötigt, muss es über einen Shared Value oder einen separaten State-Kanal zurückspielen.
6Warum stürzt meine App ab, wenn ich eine normale Funktion im Worklet aufrufe?
Weil die aufgerufene Funktion selbst nicht als Worklet kompiliert wurde und deshalb auf dem UI-Thread nicht existiert. Reanimated wirft in diesem Fall einen Laufzeitfehler über eine fehlende oder nicht synchron aufrufbare Funktion.
7Wie oft darf runOnJS pro Sekunde aufgerufen werden?
Technisch gibt es kein hartes Limit, aber jeder Aufruf erzeugt eine Nachricht auf der Warteschlange des JS-Threads. Bei 60 Aufrufen pro Sekunde, etwa in jedem Scroll-Frame, kann das den JS-Thread überlasten und den ursprünglichen Performance-Vorteil zunichtemachen.
8Funktioniert console.log innerhalb eines Worklets normal?
console.log wird innerhalb eines Worklets über einen speziellen Proxy zurück auf den JS-Thread gespiegelt und kann deshalb zeitlich verzögert oder in anderer Reihenfolge als reguläre JS-Thread-Logs erscheinen, was beim Debugging berücksichtigt werden muss.
9Muss ich für jede Hilfsfunktion in einem Worklet die Direktive setzen?
Ja, jede Funktion, die direkt aus einem Worklet heraus synchron aufgerufen wird und nicht bereits von Reanimated automatisch als Worklet erkannt wird, braucht die Direktive worklet, sonst schlägt der Aufruf zur Laufzeit fehl.
10Beeinflusst die neue Architektur mit Fabric und TurboModules das Worklet-Modell?
Die neue Architektur baut auf demselben JSI-Fundament auf, das Reanimated für Worklets nutzt, und verbessert dabei vor allem Konsistenz und Performance der Kommunikation. Das grundlegende Ausführungsmodell von Worklets bleibt aber inhaltlich gleich.