6
votes

I have a Checkbox react component that has to support the indeterminate state, but I'm updating our components to forward refs properly. The checkbox component already uses a callback ref internally to set the indeterminate property. Here's the original component (simplified):

export type ICheckboxProps = {
  checked?: boolean
  indeterminate?: boolean
} & React.InputHTMLAttributes<HTMLInputElement>

export const Checkbox: React.FC<ICheckboxProps> = props => {
  const { checked = false, ...rest } = props;
  return (
    <input
      type="checkbox"
      checked={checked}
      ref={ref => {
        if (ref) {
          ref.indeterminate = !checked ? indeterminate! : false;
        }
      }}
      {...rest}
    />
  )
}

Now, since this is a UI library, I'm trying to forward a ref as well. But that clashes with the callback ref - now I have two separate refs. Additionally, the forwarded ref could be a callback ref. So I can't even access the instance there to set the indeterminate property. I've tried a bunch of stuff, but no matter what I do typescript's helpful red underlines tell me that I'm wrong.

How do I both apply the forwarded ref to the input and set the indeterminate property on the input?

Here's it most of the way but there's an issue noted:

export type ICheckboxProps = {
  checked?: boolean
  indeterminate?: boolean
} & React.InputHTMLAttributes<HTMLInputElement>

export const Checkbox = React.forwardRef<HTMLInputElement, ICheckboxProps>((props, inRef) => {
  const { checked = false, ...rest } = props;
  return (
    <input
      type="checkbox"
      checked={checked}
      ref={ref => {
        if (ref) {
          ref.indeterminate = !checked ? indeterminate! : false;
          if (inRef) {
            if (typeof inRef === "function") {
              inRef(ref)
            } else {
              inRef.current = ref // Cannot assign to 'current' because it is a read-only property.
            }
          }
        }
      }}
      {...rest}
    />
  )
})
2

2 Answers

5
votes

The useImperativeHandle hook does almost exactly the same as your second example. The assignment of ref.current to inRef is then handled internally by React, and you don't have to break the contract by changing the readonly property.

export const Checkbox = React.forwardRef<HTMLInputElement, ICheckboxProps>((props, inRef) => {
  const { checked = false, indeterminate, ...rest } = props;

  const ref = useRef<HTMLInputElement>(null)
  useImperativeHandle(inRef, () => ref.current!, [ref])

  return (
    <input
      type="checkbox"
      checked={checked}
      ref={ref}
      {...rest}
    />
  )
})

In typescript playground

I would like to make a comment on the non-null assertion on ref.current. As far as I can tell, refs for children are resolved before refs for parents, but the only related statement in the documentation I could find is calling inputRef.current.focus() without a null guard in the documentation of useImperativeHandle

1
votes

You can do anything you want with the forwarded ref, including setting its current value:

const Checkbox = React.forwardRef(({ checked = false, indeterminate, ...rest }, forwardedRef) => (
  <input
    type="checkbox"
    checked={checked}
    ref={(inputElement) => {
      if (inputElement) {
        inputElement.indeterminate = !checked && indeterminate
      }

      if (forwardedRef) {
        if(typeof(forwardedRef) === "function") {
          forwardedRef(inputElement)
      } else {
        forwardedRef.current = inputElement
      }
    }
    {...rest}
  />
))