Question/bug: Are nested SpatialNavigationVirtualizedList (vertical parent + horizontal rails) supported? scrollBehavior breaks and virtualization collapses at scale
Describe the bug When nesting SpatialNavigationVirtualizedList (SNVL) — a vertical parent with horizontal SNVL rails as children — the horizontal scrollBehavior='stick-to-start' becomes inconsistent (doesn’t snap/stick reliably), and performance degrades as the screen adds more rails/items (focus lag after a few moves, virtualization seems less effective).
To Reproduce
import React, { memo, useMemo } from 'react'
import { Text, View } from 'react-native'
import {
DefaultFocus,
SpatialNavigationFocusableView,
SpatialNavigationNode,
SpatialNavigationRoot,
SpatialNavigationVirtualizedList,
} from 'react-tv-space-navigation'
const rails = new Array(4).fill(0).map((_, r) => ({
id: `rail-${r}`,
items: new Array(10).fill(0).map((__, i) => ({ id: `r${r}-i${i}` })),
}))
const ROW_HEIGHT = 220
const TILE_W = 120
const GAP = 16
const ITEM_SIZE = TILE_W + GAP
export const Test = () => {
const data = useMemo(() => rails, [])
return (
<View
style={{
flex: 1,
overflow: 'hidden',
}}>
<SpatialNavigationRoot>
<SpatialNavigationVirtualizedList
orientation='vertical'
data={data}
itemSize={ROW_HEIGHT}
additionalItemsRendered={1}
scrollBehavior='stick-to-start'
renderItem={({ item: rail }) => (
// CHILD: horizontal SNVL per row
<SpatialNavigationVirtualizedList
orientation='horizontal'
data={rail.items}
itemSize={ITEM_SIZE}
scrollBehavior='stick-to-start' // ← doesn't work
additionalItemsRendered={1}
renderItem={({ item }) => (
<SpatialNavigationNode>
<Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
</SpatialNavigationNode>
)}
/>
)}
/>
</SpatialNavigationRoot>
</View>
)
}
const Tile = memo(
({ id, width, height }: { id: string; width: number; height: number }) => {
const Wrapper = id === 'rail-0' ? DefaultFocus : View
return (
<Wrapper>
<SpatialNavigationFocusableView>
{({ isFocused }) => (
<View
style={{
width,
height,
backgroundColor: isFocused ? 'red' : 'blue',
justifyContent: 'center',
alignItems: 'center',
}}>
<Text
style={{
color: 'white',
fontSize: 20,
fontWeight: 'bold',
}}>
{id}
</Text>
</View>
)}
</SpatialNavigationFocusableView>
</Wrapper>
)
},
)
Observed behavior
Holding RIGHT/LEFT within a horizontal rail: after a few items, snapping to “start” doesn't work.
As more rails/items are added, overall responsiveness drops and focus moves become laggy (virtualization seems to keep more mounted than expected) - testing in release modes.
Expected behavior
- With vertical SNVL parent + horizontal SNVL children, scrollBehavior='stick-to-start' should remain reliable in each horizontal rail.
- LEFT/RIGHT within a rail and UP/DOWN between rails should be smooth.
- Virtualization should remain effective (only a small window mounted) as rails/items scale.
Library version: 5.2.0 React Native version: 0.77.0
Additional context
I followed the Pitfalls & Troubleshooting doc: every item is always wrapped in a SpatialNavigationNode / SpatialNavigationFocusableView (no conditional mounting); only inner content changes.
This issue appears specifically when the vertical container is SNVL; with a vertical SpatialNavigationScrollView, snapping is correct but performance doesn’t scale since everything stays mounted.
Questions:
- Is SNVL→SNVL nesting (vertical parent + horizontal children) officially supported?
- If yes, are there recommended props/patterns to keep scrollBehavior and virtualization stable at scale?
- If not recommended, what’s the preferred pattern for large datasets?
Found that nested SpatialNavigationVirtualizedList (vertical parent + horizontal children) works fine with a small number of rails. However, once the vertical rail count exceeds ~5, vertical scrolling/focus starts behaving inconsistently (snaps drift, focus jumps or stalls). Interestingly, very large horizontal lists are OK (e.g., 2,000 items per rail); the problem appears tied to the number of vertical rails rather than the size of each horizontal list.
To Reproduce
import React, { memo, useMemo } from 'react'
import { Dimensions, Text, View } from 'react-native'
import {
DefaultFocus,
SpatialNavigationFocusableView,
SpatialNavigationNode,
SpatialNavigationRoot,
SpatialNavigationView,
SpatialNavigationVirtualizedList,
} from 'react-tv-space-navigation'
const AMOUNT_OF_RAILS = 10
const AMOUNT_OF_ITEMS = 2000
const rails = new Array(AMOUNT_OF_RAILS).fill(0).map((_, r) => ({
id: `rail-${r}`,
items: new Array(AMOUNT_OF_ITEMS)
.fill(0)
.map((__, i) => ({ id: `r${r}-i${i}` })),
}))
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window')
const scale = SCREEN_W / 1920
export const responsiveSize = (pixels: number) => pixels * scale
const ROW_HEIGHT = responsiveSize(355)
const TILE_W = responsiveSize(225)
const GAP = responsiveSize(24)
const ITEM_SIZE = TILE_W + GAP
export const POC1_Nested = () => {
const data = useMemo(() => rails, [])
return (
<SpatialNavigationRoot>
<View
style={{
width: SCREEN_W,
height: SCREEN_H,
overflow: 'hidden',
}}>
{/* Simulate a heavier upper UI area */}
<View style={{ height: SCREEN_H * 0.6 }} />
{/* Rails area (nested SNVL) */}
<View style={{ height: SCREEN_H * 0.4, overflow: 'scroll' }}>
<SpatialNavigationVirtualizedList
orientation='vertical'
data={data}
itemSize={ROW_HEIGHT}
scrollBehavior='stick-to-start'
renderItem={({ item: rail }) => (
<SpatialNavigationView
direction='horizontal'
style={{ height: ROW_HEIGHT, width: SCREEN_W }}>
<SpatialNavigationVirtualizedList
orientation='horizontal'
data={rail.items}
itemSize={ITEM_SIZE}
scrollBehavior='stick-to-start' // ← becomes inconsistent when nested with many vertical rails
renderItem={({ item }) => (
<SpatialNavigationNode>
<Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
</SpatialNavigationNode>
)}
/>
</SpatialNavigationView>
)}
/>
</View>
</View>
</SpatialNavigationRoot>
)
}
const Tile = memo(
({ id, width, height }: { id: string; width: number; height: number }) => {
// Make the very first tile focused initially
const Wrapper = id === 'r0-i0' ? DefaultFocus : View
return (
<Wrapper>
<SpatialNavigationFocusableView>
{({ isFocused }) => (
<View
style={{
width,
height,
backgroundColor: isFocused ? 'red' : 'blue',
justifyContent: 'center',
alignItems: 'center',
}}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: 'bold' }}>
{id}
</Text>
</View>
)}
</SpatialNavigationFocusableView>
</Wrapper>
)
},
)
Horizontal rails can be huge (2k items is fine), but as soon as I crank up the number of vertical rows (say ~10), things get weird. I’d expect the vertical layer to scale just like the horizontal one: smooth up/down between rails, each rail still snapping stick-to-start on left/right, and virtualization staying sane (no container growth or over-mounting).
Hi!
Thank you for your issue. It is actually duplicate with https://github.com/bamlab/react-tv-space-navigation/issues/167 I think. The short answer: it is not supported yet 😭 I have to have a deep look, but this is a complex topic.
I would expect the virtualization inside virtualization to work (with a major drawback at first: as the rails will be unmounted, they will lose their state... so scroll will be reset if you go up and down after virtualization). But it doesn't work for a reason that I couldn't identify yet, and as this is very time consuming, I haven't had the chance to have a deeper look in the past months (I'm not working on any TV projects right now).
Unfortunately, I don't know what to recommend yet. On my projects, we managed to lazy load our rows vertically because we don't have too many rows (20 max), so it works fine without virtualization.
As soon as I try to really virtualize, I encounter a bug where I get stuck (focus is lost and I can't recover it).
I'm saying all this but I'm not 100% sure your problem is the same problem though, it looks related but the symptoms differ 🤔
hey Pierre, thanks for sharing your thoughts!
somehow we were able to include snippet structure into our project and its working better - although virtualization could be improved like in flashlist or legend list -, its actually virtualizing content. what we found is that tree structure is very fragile, if we change any of the spatial components in the tree auto scroll focus behaviour from spatial navigation stops working.
one of the key components is the wrapper for SpatialNavigationVirtualizedList, a SpatialNavigationView with the same orientation as the nested virtualized list.
Hey Pierre, We tested on beta6.0.0 and this is not working well, it has jerks on scroll.
This is making the library un-usable.
I agree, it is a critical issue indeed. I will mention it in the README in the mean time, because I have no ETA for the fix.
@nriccar I'm using the same structure: vertical SpatialNavigationVirtualizedList which renders horizontal SpatialNavigationVirtualizedLists. Focus works as expected and we don't have performance issues considering the limited resources of the TV Device I'm testing on for development.
I am wondering if you intentionally omitted this in your code for brevity, but I notice no use of onEndReached which would definitely help
@nriccar off topic, to focus the first item automatically you wrote:
const Wrapper = id === 'rail-0' ? DefaultFocus : View
but <DefaultFocus> has a enabled flag so you can write:
<DefaultFocus enabled={id === 'rail-0'}>
we were able to somehow nest virtualized lists, and its working correctly, it actually virtualizes. I'm going to share a reproducible example asap. btw, thanks @adrianbunea!
Good job @adrianbunea, I even forgot that we implemented that! 😂
we were able to somehow nest virtualized lists, and its working correctly, it actually virtualizes. I'm going to share a reproducible example asap. btw, thanks @adrianbunea!
Hi @nriccar, will you be able to share some details? I would also love to get it to work. Thanks!
Hello @nriccar @Janjiran @vishu2124 @adrianbunea,
TLDR
Use a custom keyExtractor on the first layer of virtualization (the first virtualized list, not the nested one) to deactivate recycling of rows / columns
Disadvantage : When unmouting rows, we lose the active child (last focused index), so the user experience might be affected by this change when navigating up/down between rows
Investigations
I investigated the nested virtualization problem.
For now I identified 2 cases where it works :
- When all the parent virtualized lists are rendered: in the shared examples it means that all rows ('rails') are rendered, i.e. virtualization is disabled on first level. To do so, you can setup the additionnalItemsRendered to the length of data first layer.
...
{/* Rails area (nested SNVL) */}
<View style={{ height: SCREEN_H * 0.4, overflow: 'scroll' }}>
<SpatialNavigationVirtualizedList
orientation="vertical"
data={data}
itemSize={ROW_HEIGHT}
scrollBehavior="stick-to-start"
additionalItemsRendered={rails.length} // OR rails.length - 1 for jump-on-scroll scrollBehavior (see getAdditionalNumberOfItemsRendered.ts)
renderItem={({ item: rail }) => (
<SpatialNavigationView
direction="horizontal"
style={{ height: ROW_HEIGHT, width: SCREEN_W }}
>
<SpatialNavigationVirtualizedList
orientation="horizontal"
data={rail.items}
itemSize={ITEM_SIZE}
scrollBehavior="stick-to-start" // ← becomes inconsistent when nested with many vertical rails
renderItem={({ item }) => (
<SpatialNavigationNode>
<Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
</SpatialNavigationNode>
...
);
It significantly degrades performance so I recommend the second approach
- Deactivate recycling of rows ('rails') using the deprecated keyExtractor prop :
export const NestedVirtualizedListsPage = () => {
const data = useMemo(() => rails, []);
return (
<Page>
{/* Rails area (nested SNVL) */}
<View style={{ height: '100%', width: '100%' }}>
<SpatialNavigationVirtualizedList
orientation="vertical"
data={data}
itemSize={ROW_HEIGHT}
scrollBehavior="stick-to-start"
keyExtractor={(index) => `${index}`} // This keyExtractor deactivates row recycling by giving each row a unique key -> its index
debug
renderItem={({ item }) => (
<SpatialNavigationNode>
<Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
</SpatialNavigationNode>
...
The keyExtractor prop sets the key of react components to update performance Here is a definition of component recycling, to keep in mind
Rows should not be recycled : we want them to mount / unmount when the virtualization window moves
Disadvantage : When unmouting rows, we lose the active child (last focused index), so the user experience might be affected by this change when navigating up/down between rows
Next steps
I will keep on investigating this to search another solution (maybe a better one). If not, we should unmark the keyExtractor prop as deprecated and add some docs + a nested virtualizedLists implementation in the example package