skip to Main Content

I’m trying to create a footnote component for my new React version of my blog, but, so far, I’m not able to get the numbering right.

I’m applying basically the same logic I did for my Web Components version of the same blog component:

  1. Calculate the current footnote index based on how many footnotes you can document.querySelectorAll(".footnote").
  2. Use that index for the <sup> tag where the footnote should appear.
  3. Append the footnote index and content to a footnotes container at the end of the post.

I think this won’t work due to the rendering sequence React uses. Is there a workaround? Maybe there’s a query selector method for taking only elements before a certain element?

Here’s an example of what I have so far:

function Article() {
  return (
    <article>
      <p>The<FootNote html="Footnote 1" /> article content here<FootNote html="Footnote 2" />.</p>
      <FootNotesContainer />
    </article>
  )
}

function FootNotesContainer() {
  return (
    <div id="footnotes-container">
      <h2>Footnotes</h2>
    </div>
  )
}

function FootNote({ html }) {
  const [footNoteLink, setFootNoteLink] = React.useState("")
   
  React.useEffect(() => {
    const previousFootnotes =
      document.querySelectorAll(".footnote")
    const nextFootNoteNumber = previousFootnotes.length
         setFootNoteLink(nextFootNoteNumber.toString())

    const footNoteContent = document.createElement("div")
    footNoteContent.innerHTML = /* html */ `
      <a 
        href="#footnote-base-${nextFootNoteNumber}"
        id="footnote-${nextFootNoteNumber}"
        className="no-underline"
      >${nextFootNoteNumber}</a>:
      ${html}
    `

    const footNoteContainer = document.querySelector(
      "#footnotes-container"
    )
    footNoteContainer.appendChild(footNoteContent)
  }, [html])


  return (
    <sup className="footnote">
      <a
        href={`#footnote-${footNoteLink}`}
        id={`footnote-base-${footNoteLink}`}
        className="text-orange-400 no-underline"
      >
        {footNoteLink}
      </a>
    </sup>
  )
}

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <Article />
)
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id="root"></div>

I’ve also tried doing it in a more Reactish way, but I don’t know why the list being provided by the context isn’t being copied/mutated sequentially:

const FootnotesContext = React.createContext(null)

function FootnotesProvider({ children }) {
  const [footnotes, setFootnotes] = React.useState([])
  
  function addFootnote(html) {
    setFootnotes([...footnotes, html])
  }
  
  function totalFootnotes() {
    return footnotes.length
  }

  return (
    <FootnotesContext.Provider value={{ footnotes, addFootnote, totalFootnotes }}>
      {children}
    </FootnotesContext.Provider>
  )
}

function useFootnotes() {
  const context = React.useContext(FootnotesContext)
  
  if (!context) throw new Error("`useFootnotes` must be used within a `FootnotesProvider`.")
  
  return context
}

function Article() {
  return (
    <FootnotesProvider>
      <article>
        <p>The
          <FootNote html="Footnote 1" />{" "}
          article content here
          <FootNote html="Footnote 2" />.
        </p>

        <hr/>

        <FootNotesContainer />
      </article>
    </FootnotesProvider>
  )
}

function FootNotesContainer() {
  const { footnotes } = useFootnotes()
  
  console.log(footnotes)
  
  return (
    <div id="footnotes-container">
      <h2>Footnotes</h2>
      <h3>Total Footnotes: {footnotes.length}</h3>
      
      {
        footnotes.map((f, i) => (
          <div className="footnote" key={i}>
            <a 
              href={`#footnote-sup-${i}`}
              id={`footnote-${i}`}
            >{i + 1}</a>:
            <p>{f}</p>
          </div>
        ))
      }
    </div>
  )
}

function FootNote({ html }) {
  const { footnotes, addFootnote, totalFootnotes } = useFootnotes()
  
  const i = totalFootnotes() + 1

  React.useEffect(() => {
    addFootnote(html)
  }, [])

  return (
    <sup className="footnote-sup">
      <a
        href={`#footnote-${i}`}
        id={`footnote-sup-${i}`}
      >
        {i}
      </a>
    </sup>
  )
}

