diff --git a/.changeset/strange-kings-sit.md b/.changeset/strange-kings-sit.md new file mode 100644 index 000000000..05f5419d9 --- /dev/null +++ b/.changeset/strange-kings-sit.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Introduces a new render helper component ``. Now you can optimize rendering of intensive items like IDE tabs. diff --git a/src/stories/RenderCache.docs.mdx b/src/stories/RenderCache.docs.mdx new file mode 100644 index 000000000..e804648c3 --- /dev/null +++ b/src/stories/RenderCache.docs.mdx @@ -0,0 +1,181 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/addon-docs/blocks'; +import * as RenderCacheStories from './RenderCache.stories'; + + + +# RenderCache + +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. + +## When to Use + +- When rendering lists where only specific items need to re-render based on state changes +- When child components are expensive to render and don't need to update on every parent re-render +- When you want more control than React.memo provides, with explicit per-item cache invalidation +- When building complex UI where selective re-rendering improves performance significantly + +## Component + + + +--- + +### Properties + + + +### Property Details + +- **items**: Array of items to render +- **renderKeys**: Array of keys that should trigger re-render. Only items with keys in this array will be re-rendered +- **getKey**: Function that extracts a unique key from each item +- **children**: Render function that takes an item and returns a React element + +### Base Properties + +This is a headless component and does not support base properties. + +## How It Works + +The component maintains an internal cache of rendered elements. When rendering: + +1. For each item, it checks if the item's key is in renderKeys +2. If the key is in renderKeys or the item hasn't been rendered before, it calls the children function to render/re-render +3. Otherwise, it returns the cached element from the previous render +4. It automatically cleans up cache entries for items that are no longer in the list + +## Examples + +### Basic Usage + +```jsx +import { RenderCache } from '@cube-dev/ui-kit'; + +const items = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, +]; + +const [selectedId, setSelectedId] = useState(1); + + item.id} +> + {(item) => } + +``` + +### With List of Expensive Components + +```jsx +import { RenderCache } from '@cube-dev/ui-kit'; + +function ExpensiveListItem({ item, isActive }) { + // Complex rendering logic + return
{item.name}
; +} + +const [activeId, setActiveId] = useState(1); + + item.id} +> + {(item) => ( + + )} + +``` + +## Performance Considerations + +- **Cache management**: The component uses useRef to maintain the cache across renders, avoiding unnecessary re-allocations +- **Automatic cleanup**: Removes cached entries for items no longer in the list to prevent memory leaks +- **Selective invalidation**: Only items with keys in renderKeys are re-rendered, others use cached elements +- **Best for expensive renders**: Most effective when child components have significant render cost + +## Best Practices + +1. **Use stable keys**: Ensure getKey returns consistent keys for the same items across renders + ```jsx + // Good: Using stable IDs + item.id} + renderKeys={[selectedId]} + > + {(item) => } + + + // Bad: Using indices (unstable when list changes) + index} + renderKeys={[selectedIndex]} + > + {(item) => } + + ``` + +2. **Minimize renderKeys**: Only include keys that truly need re-rendering to maximize cache benefits + ```jsx + // Good: Only re-render the active item + item.id} + > + {(item) => } + + + // Bad: Re-rendering all items defeats the purpose + item.id)} + getKey={(item) => item.id} + > + {(item) => } + + ``` + +3. **Consider alternatives**: For simple cases, React.memo might be sufficient and simpler + +4. **Profile first**: Use React DevTools Profiler to confirm you have a performance issue before adding this optimization + +5. **Avoid inline functions**: Use stable render functions to prevent unnecessary cache invalidation + ```jsx + // Good: Stable render function + const renderItem = useCallback((item) => ( + + ), []); + + item.id} + > + {renderItem} + + ``` + +## When NOT to Use + +- **Simple lists**: For simple lists without performance issues, regular rendering is simpler +- **All items update frequently**: If all items need to re-render on every change, the cache overhead isn't worth it +- **Small lists**: Lists with < 10 items typically don't benefit from caching +- **Simple components**: If child components render quickly, the optimization overhead may outweigh benefits + +## Related Components + +- **React.memo** - For simple component memoization +- **useMemo** - For memoizing computed values +- **useCallback** - For memoizing callback functions +- **DisplayTransition** - For animating component mount/unmount + diff --git a/src/stories/RenderCache.stories.tsx b/src/stories/RenderCache.stories.tsx new file mode 100644 index 000000000..f728cb704 --- /dev/null +++ b/src/stories/RenderCache.stories.tsx @@ -0,0 +1,138 @@ +import { useRef, useState } from 'react'; +import { userEvent, within } from 'storybook/test'; + +import { Block } from '../components/Block'; +import { Radio } from '../components/fields/RadioGroup/Radio'; +import { Flow } from '../components/layout/Flow'; +import { Space } from '../components/layout/Space'; +import { RenderCache } from '../utils/react/RenderCache'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Helpers/RenderCache', + component: RenderCache, + argTypes: { + items: { + control: { type: null }, + description: 'Array of items to render', + }, + renderKeys: { + control: { type: null }, + description: + 'Array of keys that should trigger re-render. Only items with keys in this array will be re-rendered', + }, + getKey: { + control: { type: null }, + description: 'Function that extracts a unique key from each item', + }, + children: { + control: { type: null }, + description: + 'Render function that takes an item and returns a React element', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Item component that tracks and displays render count +function RenderCountItem({ id }: { id: number }) { + const renderCount = useRef(0); + renderCount.current += 1; + + return ( + + Item {id}: Rendered {renderCount.current} time + {renderCount.current !== 1 ? 's' : ''} + + ); +} + +export const Default: Story = { + render: () => { + const [selectedTab, setSelectedTab] = useState('1'); + const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]; + + return ( + + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + + + + + item.id} + > + {(item) => } + + + + + + How it works: Only the selected item re-renders when + you switch tabs. Other items show their cached render count. This + demonstrates how RenderCache optimizes performance by avoiding + unnecessary re-renders. + + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for initial render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Click on Item 2 tab + const item2Tab = canvas.getByRole('radio', { name: 'Item 2' }); + await userEvent.click(item2Tab); + + // Wait for render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Click on Item 3 tab + const item3Tab = canvas.getByRole('radio', { name: 'Item 3' }); + await userEvent.click(item3Tab); + + // Wait for render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Click on Item 5 tab + const item5Tab = canvas.getByRole('radio', { name: 'Item 5' }); + await userEvent.click(item5Tab); + + // Wait for render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Click back to Item 2 to show it re-renders again + await userEvent.click(item2Tab); + + // Final wait + await new Promise((resolve) => setTimeout(resolve, 100)); + }, +}; diff --git a/src/utils/react/RenderCache.tsx b/src/utils/react/RenderCache.tsx new file mode 100644 index 000000000..8562ae846 --- /dev/null +++ b/src/utils/react/RenderCache.tsx @@ -0,0 +1,46 @@ +import { ReactElement, useRef } from 'react'; + +export interface RenderCacheProps { + items: T[]; + renderKeys: (string | number)[]; + getKey: (item: T) => string | number; + children: (item: T) => ReactElement; +} + +/** + * RenderCache optimizes rendering of item lists by reusing + * previously rendered elements for unchanged items. + */ +export function RenderCache({ + items, + renderKeys, + getKey, + children, +}: RenderCacheProps): ReactElement { + // Store previous renders + const cacheRef = useRef>(new Map()); + + const rendered = items.map((item) => { + const key = getKey(item); + const shouldRerender = renderKeys.includes(key); + const cached = cacheRef.current.get(key); + + if (!cached || shouldRerender) { + const element = children(item); + cacheRef.current.set(key, element); + return element; + } + + return cached; + }); + + // Optionally clean up cache for items no longer present + const currentKeys = new Set(items.map(getKey)); + for (const key of cacheRef.current.keys()) { + if (!currentKeys.has(key)) { + cacheRef.current.delete(key); + } + } + + return <>{rendered}; +} diff --git a/src/utils/react/index.ts b/src/utils/react/index.ts index 0e0079a84..81b5cd5cd 100644 --- a/src/utils/react/index.ts +++ b/src/utils/react/index.ts @@ -13,3 +13,5 @@ export { useEventBus, useEventListener, EventBusProvider } from './useEventBus'; export type { EventBusListener, EventBusContextValue } from './useEventBus'; export { useControlledFocusVisible } from './useControlledFocusVisible'; export type { UseControlledFocusVisibleResult } from './useControlledFocusVisible'; +export { RenderCache } from './RenderCache'; +export type { RenderCacheProps } from './RenderCache';