I'm trying to display large text documents with complex markups using the react-virtualized List component. The document is broken up into chunks of text of varying lengths shorter than some maximum. The List component renders each of these chunks as a row.
Since I can't use fixed row heights because chunk lengths vary, I'd like to use CellMeasurer. The problem is that the parsing needed to generate the markups on each chunk is expensive -- this is part of the reason I want to use react-virtualized. Even if all the chunks are rendered in the background, it will still be too slow.
Since the markup does not affect height, I'd like to use a simpler rowRenderer function that renders text without markup only for measuring rows, and then provide a separate and more complete rowRenderer for the actual render of each chunk with markup. Is there a way to do this?
This is admittedly really hacky, and not best practices, but could you do something like:
<List
rowHeight={(index) => {
const rowData = data[index];
let div = document.getElementById('renderer');
if (!div) {
div = document.createElement('div');
div.id = 'renderer';
}
ReactDOM.render(div, <Row>{rowData}</Row>);
const height = div.offsetHeight;
ReactDOM.unmountComponentAtNode(div);
if (index === data.length - 1) {
div.parentNode.removeChild(div);
}
return height;
}}
/>
Or, actually, you could have two lists, one with visibility: hidden, where you just render each row without markup, get the height, and add it to an array. Once the length of the array is equal to your data length, you no longer show it, and then render the other one, with rowHeight={index => heights[index]}
After some trial and error I found a good way to do this is to make the rowRenderer function decide which way to render the row. You can do this by checking the _rowHeightCache property in the CellMeasurerCache instance use use in your list. Note that the keys of _rowHeightCache take the form: "index-0" where index is the row's index.
Here is how you can set up the row renderer:
TextRowRenderer(
{
key, // Unique key within array of rows
index, // Index of row within collection
// isScrolling, // The List is currently being scrolled
style, // Style object to be applied to row (to position it)
parent, // reference to List
},
) {
const { textArray } = this.props;
// get the cached row height for this row
const rowCache = this.listCache._rowHeightCache[`${index}-0`];
// if it has been cached, render it using the normal, more expensive
// version of the component, without bothering to wrap it in a
// CellMeasurer (it's already been measured!)
// Note that listCache.defaultHeight has been set to 0, to make the
// the comparison easy
if (rowCache !== null && rowCache !== undefined && rowCache !== 0) {
return (
<TextChunk
key={key}
chunk={textArray[index]}
index={index}
style={style}
/>
);
}
// If the row height has not been cached (meaning it has not been
// measured, return the text chunk component, but this time:
// a) it's wrapped in CellMeasurer, which is configured with the
// the cache, and
// b) it receives the prop textOnly={true}, which it tells it to
// to skip rendering the markup
return (
<CellMeasurer
cache={this.listCache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
{() => {
return (
<TextChunk
key={key}
chunk={textArray[index]}
index={index}
style={style}
textOnly
/>
);
}}
</CellMeasurer>
);
}
This is then passed to the List component in the ordinary way:
<List
height={pageHeight}
width={pageWidth}
rowCount={textArray.length}
rowHeight={this.listCache.rowHeight}
rowRenderer={this.TextRowRenderer}
deferredMeasurementCache={this.listCache}
style={{ outline: 'none' }}
ref={(r) => { this.listRef = r; }}
/>
By including a reference callback, we can now access the
measureAllRows method, which we can use to force the List
to render all rows in advance to get the heights. This will
ensure that the scroll bar functions properly, but even with
the textOnly flag can take a while longer. In my case, I believe
it is worth the wait.
Here is how you can call measureAllRows:
componentDidUpdate() {
const {
textArray,
} = this.props;
// We will only run measureAllRows if they have not been
// measured yet. An easy way to check is to see if the
// last row is measured
const lastRowCache = this
.listCache
._rowHeightCache[`${textArray.length - 1}-0`];
if (this.listRef
|| lastRowCache === null
|| lastRowCache === undefined
|| lastRowCache === 0) {
try {
this.listRef.measureAllRows();
} catch {
console.log('failed to measure all rows');
}
}
}
The try-catch block is needed because if it tries to measure
before the List is constructed it will throw an index-out-of
-bounds error.
FINAL THOUGHTS
Another possible way to do this would be to have the actual List
component be conditional on cached measurements rather than the
rowRenderer function. You could render an array of text as a a
regular list, wrapping each row in a <CellMeasurer>. When the
cache is filled, you would then render a virtual List configured
using the prefilled cache. This would obviate the need to call
measureAllRows in componentDidUpdate or a useEffect callback.
Related
When I console.log the index that is being deleted it shows the correct index to be deleted, but the behaviour does not correspond - it always only deletes the last item in the array on the UI. (but in the state array it is deleting the correct index)
example:
(refer to image below)
When I click on delete for the first item (index 0), it removes the last item (index 3) from the UI. But upon viewing the console.log, it actually removed the correct item (index 0) from the state array.
How it looks in the UI:
Note that the red numbers are there just for me to know if the array index is correct. (and it is working correctly)
EditUser.js
// holds the array of data for each RoleMapInput component
const [roleMaps, setRoleMaps] = useState([{organisation:[], roles:[], type:[]}]);
// Used to add a RoleMapInput component to the UI
const handleAddRoleMap = () =>{
setRoleMaps((oldRoleMaps) => [...oldRoleMaps,{organisation:[], roles:[], type:[]}])
}
// Used to delete a RoleMapInput component from the UI
const handleDelRoleMap = (delIndex) =>{
console.log("delIndex: ", delIndex)
setRoleMaps((oldRoleMaps) => {
console.log(oldRoleMaps)
return oldRoleMaps.filter((_,index)=>index !== delIndex)
})
...
...
...
return(
...
// Where the RoleMapInput component is duplicated based on the array roleMaps
{console.log("roleMaps at grid: ",roleMaps)}
{roleMaps.map((roleMap, index) => (
<RoleMapInput key={index} roleMapIndex={index} roleMap={roleMap} handleDelRoleMap={handleDelRoleMap}/>
))}
...
)
RoleMapInput.js
export default function RoleMapInput(props) {
...
...
return (
<>
<Grid item xs={1}>
<FormControl fullWidth>
<IconButton aria-label="delete" color='error' onClick={()=>props.handleDelRoleMap(props.roleMapIndex)}>
{props.roleMapIndex}
<DeleteOutlineOutlinedIcon/>
</IconButton>
</FormControl>
</Grid>
</>
);
}
Without the minimum reproducible example is really hard to tell. But I can tell you this: the filter function is working properly, but using as react docs mention:
We donβt recommend using indexes for keys if the order of items may change. This can negatively impact performance and may cause issues with component state.
You can read more about this here
A possible explanation is that when you are removing the element on pos 0 react might consider that it doesn't need to re-render any child component, but rather remove the last element (because you had rendered <RoleMapInput components with keys 0,1,2,3 and after removing any item from the array you are left with keys 0,1,2)
Say I have the standard TODO app, and want a ref to the last item in the list. I might have something like:
const TODO = ({items}) => {
const lastItemRef = Reeact.useRef()
return {
<>
{items.map(item => <Item ref={item == items.last() ? lastItemRef : undefined} />)}
<>
}
}
But this doesn't seem to work - after lastItemRef is initialized, it is never subsequently updated as items are added to items. Is there a clean way of doing this without using a selector?
I think in your case it depends upon how the items list is updated. This is because useRef won't re-render the component if you change its current attribute (persistent). But it does re-render when you choose, for example, useState.
Just as a working case, see if this is what you were looking for.
Ps: check the console
I am looking at many answers of checking if elements overlap, but they are not applicable.
I have a wrapper component that has a header with position fixed and children
const Wrapper = ({ children }) => {
return (
<>
<Header/>
{children}
</>
)
};
export default Wrapper
I need to know when the header is overlapping certain parts in several different pages (children) so as to change the header color
I am trying to detect in the wrapper component, but the elements do not exist or are not accesible to the container
Do I need to pass refs all the way from the wrapper to all the children? Is there an easier way to do this?
Thanks
there are a few approaches i can think of, one being the one you mentioned and passing refs around and doing a bunch of calculations and a few others below.
Hardcoded heights of the pages
This would work basically by having a large switch case in you header file and check the offset scroll position of your scroll target.
getBackgroundColor = (scrollPosition) => {
switch (true) {
case scrollPosition <= $('#page1').height:
return 'red'
case scrollPosition <= $('#page1').height + $('#page2').height:
return 'blue'
case scrollPosition <= $('#page1').height + $('#page2').height + $('page3').height
return 'green'
default:
return 'yellow'
}
}
This has obvious flaws, one being, if the page content is dynamic or changes frequently it may not work, checking height every re-render may cause reflow issues, and this requires knowledge of page IDs on the page. (note: this snippet is just to prove concept, it will need tweeks).
Intersection Observer (Recommended way)
Intersection observer is an awesome API that allows you to observe elements in a performant way and reduces layout thrashing from the alternative ways with constant measurements
heres an example I made with react and intersection observer, https://codesandbox.io/s/relaxed-spence-yrozp. This may not be the best if you have hundreds of targets, but I have not ran any benchmarks on that so not certain. Also it is considered "Experimental" but has good browser support (not IE).
here is the bulk of the codesandbox below just to get an idea.
React.useEffect(() => {
let headerRect = document.getElementById("header").getBoundingClientRect();
let el = document.getElementById("wrapper");
let targets = [...document.getElementsByClassName("block")];
let callback = (entries, observer) => {
entries.forEach(entry => {
let doesOverlap = entry.boundingClientRect.y <= headerRect.bottom;
if (doesOverlap) {
let background = entry.target.style.background;
setColor(background);
}
});
};
let io = new IntersectionObserver(callback, {
root: el,
threshold: [0, 0.1, 0.95, 1]
});
targets.forEach(target => io.observe(target));
return () => {
targets.forEach(target => io.unobserve(target));
};
}, []);
ALso notice this is not the most "React" way to do things since it relys a lot on ids, but you can get around that by passing refs everywhere i have used dom selections, but that may become unwieldy.
I am using react-table component inside my project. The row expansion property is something that my features utilized and it is working fine now.
I need the ability to collapse all the rows while I expand a row. ie Only one row should be open at a time. I did go through many documentation and stackoverflow links but none didn't work out. Please note that this implementation is using hooks. Just like the one mentioned here : https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/expanding
By default they allow to open more than one row at a time, but I need to implement the opposite.
The closest I came to is this : Auto expandable rows and subrows react table using hooks
But here its opening on initial load.
Thanks
I have only added a portion of App function here. Codesandbox: https://codesandbox.io/s/jolly-payne-dxs1d?fontsize=14&hidenavigation=1&theme=dark.
Note: I am not used to react-table library. This code is a sample that only works in the table with two levels of rows. You can optimize the code with recursion or some other way to make the code work in multi-level tables.
Cell: ({ row, rows, toggleRowExpanded }) =>
// Use the row.canExpand and row.getToggleRowExpandedProps prop getter
// to build the toggle for expanding a row
row.canExpand ? (
<span
{...row.getToggleRowExpandedProps({
style: {
// We can even use the row.depth property
// and paddingLeft to indicate the depth
// of the row
paddingLeft: `${row.depth * 2}rem`
},
onClick: () => {
const expandedRow = rows.find(row => row.isExpanded);
if (expandedRow) {
const isSubItemOfRow = Boolean(
expandedRow && row.id.split(".")[0] === expandedRow.id
);
if (isSubItemOfRow) {
const expandedSubItem = expandedRow.subRows.find(
subRow => subRow.isExpanded
);
if (expandedSubItem) {
const isClickedOnExpandedSubItem =
expandedSubItem.id === row.id;
if (!isClickedOnExpandedSubItem) {
toggleRowExpanded(expandedSubItem.id, false);
}
}
} else {
toggleRowExpanded(expandedRow.id, false);
}
}
row.toggleRowExpanded();
}
})}
>
{row.isExpanded ? "π" : "π"}
</span>
) : null
What I needed was something like that shown in the picture. I need to show certain names and if the names list exceeds more than 2 rows I need to show +n others. If the user clicks +n others the list needs to be expanded to show all the others.
Is there any component available in react to get this result? I have seen it on a number of websites but don't know what they are called.
I could write the component myself but the difficult part would be how many names to show before i show the +n others. I can only show 2 rows initially and each name can be of variable length. So in one case, a single name may take up the entire 1st row and in others, i may be able to fit 3 names.
You have to store the state of the list see it's expanded or not.
Something like this should help.
import React, { useEffect, useState } from "react";
const myList = ({ list }) => {
const MAX_COUNT = 5;
const [isExpended, setIsExpended] = useState(false);
useEffect(() => {
if (list.length <= MAX_COUNT) {
setIsExpended(true);
}
}, [list]);
const expend = () => {
setIsExpended(true);
};
return (
<>
<div>
{list.map((item, i) =>
(isExpended === false && i < MAX_COUNT) || isExpended === true
? item.text
: null
)}
</div>
{isExpended === false ? (
<button onClick={expend}>+ {list.length - MAX_COUNT} others</button>
) : null}
</>
);
};
export default myList;
If you want to stick with 2 rows on any conditions there will be 2 approach to set the dynamic MAX_COUNT:
1: if you have a constant value for box-sizing and fonts and etc:
You can compute outerWidth of each elements (with box-sizing and elements length) and set a real MAX_COUNT based on that.
2: if you have responsive design:
you can render component with initial MAX_COUNT but hide contents with visibility: hidden and then computing outerWidth of each elements would be more realistic and much more easier.
in this scenario you have to fix the container height to prevent it from extending too much, just set a constant height to it, also you can change it after you get a real MAX_COUNT. now you can show contents with no worries.