Skip to content

Commit f6db220

Browse files
authored
feat(RenderCache): component (#857)
1 parent d229748 commit f6db220

File tree

5 files changed

+372
-0
lines changed

5 files changed

+372
-0
lines changed

.changeset/strange-kings-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
Introduces a new render helper component `<RenderCache/>`. Now you can optimize rendering of intensive items like IDE tabs.

src/stories/RenderCache.docs.mdx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { Meta, Canvas, Story, Controls } from '@storybook/addon-docs/blocks';
2+
import * as RenderCacheStories from './RenderCache.stories';
3+
4+
<Meta of={RenderCacheStories} />
5+
6+
# RenderCache
7+
8+
RenderCache is a performance optimization component that caches rendered React elements and only re-renders specific items when needed. It's useful for lists where most items remain unchanged but you need fine-grained control over which items to re-render.
9+
10+
## When to Use
11+
12+
- When rendering lists where only specific items need to re-render based on state changes
13+
- When child components are expensive to render and don't need to update on every parent re-render
14+
- When you want more control than React.memo provides, with explicit per-item cache invalidation
15+
- When building complex UI where selective re-rendering improves performance significantly
16+
17+
## Component
18+
19+
<Story of={RenderCacheStories.Default} />
20+
21+
---
22+
23+
### Properties
24+
25+
<Controls of={RenderCacheStories.Default} />
26+
27+
### Property Details
28+
29+
- **items**: Array of items to render
30+
- **renderKeys**: Array of keys that should trigger re-render. Only items with keys in this array will be re-rendered
31+
- **getKey**: Function that extracts a unique key from each item
32+
- **children**: Render function that takes an item and returns a React element
33+
34+
### Base Properties
35+
36+
This is a headless component and does not support base properties.
37+
38+
## How It Works
39+
40+
The component maintains an internal cache of rendered elements. When rendering:
41+
42+
1. For each item, it checks if the item's key is in renderKeys
43+
2. If the key is in renderKeys or the item hasn't been rendered before, it calls the children function to render/re-render
44+
3. Otherwise, it returns the cached element from the previous render
45+
4. It automatically cleans up cache entries for items that are no longer in the list
46+
47+
## Examples
48+
49+
### Basic Usage
50+
51+
```jsx
52+
import { RenderCache } from '@cube-dev/ui-kit';
53+
54+
const items = [
55+
{ id: 1, name: 'Item 1' },
56+
{ id: 2, name: 'Item 2' },
57+
{ id: 3, name: 'Item 3' },
58+
];
59+
60+
const [selectedId, setSelectedId] = useState(1);
61+
62+
<RenderCache
63+
items={items}
64+
renderKeys={[selectedId]}
65+
getKey={(item) => item.id}
66+
>
67+
{(item) => <ExpensiveItem item={item} isSelected={item.id === selectedId} />}
68+
</RenderCache>
69+
```
70+
71+
### With List of Expensive Components
72+
73+
```jsx
74+
import { RenderCache } from '@cube-dev/ui-kit';
75+
76+
function ExpensiveListItem({ item, isActive }) {
77+
// Complex rendering logic
78+
return <div className={isActive ? 'active' : ''}>{item.name}</div>;
79+
}
80+
81+
const [activeId, setActiveId] = useState(1);
82+
83+
<RenderCache
84+
items={data}
85+
renderKeys={[activeId]}
86+
getKey={(item) => item.id}
87+
>
88+
{(item) => (
89+
<ExpensiveListItem
90+
item={item}
91+
isActive={item.id === activeId}
92+
/>
93+
)}
94+
</RenderCache>
95+
```
96+
97+
## Performance Considerations
98+
99+
- **Cache management**: The component uses useRef to maintain the cache across renders, avoiding unnecessary re-allocations
100+
- **Automatic cleanup**: Removes cached entries for items no longer in the list to prevent memory leaks
101+
- **Selective invalidation**: Only items with keys in renderKeys are re-rendered, others use cached elements
102+
- **Best for expensive renders**: Most effective when child components have significant render cost
103+
104+
## Best Practices
105+
106+
1. **Use stable keys**: Ensure getKey returns consistent keys for the same items across renders
107+
```jsx
108+
// Good: Using stable IDs
109+
<RenderCache
110+
items={items}
111+
getKey={(item) => item.id}
112+
renderKeys={[selectedId]}
113+
>
114+
{(item) => <Item {...item} />}
115+
</RenderCache>
116+
117+
// Bad: Using indices (unstable when list changes)
118+
<RenderCache
119+
items={items}
120+
getKey={(item, index) => index}
121+
renderKeys={[selectedIndex]}
122+
>
123+
{(item) => <Item {...item} />}
124+
</RenderCache>
125+
```
126+
127+
2. **Minimize renderKeys**: Only include keys that truly need re-rendering to maximize cache benefits
128+
```jsx
129+
// Good: Only re-render the active item
130+
<RenderCache
131+
items={items}
132+
renderKeys={[activeItemId]}
133+
getKey={(item) => item.id}
134+
>
135+
{(item) => <Item {...item} />}
136+
</RenderCache>
137+
138+
// Bad: Re-rendering all items defeats the purpose
139+
<RenderCache
140+
items={items}
141+
renderKeys={items.map(item => item.id)}
142+
getKey={(item) => item.id}
143+
>
144+
{(item) => <Item {...item} />}
145+
</RenderCache>
146+
```
147+
148+
3. **Consider alternatives**: For simple cases, React.memo might be sufficient and simpler
149+
150+
4. **Profile first**: Use React DevTools Profiler to confirm you have a performance issue before adding this optimization
151+
152+
5. **Avoid inline functions**: Use stable render functions to prevent unnecessary cache invalidation
153+
```jsx
154+
// Good: Stable render function
155+
const renderItem = useCallback((item) => (
156+
<ExpensiveItem item={item} />
157+
), []);
158+
159+
<RenderCache
160+
items={items}
161+
renderKeys={[selectedId]}
162+
getKey={(item) => item.id}
163+
>
164+
{renderItem}
165+
</RenderCache>
166+
```
167+
168+
## When NOT to Use
169+
170+
- **Simple lists**: For simple lists without performance issues, regular rendering is simpler
171+
- **All items update frequently**: If all items need to re-render on every change, the cache overhead isn't worth it
172+
- **Small lists**: Lists with < 10 items typically don't benefit from caching
173+
- **Simple components**: If child components render quickly, the optimization overhead may outweigh benefits
174+
175+
## Related Components
176+
177+
- **React.memo** - For simple component memoization
178+
- **useMemo** - For memoizing computed values
179+
- **useCallback** - For memoizing callback functions
180+
- **DisplayTransition** - For animating component mount/unmount
181+
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useRef, useState } from 'react';
2+
import { userEvent, within } from 'storybook/test';
3+
4+
import { Block } from '../components/Block';
5+
import { Radio } from '../components/fields/RadioGroup/Radio';
6+
import { Flow } from '../components/layout/Flow';
7+
import { Space } from '../components/layout/Space';
8+
import { RenderCache } from '../utils/react/RenderCache';
9+
10+
import type { Meta, StoryObj } from '@storybook/react-vite';
11+
12+
const meta = {
13+
title: 'Helpers/RenderCache',
14+
component: RenderCache,
15+
argTypes: {
16+
items: {
17+
control: { type: null },
18+
description: 'Array of items to render',
19+
},
20+
renderKeys: {
21+
control: { type: null },
22+
description:
23+
'Array of keys that should trigger re-render. Only items with keys in this array will be re-rendered',
24+
},
25+
getKey: {
26+
control: { type: null },
27+
description: 'Function that extracts a unique key from each item',
28+
},
29+
children: {
30+
control: { type: null },
31+
description:
32+
'Render function that takes an item and returns a React element',
33+
},
34+
},
35+
} satisfies Meta<typeof RenderCache>;
36+
37+
export default meta;
38+
39+
type Story = StoryObj<typeof meta>;
40+
41+
// Item component that tracks and displays render count
42+
function RenderCountItem({ id }: { id: number }) {
43+
const renderCount = useRef(0);
44+
renderCount.current += 1;
45+
46+
return (
47+
<Block
48+
padding="2x"
49+
radius="1x"
50+
border="1px solid #border"
51+
fill="#dark.04"
52+
data-qa={`item-${id}`}
53+
>
54+
Item {id}: Rendered {renderCount.current} time
55+
{renderCount.current !== 1 ? 's' : ''}
56+
</Block>
57+
);
58+
}
59+
60+
export const Default: Story = {
61+
render: () => {
62+
const [selectedTab, setSelectedTab] = useState('1');
63+
const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
64+
65+
return (
66+
<Flow gap="3x">
67+
<Radio.Tabs
68+
value={selectedTab}
69+
label="Select a tab to re-render only that item"
70+
onChange={setSelectedTab}
71+
>
72+
<Radio value="1">Item 1</Radio>
73+
<Radio value="2">Item 2</Radio>
74+
<Radio value="3">Item 3</Radio>
75+
<Radio value="4">Item 4</Radio>
76+
<Radio value="5">Item 5</Radio>
77+
</Radio.Tabs>
78+
79+
<Block>
80+
<Space placeItems="start" gap="2x">
81+
<RenderCache
82+
items={items}
83+
renderKeys={[parseInt(selectedTab)]}
84+
getKey={(item) => item.id}
85+
>
86+
{(item) => <RenderCountItem key={item.id} id={item.id} />}
87+
</RenderCache>
88+
</Space>
89+
</Block>
90+
91+
<Block
92+
padding="2x"
93+
radius="1x"
94+
fill="#purple.10"
95+
border="1px solid #purple"
96+
>
97+
<strong>How it works:</strong> Only the selected item re-renders when
98+
you switch tabs. Other items show their cached render count. This
99+
demonstrates how RenderCache optimizes performance by avoiding
100+
unnecessary re-renders.
101+
</Block>
102+
</Flow>
103+
);
104+
},
105+
play: async ({ canvasElement }) => {
106+
const canvas = within(canvasElement);
107+
108+
// Wait for initial render
109+
await new Promise((resolve) => setTimeout(resolve, 100));
110+
111+
// Click on Item 2 tab
112+
const item2Tab = canvas.getByRole('radio', { name: 'Item 2' });
113+
await userEvent.click(item2Tab);
114+
115+
// Wait for render
116+
await new Promise((resolve) => setTimeout(resolve, 100));
117+
118+
// Click on Item 3 tab
119+
const item3Tab = canvas.getByRole('radio', { name: 'Item 3' });
120+
await userEvent.click(item3Tab);
121+
122+
// Wait for render
123+
await new Promise((resolve) => setTimeout(resolve, 100));
124+
125+
// Click on Item 5 tab
126+
const item5Tab = canvas.getByRole('radio', { name: 'Item 5' });
127+
await userEvent.click(item5Tab);
128+
129+
// Wait for render
130+
await new Promise((resolve) => setTimeout(resolve, 100));
131+
132+
// Click back to Item 2 to show it re-renders again
133+
await userEvent.click(item2Tab);
134+
135+
// Final wait
136+
await new Promise((resolve) => setTimeout(resolve, 100));
137+
},
138+
};

