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?
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
, oruseEffect
.- π΄ Do not call Hooks inside
try
/catch
/finally
blocks.
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
orboolean
but also data structures such asarray
andobject
.
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>
);
}