ReactDOM.createRoot(document.getElementById("root")).render(<Article />)
.footnote {
  display: flex;
  gap: 4px;
  align-items: center;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id="root"></div>

2

Answers


  1. The React Context way

    One way to solve this is using React Context. Here is the revised and updated code using React Context:

    const FootnotesContext = React.createContext(null);
    
    function FootnotesProvider({ children }) {
      const [footnotes, setFootnotes] = React.useState([]);
      const footnotesRef = React.useRef([]);
    
      function addFootnote(html) {
        footnotesRef.current = [...footnotesRef.current, html];
        setFootnotes([...footnotesRef.current, ]);
      }
    
      function totalFootnotes() {
        return footnotes.length;
      }
    
      return (
        <FootnotesContext.Provider value={{ footnotes, addFootnote, totalFootnotes,  }}>
          {children}
        </FootnotesContext.Provider>
      );
    }
    
    function useFootnotes() {
      const context = React.useContext(FootnotesContext);
    
      if (!context) throw new Error('`useFootnotes` must be used within a `FootnotesProvider`.');
    
      return context;
    }
    
    function Article() {
      return (
        <FootnotesProvider>
          <article>
            <p>
              The <FootNote html="Footnote 1" /> article content here <FootNote html="Footnote 2" />.
            </p>
    
            <hr />
    
            <FootNotesContainer />
          </article>
        </FootnotesProvider>
      );
    }
    
    function FootNotesContainer() {
      const { footnotes } = useFootnotes();
    
      return (
        <div id="footnotes-container">
          <h2>Footnotes</h2>
          <h3>Total Footnotes: {footnotes.length}</h3>
    
          {footnotes.map((f, i) => (
            <div className="footnote" key={i}>
              <a href={`#footnote-sup-${i + 1}`} id={`footnote-${i}`}>
                {i + 1}
              </a>:
              <p>{f}</p>
            </div>
          ))}
        </div>
      );
    }
    
    function FootNote({ html }) {
      const { footnotes, addFootnote, totalFootnotes } = useFootnotes();
      const footnoteRef = React.useRef(null);
      const [index, setIndex] = React.useState(0)
    
      React.useEffect(() => {
        addFootnote(html);
      }, []);
    
      React.useEffect(() => {
        setIndex(footnotes.indexOf(html) + 1)
      }, [footnotes])
    
      return (
        <sup className="footnote-sup" ref={ref => footnoteRef.current = ref}>
          <a
            href={`#footnote-${index}`}
            id={`footnote-sup-${index}`}
          >
            {index}
          </a>
        </sup>
      )
    }
    
    ReactDOM.createRoot(document.getElementById("root")).render(<Article />);
    .footnote {
      display: flex;
      gap: 4px;
      align-items: center;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
    <script src="
      https://cdn.jsdelivr.net/npm/[email protected]/runtime.min.js
    "></script>
    <div id="root"></div>

    Key changes

    • We added a footnotesRef to ensures that footnotes are added sequentially.
    • We restructured the FootNote component to correct render footnote numbering.
    • We corrected the links in FootNotesContainer.
    • We didn’t use DOM manipulation!

    This should fix the footnotes issue.

    The generator way (not recommended)

    Another approach is using ES6 generator functions. Here is an example of a basic generator function:

    function* genId() {
        var index = 0;
        while (true)
            yield index++;
        }
    }
    
    let gen = genId();
    
    console.log(gen.next().value); // 0
    console.log(gen.next().value); // 1
    console.log(gen.next().value); // 2
    

    Learn more about generator functions here.

    Here is the revised and updated code:

    function Article() {
      return (
        <article>
          <p>The<FootNote html="Footnote 1" /> article content here<FootNote html="Footnote 2" />.</p>
          <FootNotesContainer />
        </article>
      );
    }
    
    function FootNotesContainer() {
      return (
        <div id='footnotes-container'>
          <h2>Footnotes</h2>
        </div>
      );
    }
    
    function* generateId() {
      let idCounter = 1;
      while (true) {
        yield idCounter++;
      }
    }
    
    const gen = generateId();
    
    function useFootnoteId() {
      const [id, setId] = React.useState(0);
    
      React.useEffect(() => {
        setId(gen.next().value);
      }, [gen]);
    
      return id;
    }
    
    function FootNote({ html }) {
      const footNoteLink = useFootnoteId();
    
      React.useEffect(() => {
        const footNoteContent = document.createElement('div');
        footNoteContent.innerHTML = /* html */ `
        <a href="#footnote-base-${footNoteLink}" id="footnote-${footNoteLink}" className="no-underline">${footNoteLink}</a>:
        ${html}
        `;
        const footNoteContainer = document.querySelector(
          '#footnotes-container'
        );
        footNoteContainer.appendChild(footNoteContent);
    
        return () => {
          // Cleanup function to remove the footnote content when the component unmounts
          footNoteContainer.removeChild(footNoteContent);
        };
      }, [html, footNoteLink]);
    
      return (
        <sup className='footnote'>
          <a
            href={`#footnote-${footNoteLink}`}
            id={`footnote-base-${footNoteLink}`}
            className='text-orange-400 no-underline'
          >
            {footNoteLink}
          </a>
        </sup>
      );
    }
    
    ReactDOM.createRoot(
      document.getElementById("root")
    ).render(
      <Article />
    )
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
    <script src="
    https://cdn.jsdelivr.net/npm/[email protected]/runtime.min.js
    "></script>
    
    <div id="root"></div>

    Key changes

    • We no longer use previousFootnotes.length to calculate what footNoteLink is. Instead we use useFootnoteId and generateId functions.

      Note: We’ve removed DOM reads, so actions like deleting footnotes may require extra steps.

    • We implemented a cleanup function in FootNotesContainer to avoid unnecessary footnote creation due to excessive re-renders.

    Now footnotes should work the way you want them to.

    Login or Signup to reply.
  2. with context, without state

    Here’s my first attempt at it. I chose React useRef because we should be able to accomplish the goal with a single render. Whenever the provider renders, it resets its local ref. As the provider’s children render, ref is updated.

    function App() {
      return <div>
        <p>
          Hello <Footnote children="hello" />.
          <Child />
          World <Footnote children="world" />.
        </p>
        <Footnotes />
      </div>
    }
    
    function Child() {
      return <span>More text from the child
        <Footnote children="more" />
        and a little more <Footnote children="and more"/>.
      </span>
    }
    

    As you can see this works for Footnote components that appear at different levels in the component tree. One caveat to this approach is the footnotes list must be rendered after all individual Footnote components –

    Hello [1].More text from the child[2]and a little more [3].World [4].
    
    [1]: hello
    [2]: more
    [3]: and more
    [4]: world

    We can implement Footnote and Footnotes as –

    function Footnote(props) {
      const context = React.useContext(Context)
      const id = context.add(props.children)
      return <sup>[{id}]</sup>
    }
    
    function Footnotes() {
      const context = React.useContext(Context)
      return <ul>
        {context.get().map((children, index) => (
          <li key={index}>
            [{index + 1}]: {children}
          </li>
        ))}
      </ul>
    }
    

    And the Context and Provider

    const defaultContext = {
      get: () => [],
      add: () => {
        throw Error(`add called outside of context`)
      }
    }
    
    const Context = React.createContext(defaultContext)
    
    function Provider(props) {
      const footnotes = React.useRef([])
      footnotes.current = []
      const add = children => footnotes.current.push(children)
      const get = () => footnotes.current
      return <Context.Provider
        children={props.children}
        value={{ add, get }}
      />
    }
    

    Run the snippet below to verify the result in your own browser –

    const defaultContext = {
      get: () => [],
      add: () => {
        throw Error(`add called outside of context`)
      }
    }
    
    const Context = React.createContext(defaultContext)
    
    function Provider(props) {
      const footnotes = React.useRef([])
      footnotes.current = []
      const add = children => footnotes.current.push(children)
      const get = () => footnotes.current
      return <Context.Provider
        children={props.children}
        value={{ add, get }}
      />
    }
    
    function Footnote(props) {
      const context = React.useContext(Context)
      const id = context.add(props.children)
      return <sup>[{id}]</sup>
    }
    
    function Footnotes() {
      const context = React.useContext(Context)
      return <ul>
        {context.get().map((children, index) => (
          <li key={index}>
            [{index + 1}]: {children}
          </li>
        ))}
      </ul>
    }
    
    function App() {
      return <div>
        <p>
          Hello <Footnote children="hello" />.
          <Child />
          World <Footnote children="world" />.
        </p>
        <Footnotes />
      </div>
    }
    
    function Child() {
      return <span>More text from the child
        <Footnote children="more" />
        and a little more <Footnote children="and more"/>.
      </span>
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(
      <Provider>
        <App />
      </Provider>
    )
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    I’ll leave it to you to create actual a elements and generate the corresponding href. To ensure URLs stay the same, I would advise not using the footnote’s number as part of the URL.

    An improvement may be to return a component from the context’s add function so the Footnote component does not take on this concern –

    function Provider(props) {
      const add = children => {
        const id = footnotes.current.push(children)
        const fragment = children.split(" ").join("-")
        return <a href=`#${encodeURIComponent(fragment)}`>${id}</a>
      return // ...
    }
    
    function Footnote(props) {
      const context = React.useContext(Context)
      const link = context.add(props.children) // <a href=...>...</a>
      return <sup>[{link}]</sup>
    }
    

    If you find drawbacks to this approach, please share and I’ll try to address them ^-^

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search