Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Variable number of sections #2

Open
tremby opened this issue Nov 21, 2019 · 11 comments
Open

Variable number of sections #2

tremby opened this issue Nov 21, 2019 · 11 comments

Comments

@tremby
Copy link

tremby commented Nov 21, 2019

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
Copy link
Owner

Purii commented Nov 26, 2019

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

@mayteio
Copy link

mayteio commented Mar 4, 2020

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

@tremby
Copy link
Author

tremby commented Mar 5, 2020

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.

@mayteio
Copy link

mayteio commented Mar 5, 2020

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

@MiroslavPetrik
Copy link

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

@andrew310
Copy link

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

@christiaanwesterbeek
Copy link

christiaanwesterbeek commented Mar 10, 2023

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

@tremby
Copy link
Author

tremby commented Mar 10, 2023

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

@tremby
Copy link
Author

tremby commented Mar 10, 2023

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

@christiaanwesterbeek
Copy link

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.

@tremby
Copy link
Author

tremby commented Mar 11, 2023

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.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants