Skip to content

double re-renders from riveViewRef and setValue #136

@ethanneff

Description

@ethanneff

Why

  • Stable riveViewRef — Upstream uses useState for the view, so when it becomes ready we get a new value → re-renders and effects run again. We need a ref so we can call riveViewRef.current?.playIfNeeded() in effects without the ref changing and causing double runs or flicker.

  • onViewReady — Gives us a one-time callback when the view is ready (e.g. for useViewModelInstance). The callback is stored in a ref so the ref setter stays stable.

  • Stable setPropertyValue — Upstream’s setter has [property, path] in its deps, so we get a new function when those change. We use the setter in effects; a changing setter re-runs effects and can double-fire triggers. The patch uses refs so the setter has [] deps and stays stable.

  • useViewModelInstance accepts RefObject — We want to pass the same riveViewRef we use for playback into useViewModelInstance. Upstream only takes a concrete RiveViewRef; the patch adds RefObject<RiveViewRef | null> and unwraps source.current.

Sample Patch

diff --git a/src/hooks/useRive.ts b/src/hooks/useRive.ts
index 078a77eeda7122f92a9d71a31db4890b808ec32d..601966848bf6c142ebadcef2ce01aa02cb5a0eb8 100644
--- a/src/hooks/useRive.ts
+++ b/src/hooks/useRive.ts
@@ -1,10 +1,17 @@
-import { useRef, useCallback, useState } from 'react';
+import { useRef, useCallback } from 'react';
 import type { RiveViewRef } from '@rive-app/react-native';
 
