I have a to-do list where tasks can be either a single big textarea (called dataArea here) or a list of those textareas. Those textareas should grow in height as content is added, which I do by setting the height to its scrollHeight on input (via handleInput). What I want to do is let folks toggle between that plain textarea and list of textareas (via toggleChecklist), using state to store the content.
However, when the content is set via state—not direct user input—the handleInput function isn't reached and I must set that from a different function or manually fire onInput. Either way, I believe I must use a ref (blobRef) to access that element to set its height. However, blobRef is null after toggling to/from checklist. Why is that?
Here's where that's [not] happening in full context (I think only the Form.js file is what needs looking at): https://github.com/werdnanoslen/tasks/blob/help/src/components/Form.js#L85
And here's some code previews:
const blobRef = useRef(null)
...
function handleInput(e, i) {
const element = e.target
if (checklist) {
let checklistDataCopy = [...checklistData]
checklistDataCopy[i] = { ...checklistDataCopy[i], data: element.value }
setChecklistData(checklistDataCopy)
} else {
setData(element.value)
}
element.style.height = '0'
element.style.height = element.scrollHeight + 'px'
}
function toggleChecklist() {
setChecklist((prevChecklist) => !prevChecklist)
if (checklist) {
const n = String.fromCharCode(13, 10) //newline character
setData(checklistData.reduce((p, c) => p.concat(c.data + n), ''))
blobRef.current && blobRef.current.dispatchEvent(new Event('input'))
}
}
function dataArea(item?, index?) {
return (
<textarea
id={item ? item.id : 'add-task'}
name="data"
className="input"
value={item ? item.data : data}
onKeyDown={(e) => addChecklistItem(e, index)}
onInput={(e) => handleInput(e, index)}
onFocus={() => setEditing(true)}
placeholder={inputLabel}
rows="1"
ref={item ? (item.id === newItemId ? lastRef : undefined) : blobRef}
// HELP ^^^ blobRef is null after toggling to/from checklist
/>
)
}
...
return (
<>
<form onSubmit={handleSubmit} onBlur={blurCancel}>
<label htmlFor="add-task" className="visually-hidden">
{inputLabel}
</label>
{checklist ? checklistGroup : dataArea()}
{isEditing ? editingTemplate : ''}
</form>
</>
)
On the first render the ref is null. Only on the second render will it be populated. This is because during the first render pass, the HTML is not yet loaded, so there is no HTML element reference.
This applies to when you switch to the other component and back to dataArea
, as when you switch away react sets it to null when it unmounts since the element reference is gone from the DOM. So when it remounts again, on the first render it will be null.
You can sometimes get around this sort of problem with useLayoutEffect
which only runs when the DOM has rendered.
Your code is a little odd in that you also are conditionally applying a ref. Normally, you want this to be stable, I would expect it to be simply:
ref={blobRef}
And any branching would be done when you consume the ref, not set it. It will be quite hard to manage otherwise.
However, before I go further I should warn you grabbing the element and setting its height is not actually a good idea. This violates React, because you shouldn't change a DOM node's properties such that they no longer can be reproduced by React. There is now a difference between reality and React's internal VDOM implementation.
This is quite tricky though, I'd recommend just using a library: https://github.com/Andarist/react-textarea-autosize/tree/main/src. These libraries get around the problem by rendering a cloned off-screen text area outside of React's remit and taking the heights from that, then applying them using the style
prop on the "real" textarea.