src/utils/react/RenderCache.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ReactElement, useRef } from 'react';
2+
3+
export interface RenderCacheProps<T> {
4+
items: T[];
5+
renderKeys: (string | number)[];
6+
getKey: (item: T) => string | number;
7+
children: (item: T) => ReactElement;
8+
}
9+
10+
/**
11+
* RenderCache optimizes rendering of item lists by reusing
12+
* previously rendered elements for unchanged items.
13+
*/
14+
export function RenderCache<T>({
15+
items,
16+
renderKeys,
17+
getKey,
18+
children,
19+
}: RenderCacheProps<T>): ReactElement {
20+
// Store previous renders
21+
const cacheRef = useRef<Map<string | number, ReactElement>>(new Map());
22+
23+
const rendered = items.map((item) => {
24+
const key = getKey(item);
25+
const shouldRerender = renderKeys.includes(key);
26+
const cached = cacheRef.current.get(key);
27+
28+
if (!cached || shouldRerender) {
29+
const element = children(item);
30+
cacheRef.current.set(key, element);
31+
return element;
32+
}
33+
34+
return cached;
35+
});
36+
37+
// Optionally clean up cache for items no longer present
38+
const currentKeys = new Set(items.map(getKey));
39+
for (const key of cacheRef.current.keys()) {
40+
if (!currentKeys.has(key)) {
41+
cacheRef.current.delete(key);
42+
}
43+
}
44+
45+
return <>{rendered}</>;
46+
}

src/utils/react/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ export { useEventBus, useEventListener, EventBusProvider } from './useEventBus';
1313
export type { EventBusListener, EventBusContextValue } from './useEventBus';
1414
export { useControlledFocusVisible } from './useControlledFocusVisible';
1515
export type { UseControlledFocusVisibleResult } from './useControlledFocusVisible';
16+
export { RenderCache } from './RenderCache';
17+
export type { RenderCacheProps } from './RenderCache';

0 commit comments

Comments
 (0)