react-use-scrollspy icon indicating copy to clipboard operation
react-use-scrollspy copied to clipboard

Variable number of sections

Open tremby opened this issue 6 years ago • 11 comments
trafficstars

Any thoughts on what I can do if I have a variable number of sections to watch?

I can't run useRef in a loop (it's against the "rules of hooks").

tremby avatar Nov 21 '19 08:11 tremby

The hook just needs access to the DOM Element to get the position.. you could use plain JS, like document.getElementsByClassName?

Then we might need to tweak that line, because of current: https://github.com/Purii/react-use-scrollspy/blob/master/index.js#L15 Let me know or open a PR if you found a solution :-)

Purii avatar Nov 26 '19 09:11 Purii

We solve it like so;

const refs = React.useRef<HTMLElement[]>([])
const activeScreen = useScrollSpy({
  sectionElementRefs: refs.current,
})
...
{items.map((item, i) => 
  <div key={i} ref={ref => !refs.current.includes(ref) && refs.current.push(ref)}>section</div>
)}

mayteio avatar Mar 04 '20 03:03 mayteio

Does that seem stable? I don't see any logic for what to do when a section is removed so surely you'd end up with some dangling references.

tremby avatar Mar 05 '20 02:03 tremby

We are fortunate that we don’t have to worry about that in our situation, though it’s a good question. Would have to pass a function down to each mapped component that filters the ref in a useEffect cleanup

mayteio avatar Mar 05 '20 07:03 mayteio

@tremby the scrollspy feature is trivial to build with useIntersection

Just watch every element/section, and when it enters viewport, set it's id to callback/context/whatever and toggle the active item in menu linking to that element/section.

MiroslavPetrik avatar Jul 06 '21 13:07 MiroslavPetrik

@MiroslavPetrik thanks for the tip, I ended up using this method

andrew310 avatar Sep 07 '22 19:09 andrew310

I solved it using createRef with useMemo instead of useRef.

const sectionRefs = useMemo(() => sections.map(() => createRef<HTMLDivElement>()), []);

Here's a complete example (with sticky menu, smooth scrolling, and way more)

import { Card, CardContent, Container, Grid, MenuItem, MenuList, Paper } from '@mui/material';
import { createRef } from 'react';
import { Title, useTranslate } from 'react-admin';
import ReactMarkdown from 'react-markdown';
import useScrollSpy from 'react-use-scrollspy';

const sections = [
  'content.you',
  'content.how',
  'content.start',
  'content.advantages',
  'content.feedback',
  'content.privacy',
  'content.closingMessage',
];

export const Homepage = () => {
  const translate = useTranslate();

  const sectionRefs = useMemo(() => sections.map(() => createRef<HTMLDivElement>()), []);

  const activeSection = useScrollSpy({
    sectionElementRefs: sectionRefs,
    offsetPx: -80,
  });

  const handleMenuItemClick = (event: React.MouseEvent<HTMLLIElement, MouseEvent>) => {
    const { section } = event.currentTarget.dataset;
    const ref = sectionRefs.find((ref) => ref.current?.id === section);
    ref?.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
  };

  return (
    <Container maxWidth="lg">
      <Grid container spacing={4}>
        <Grid item xs={12} sm={8}>
          <Title title="Homepage" />
          {sections.map((section, index) => {
            const content = translate(section).trim().replace(/\t/g, '');
            const ref = sectionRefs[index];

            return (
              <Card key={section} id={section} ref={ref} sx={{ mb: 2 }}>
                <CardContent>
                  <ReactMarkdown>{content}</ReactMarkdown>
                </CardContent>
              </Card>
            );
          })}
        </Grid>
        <Grid item xs={12} sm={4}>
          <Paper
            sx={{
              position: 'sticky',
              top: 80,
              maxHeight: 'calc(100vh - 8rem)',
              overflowY: 'auto',
            }}
          >
            <MenuList>
              {sections.map((section, index) => {
                return (
                  <MenuItem
                    key={section}
                    selected={index === activeSection}
                    sx={{ transition: 'background-color 0.5s ease-in-out' }}
                    onClick={handleMenuItemClick}
                    data-section={section}
                  >
                    {section}
                  </MenuItem>
                );
              })}
            </MenuList>
          </Paper>
        </Grid>
      </Grid>
    </Container>
  );
};

christiaanwesterbeek avatar Mar 10 '23 19:03 christiaanwesterbeek

(@MiroslavPetrik)

@tremby the scrollspy feature is trivial to build with useIntersection

Mm, no, not really. I've tried exactly that more than once over the years. It's fine if all your sections are very short, but IntersectionObserver is not well suited to elements which can be taller than the viewport, and if you're supporting mobile devices that's very likely to be the case. You might suggest targeting the headings; they're likely to always be shorter than the viewport. But that doesn't help, it's the length of the sections which matters. Imagine for example that section 1 is taller than the viewport -- if we scroll down until we see the section 2 heading, but then start scrolling up again, section 2 is still listed active even though we can't see it, and the section 1 heading might still be pages away. It's not a simple problem to solve.

See https://github.com/w3c/IntersectionObserver/issues/124 for a relevant discussion.

tremby avatar Mar 10 '23 22:03 tremby

(@christiaanwesterbeek)

I solved it using createRef with useMemo instead of useRef.

const sectionRefs = useMemo(() => sections.map(() => createRef<HTMLDivElement>()), []);

This seems like an approach that ought to work, I think. I hadn't thought of using createRef.

However, you don't have sections as a dependency of that useMemo. Surely this means it will not correctly respond to changing numbers of sections; it'll only evaluate once with the initial value of sections and then just return the memoized result on successive calls, even if sections changes.

To fix that I think you should just be able to do

const sectionRefs = useMemo(() => sections.map(() => createRef<HTMLDivElement>()), [sections]);

Do refs need to be cleaned up? I don't think these will; references will remain in the memo table. Probably not a big deal.

tremby avatar Mar 10 '23 22:03 tremby

The sections I used are imported so no need to have those as a dependency. I am not aware of refs needing to be cleaned up.

christiaanwesterbeek avatar Mar 11 '23 07:03 christiaanwesterbeek

Even so, it wouldn't hurt.

If not cleaned up I think there will still be references to the old DOM nodes, and they will never be garbage collected until React decides to purge the memo table, which to my understanding currently never happens. But like I said it's probably not a big deal, at least unless the app is changing these sections a lot and is long running.

tremby avatar Mar 11 '23 09:03 tremby