javascriptreact-nativereact-hooksreact-native-reanimatedreact-native-hermes

React Native and Reanimated Error: Rendered more hooks than during the previous render when adding objects to an array


I am having trouble allowing a user to dynamically add objects to an array that is rendered to the screen using useSharedValues()

This is my main screen where objects are added to an array

import { useRoute } from '@react-navigation/native';
import React, { useState } from 'react';
import { Pressable, View } from 'react-native';

import PlayerView from '../../components/PlayerView/PlayerView';
import deck from './deck';
import styles from './styles';

export default function GameScreen() {
  const names = route.params?.names;

  const [players, setPlayers] = useState(() => initPlayers());
  const [round, setRound] = useState(1);
  const [currentDeck, setCurrentDeck] = useState(deck);
  const [tlayout, setLayout] = useState([]);
  const [playerTurnCounter, setPlayerTurnCounter] = useState(0); // used to keep track of whose turn it is
  const [deckSelected, setDeckSelected] = useState(false);

  function initPlayers() {
    const players = [];
    for (let i = 0; i < names.length; i++) {
      players.push({ id: i, name: names[i].name, points: 0, hand: [], subHand: [[], [], [], []] });
    }
    return players;
  }

  function handleDeckPress() {
    if (deckSelected) {
      const rand = Math.floor(Math.random() * currentDeck.length);
      const card = currentDeck[rand];
      currentDeck.splice(rand, 1);
      setCurrentDeck(currentDeck);
      addDeckCardToPlayerHand(card);
      setDeckSelected(false);
    } else if (!deckSelected) {
      setDeckSelected(true);
    }
  }

  const addDeckCardToPlayerHand = (card) => {
    const test = players[playerTurnCounter].hand;
    const newArray = [...test, { card, id: 3 }];

    setPlayers(
      players.map((player) => {
        if (player.id === playerTurnCounter) {
          return { ...player, hand: newArray };
        }
        return player;
      }),
    );
  };

  return (
    <View>
      <Pressable onPress={() => handleDeckPress()} hitSlop={{ bottom: 10 }}>
        {currentDeck.map((card, index) => (
          <View
            onLayout={(event) => {
              const { x, y, width, height } = event.nativeEvent.layout;
            }}
          />
        ))}
      </Pressable>
      <View
        onLayout={(event) => {
          event.target.measure((x, y, width, height, pageX, pageY) => {
            const t = tlayout;
            t.push({ x, y, width, height, pageX, pageY });
            // console.log('subhand one: ', t);
            setLayout(t);
          });
        }}
        style={styles.subHandOne}
      />
      <PlayerView
        style={styles.playerView}
        players={players}
        setPlayers={setPlayers}
        playerTurnCounter={playerTurnCounter}
        layout={tlayout}
      />
    </View>
  );
}

The 'player', which contains the array of objects to be rendered, is passed to the following component:

