Ligang Yan颜力刚

Six React ref challenges. How many can you get?

When to use a ref and when to use state, why a module-level variable gets shared across components, how to drive a video and a scroll position with refs, and how forwardRef works. Six problems, each with the broken code and the fix.

reactrefjavascript

中文版:6 个 React Ref 代码挑战,你能过几个?

Challenge 1: keeping a setTimeout in a ref

Look at this code. It renders an input, a Send button and an Undo button. The intended behaviour: three seconds after clicking Send, a “Sent!” alert appears; if Undo is clicked within those three seconds, the alert is cancelled.

That is not what happens. Click Undo and “Sent!” still fires. Why, and how do you fix it?

import { useState } from 'react'

export default function Chat() {
  const [text, setText] = useState('')
  const [isSending, setIsSending] = useState(false)
  let timeoutID = null

  function handleSend() {
    setIsSending(true)
    timeoutID = setTimeout(() => {
      alert('Sent!')
      setIsSending(false)
    }, 3000)
  }

  function handleUndo() {
    setIsSending(false)
    clearTimeout(timeoutID)
  }

  return (
    <>
      <input disabled={isSending} value={text} onChange={(e) => setText(e.target.value)} />
      <button disabled={isSending} onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending && <button onClick={handleUndo}>Undo</button>}
    </>
  )
}

Explanation: when the component re-renders, every local variable is re-initialised, so timeoutID is null again rather than holding the timer. Store it in a ref instead; React keeps the ref’s value across renders.

The fix:

import { useState, useRef } from 'react'

export default function Chat() {
  const [text, setText] = useState('')
  const [isSending, setIsSending] = useState(false)
  const timeoutRef = useRef(null)

  function handleSend() {
    setIsSending(true)
    timeoutRef.current = setTimeout(() => {
      alert('Sent!')
      setIsSending(false)
    }, 3000)
  }

  function handleUndo() {
    setIsSending(false)
    clearTimeout(timeoutRef.current)
  }

  return (
    <>
      <input disabled={isSending} value={text} onChange={(e) => setText(e.target.value)} />
      <button disabled={isSending} onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending && <button onClick={handleUndo}>Undo</button>}
    </>
  )
}

Challenge 2: ref or state?

The problem here is that isOnRef.current = !isOnRef.current does not trigger a re-render. Changing ref.current never re-renders, so the button’s label never changes.

import { useRef } from 'react'

export default function Toggle() {
  const isOnRef = useRef(false)

  return (
    <button
      onClick={() => {
        isOnRef.current = !isOnRef.current
      }}
    >
      {isOnRef.current ? 'On' : 'Off'}
    </button>
  )
}

The fix:

import { useState } from 'react'

export default function Toggle() {
  const [isOn, setIsOn] = useState(false)

  return (
    <button
      onClick={() => {
        setIsOn(!isOn)
      }}
    >
      {isOn ? 'On' : 'Off'}
    </button>
  )
}

Challenge 3: a ref to fix shared state between components

Click the three buttons in any order and only the last one fires its alert. The cause: a single timeoutID variable is shared by every instance of the component. The fix is a ref, so each component has its own timeoutID.

let timeoutID

function DebouncedButton({ onClick, children }) {
  return (
    <button
      onClick={() => {
        clearTimeout(timeoutID)
        timeoutID = setTimeout(() => {
          onClick()
        }, 1000)
      }}
    >
      {children}
    </button>
  )
}

export default function Dashboard() {
  return (
    <>
      <DebouncedButton onClick={() => alert('Spaceship launched!')}>
        Launch the spaceship
      </DebouncedButton>
      <DebouncedButton onClick={() => alert('Soup boiled!')}>Boil the soup</DebouncedButton>
      <DebouncedButton onClick={() => alert('Lullaby sung!')}>Sing a lullaby</DebouncedButton>
    </>
  )
}

The fix:

import { useRef } from 'react'

function DebouncedButton({ onClick, children }) {
  const timeoutRef = useRef(null)
  return (
    <button
      onClick={() => {
        clearTimeout(timeoutRef.current)
        timeoutRef.current = setTimeout(() => {
          onClick()
        }, 1000)
      }}
    >
      {children}
    </button>
  )
}

export default function Dashboard() {
  return (
    <>
      <DebouncedButton onClick={() => alert('Spaceship launched!')}>
        Launch the spaceship
      </DebouncedButton>
      <DebouncedButton onClick={() => alert('Soup boiled!')}>Boil the soup</DebouncedButton>
      <DebouncedButton onClick={() => alert('Lullaby sung!')}>Sing a lullaby</DebouncedButton>
    </>
  )
}

Challenge 4: play and pause a video

To play or pause a <video> you have to call its play() and pause() methods, which means you need a ref to the DOM node.

import { useState, useRef } from 'react'

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false)

  function handleClick() {
    const nextIsPlaying = !isPlaying
    setIsPlaying(nextIsPlaying)
  }

  return (
    <>
      <button onClick={handleClick}>{isPlaying ? 'Pause' : 'Play'}</button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

The fix:

import { useState, useRef } from 'react'

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false)
  const ref = useRef(null)

  function handleClick() {
    const nextIsPlaying = !isPlaying
    setIsPlaying(nextIsPlaying)

    if (nextIsPlaying) {
      ref.current.play()
    } else {
      ref.current.pause()
    }
  }

  return (
    <>
      <button onClick={handleClick}>{isPlaying ? 'Pause' : 'Play'}</button>
      <video
        width="250"
        ref={ref}
        onPlay={() => setIsPlaying(true)}
        onPause={() => setIsPlaying(false)}
      >
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

Clicking Next should scroll the next image into the centre of the view. The catch: right after setIndex the DOM hasn’t updated yet, so calling scrollIntoView immediately scrolls to the old image. Use flushSync to force React to commit the update synchronously, then scroll:

import { useRef, useState } from 'react'
import { flushSync } from 'react-dom'

export default function CatFriends() {
  const selectedRef = useRef(null)
  const [index, setIndex] = useState(0)

  return (
    <>
      <nav>
        <button
          onClick={() => {
            flushSync(() => {
              if (index < catList.length - 1) {
                setIndex(index + 1)
              } else {
                setIndex(0)
              }
            })
            selectedRef.current.scrollIntoView({
              behavior: 'smooth',
              block: 'nearest',
              inline: 'center',
            })
          }}
        >
          Next
        </button>
      </nav>
      <div>
        <ul>
          {catList.map((cat, i) => (
            <li key={cat.id} ref={index === i ? selectedRef : null}>
              <img
                className={index === i ? 'active' : ''}
                src={cat.imageUrl}
                alt={'Cat #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  )
}

const catList = []
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i,
  })
}

Challenge 6: passing a ref into a child with forwardRef

Function components don’t receive ref by default. For a parent to focus an input inside a child, the child has to use forwardRef to pass the ref through to the real DOM node:

import { forwardRef, useRef } from 'react'

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />
})

export default function Form() {
  const inputRef = useRef(null)

  function handleClick() {
    inputRef.current.focus()
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>Focus the input</button>
    </>
  )
}

The problems are the challenges at the end of the “Referencing Values with Refs” and “Manipulating the DOM with Refs” chapters of the official react.dev tutorial, code reproduced under CC BY 4.0; the explanations are my own. 中文版.