-export function useRive() {
+export interface UseRiveOptions {
+  /** Called when the Rive view is ready. Use this to e.g. set state and trigger a re-render if you need useViewModelInstance to see the ref. */
+  onViewReady?: (viewRef: RiveViewRef) => void;
+}
+
+export function useRive(options?: UseRiveOptions) {
   const riveRef = useRef<RiveViewRef>(null);
-  const [riveViewRef, setRiveViewRef] = useState<RiveViewRef | null>(null);
+  const riveViewRef = useRef<RiveViewRef | null>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const onViewReadyRef = useRef(options?.onViewReady);
+  onViewReadyRef.current = options?.onViewReady;
 
   const setRef = useCallback((node: RiveViewRef | null) => {
     if (riveRef.current !== node) {
@@ -25,16 +32,17 @@ export function useRive() {
       // or add this timeout natively and return false
       Promise.race([node?.awaitViewReady(), timeout])
         .then((result) => {
-          if (result === true) {
-            setRiveViewRef(node);
+          if (result === true && node) {
+            riveViewRef.current = node;
+            onViewReadyRef.current?.(node);
           } else {
             console.warn('Rive view ready check returned false');
-            setRiveViewRef(null);
+            riveViewRef.current = null;
           }
         })
         .catch((error) => {
           console.warn('Failed to initialize Rive view:', error);
-          setRiveViewRef(null);
+          riveViewRef.current = null;
         })
         .finally(() => {
           // Clear the timeout in both success and error cases
diff --git a/src/hooks/useRiveProperty.ts b/src/hooks/useRiveProperty.ts
index a49bdfe9bf8aa5302690b3e5ef81de415166bf85..f3b1c774fbce1fc23c5397b59cb5055027a05880 100644
--- a/src/hooks/useRiveProperty.ts
+++ b/src/hooks/useRiveProperty.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   type ObservableProperty,
   type ViewModelInstance,
@@ -46,6 +46,10 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
   // Initialize state with property's current value (if available)
   const [value, setValue] = useState<T | undefined>(() => property?.value);
   const [error, setError] = useState<Error | null>(null);
+  const propertyRef = useRef(property);
+  const pathRef = useRef(path);
+  propertyRef.current = property;
+  pathRef.current = path;
 
   // Sync value when property reference changes (path or instance changed)
   useEffect(() => {
@@ -86,26 +90,26 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
     };
   }, [options, property]);
 
-  // Set the value of the property
+  // Set the value of the property (stable reference)
   const setPropertyValue = useCallback(
     (valueOrUpdater: T | ((prevValue: T | undefined) => T)) => {
-      if (!property) {
+      const prop = propertyRef.current;
+      const pathStr = pathRef.current;
+      if (!prop) {
         setError(
           new Error(
-            `Cannot set value for property "${path}" because it was not found. Your view model instance may be undefined, or the path may be incorrect.`
+            `Cannot set value for property "${pathStr}" because it was not found. Your view model instance may be undefined, or the path may be incorrect.`
           )
         );
       } else {
         const newValue =
           typeof valueOrUpdater === 'function'
-            ? (valueOrUpdater as (prevValue: T | undefined) => T)(
-                property.value
-              )
+            ? (valueOrUpdater as (prevValue: T | undefined) => T)(prop.value)
             : valueOrUpdater;
-        property.value = newValue;
+        prop.value = newValue;
       }
     },
-    [property, path]
+    []
   );
 
   return [value, setPropertyValue, error, property as unknown as P];
diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts
index 7666dfc25ca1f658bf297e62b5fb0188f521fbaf..9fe0d5e870ea488a414988216f0b132ede5f9b5d 100644
--- a/src/hooks/useViewModelInstance.ts
+++ b/src/hooks/useViewModelInstance.ts
@@ -1,4 +1,5 @@
 import { useMemo, useEffect, useRef } from 'react';
+import type { RefObject } from 'react';
 import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro';
 import type { RiveFile } from '../specs/RiveFile.nitro';
 import type { RiveViewRef } from '../index';
@@ -27,11 +28,20 @@ export interface UseViewModelInstanceParams {
   onInit?: (instance: ViewModelInstance) => void;
 }
 
-type ViewModelSource = ViewModel | RiveFile | RiveViewRef;
+type ViewModelSource = ViewModel | RiveFile | RiveViewRef | RefObject<RiveViewRef | null>;
+
+function isRefObject(
+  source: ViewModelSource | null
+): source is RefObject<RiveViewRef | null> {
+  return source !== null && source !== undefined && 'current' in source;
+}
 
 function isRiveViewRef(source: ViewModelSource | null): source is RiveViewRef {
   return (
-    source !== null && source !== undefined && 'getViewModelInstance' in source
+    source !== null &&
+    source !== undefined &&
+    !isRefObject(source) &&
+    'getViewModelInstance' in source
   );
 }
 
@@ -43,8 +53,10 @@ function isRiveFile(source: ViewModelSource | null): source is RiveFile {
   );
 }
 
+type ResolvedViewModelSource = ViewModel | RiveFile | RiveViewRef | null;
+
 function createInstance(
-  source: ViewModelSource | null,
+  source: ResolvedViewModelSource,
   name: string | undefined,
   useNew: boolean
 ): { instance: ViewModelInstance | null; needsDispose: boolean } {
@@ -146,19 +158,23 @@ export function useViewModelInstance(
   const required = params?.required ?? false;
   const onInit = params?.onInit;
 
+  const resolvedSource: ResolvedViewModelSource = isRefObject(source)
+    ? source.current
+    : (source as ResolvedViewModelSource);
+
   const prevInstanceRef = useRef<{
     instance: ViewModelInstance | null;
     needsDispose: boolean;
   } | null>(null);
 
   const result = useMemo(() => {
-    const created = createInstance(source, name, useNew);
+    const created = createInstance(resolvedSource, name, useNew);
     if (created.instance && onInit) {
       onInit(created.instance);
     }
     return created;
     // eslint-disable-next-line react-hooks/exhaustive-deps -- onInit excluded intentionally
-  }, [source, name, useNew]);
+  }, [resolvedSource, name, useNew]);
 
   // Dispose previous instance if it changed and needed disposal
   if (
diff --git a/src/index.tsx b/src/index.tsx
index f37bd9ebaba7743baadcfc43e64a98ad1f24b410..28c874fb0d0e5977de43edb02a05e0118c1f65e5 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -43,7 +43,7 @@ export { RiveColor } from './core/RiveColor';
 export { type RiveEvent, RiveEventType } from './core/Events';
 export { type RiveError, RiveErrorType } from './core/Errors';
 export { ArtboardByIndex, ArtboardByName } from './specs/ArtboardBy';
-export { useRive } from './hooks/useRive';
+export { useRive, type UseRiveOptions } from './hooks/useRive';
 export { useRiveNumber } from './hooks/useRiveNumber';
 export { useRiveString } from './hooks/useRiveString';
 export { useRiveBoolean } from './hooks/useRiveBoolean';

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions