Purii/react-use-scrollspy

Variable number of sections

tremby opened this issue · 11 comments

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").

Purii commented

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 :-)

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>
)}

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.

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

@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 thanks for the tip, I ended up using this method

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>
  );
};

(@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 w3c/IntersectionObserver#124 for a relevant discussion.

(@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.

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.

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.