import React from 'react';
import { View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import Card from '../Card/Card';
import CardList from '../CardList/CardList';
import styles from './styles';

export default function PlayerView({ players, setPlayers, playerTurnCounter }) {
  const player = initPlayer();

  function initPlayer() {
    for (let i = 0; i < players.length; i++) {
      if (players[i].id === playerTurnCounter) {
        return players[i];
      }
    }
    return players[0];
  }

  if (player.hand.length > 0) {
    return (
      <GestureHandlerRootView style={styles.container}>
        <View style={styles.cards}>
          <CardList players={players} setPlayers={setPlayers} playerTurnCounter={playerTurnCounter}>
            {player.hand.map((item) => (
              <Card key={item.id} item={item.card} count={item.id} />
            ))}
          </CardList>
        </View>
      </GestureHandlerRootView>
    );
  }
  return (
    <GestureHandlerRootView style={styles.container}>
      <View style={styles.cards} />
    </GestureHandlerRootView>
  );
}


CardList (which calculates the hand array's positions):

/* eslint-disable react-hooks/rules-of-hooks */
/* eslint-disable react/no-array-index-key */
/* eslint-disable react/prop-types */
import React, { useState } from 'react';
import { Dimensions, View } from 'react-native';
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated';

import SortableCard from '../SortableCard/SortableCard';
import styles from './styles';

const containerWidth = Dimensions.get('window').width * 2;

export default function CardList({ children, players, setPlayers, playerTurnCounter }) {
  const [ready, setReady] = useState(false);
  const offsets = children.map(() => ({
    order: useSharedValue(0),
    width: useSharedValue(0),
    height: useSharedValue(0),
    x: useSharedValue(0),
    y: useSharedValue(0),
    originalX: useSharedValue(0),
    originalY: useSharedValue(0),
  }));

  if (!ready) {
    return (
      <View style={styles.row}>
        {children.map((child, index) => {
          return (
            <View
              key={child.key}
              onLayout={({
                nativeEvent: {
                  layout: { x, y, width, height }, // these give the original layout positions of the cards (and their sizes) when loaded in
                },
              }) => {
                const offset = offsets[index];
                offset.order.value = -1;
                offset.width.value = width / children.length + 10;
                offset.height.value = height;
                offset.originalX.value = x;
                offset.originalY.value = y;
                runOnUI(() => {
                  'worklet';

                  if (offsets.filter((o) => o.order.value !== -1).length === 0) {
                    runOnJS(setReady)(true);
                  }
                })();
              }}>
              {child}
            </View>
          );
        })}
      </View>
    );
  }
  return (
    <View style={styles.container}>
      {children.map((child, index) => (
        <SortableCard
          key={child.key}
          offsets={offsets}
          index={index}
          players={players}
          setPlayers={setPlayers}
          containerWidth={containerWidth}
          playerTurnCounter={playerTurnCounter}>
          {child}
        </SortableCard>
      ))}
    </View>
  );
}

The SortableCard component contains the code responsible for animated gestures.

My issue is when I try to add an object to this array (called 'hand') to the component that renders the objects in 'hand' to the screen, I get the error: Error: Rendered more hooks than during the previous render , when I am expecting this new object added to the array to be rendered to the screen.

I understand that this error is due to the number of hooks in the offsets array in CardList changing when the addDeckCardToPlayerHand callback is called which changes the amount of hooks rendered. So my question is, how can I allow users to dynamically create objects that will be rendered to the screen if the total amount of useSharedValues is unknown at the start?


Solution

  • Issue

    The issue in the code is that the useSharedValue hook is being called in a loop/callback which breaks React's Rules of Hooks. (emphasis mine)

    It’s not supported to call Hooks (functions starting with use) in any other cases, for example:

    • πŸ”΄ Do not call Hooks inside conditions or loops.
    • πŸ”΄ Do not call Hooks after a conditional return statement.
    • πŸ”΄ Do not call Hooks in event handlers.
    • πŸ”΄ Do not call Hooks in class components.
    • πŸ”΄ Do not call Hooks inside functions passed to useMemo, useReducer, or useEffect.
    • πŸ”΄ Do not call Hooks inside try/catch/finally blocks.

    Solution

    If I'm understanding your code correctly it appears you are creating a sort of "loading skeleton" or doing some pre-measurements of the children before setting the ready state.

    Based on the useSharedValue hook docs, you can store an array value.

    See Initial Value: (emphasis mine)

    The value you want to store initially in the shared value. It can be any JavaScript value like number, string or boolean but also data structures such as array and object.

    I suggest using a single useSharedValue hook with an array for the "children" and can be updated and accessed in the View component's onLayout callback handler.

    Example:

    export default function CardList({ children, players, setPlayers, playerTurnCounter }) {
      const [ready, setReady] = useState(false);
    
      const offsets = useSharedValue(children.map(() => ({
        order: 0,
        width: 0,
        height: 0,
        x: 0,
        y: 0,
        originalX: 0,
        originalY: 0,
      }));
    
      if (!ready) {
        return (
          <View style={styles.row}>
            {children.map((child, index) => {
              return (
                <View
                  key={child.key}
                  onLayout={({
                    nativeEvent: { layout: { x, y, width, height } },
                  }) => {
                    const offset = offsets[index];
                    offset.order.value = -1;
                    offset.width.value = width / children.length + 10;
                    offset.height.value = height;
                    offset.originalX.value = x;
                    offset.originalY.value = y;
    
                    runOnUI(() => {
                      'worklet';
    
                      if (offsets.filter((o) => o.order.value !== -1).length === 0) {
                        runOnJS(setReady)(true);
                      }
                    })();
                  }}>
                  {child}
                </View>
              );
            })}
          </View>
        );
      }
      return (
        <View style={styles.container}>
          {children.map((child, index) => (
            <SortableCard
              key={child.key}
              offsets={offsets}
              index={index}
              players={players}
              setPlayers={setPlayers}
              containerWidth={containerWidth}
              playerTurnCounter={playerTurnCounter}>
              {child}
            </SortableCard>
          ))}
        </View>
      );
    }