React ref null after re-rendering element - javascript

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 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 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 nodes properties such that they no longer can be reproduced by React. There is now a different 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 use the style prop on the "real" textarea.

A few notes around your implementation:
On the first render, the ref is null and not undefined because of component mounting or unmounting and setting the state.
Your ref is assigned on further renders.
You are doing DOM manipulation by directly assigning heights to DOM element which is not a correct way of doing it.
You can check by console logging that you are not getting the ref in actual as blobRef.current got nothing/undefined and thus handleInput is not triggered, thats why you added an extra && check there. You can handle the case of undefined too. If you don't get any ref then?
You cannot achieve this directly, either use a package or take the height from actual DOM instead of VDOM and assign on the actual DOM element.
A NPM packages can be helpful in your case as mentioned by #adam-thomas.
Example is: react-textarea-autosize

Related

Plotly - Dash #app.callback does not re-render Dash elements inside custom component unless re-render is triggered somehow

Inside usage.py I try to update graph-tooltip:
#app.callback(
Output("grid-value", "children"),
Input("live-graph", "hoverData"),
)
def display_hover(hoverData):
if hoverData is None:
return no_update
# demo only shows the first point, but other points may also be available
pt = hoverData["points"][0]
x = pt["x"]
y = pt["y"]
children = f"{x}:{y}"
return children
Both the live-graph and grid-value elements are inside custom Dash component (dash-react-flow, but with a new node type that can contain anything passed to it from Dash).
Now if I over a the live-graph, it gets updated only once I click, so it seems not to be re-rendering properly.
I implemented the node:
function AnyContentNode({ data }) {
return (
<div className="any-content-node" id={data.id} >
<div>
{
data.elements?.map((node, index) => {
return window.elements[data.elementId][node];
})
}
</div>
{
data.handles?.map((node, index) => {
return <Handle key={index} type={node.direction} position={node.position} id={node.id} />
})
}
</div>
);
}
where window.elements contains the node that is not being updated properly. I suspect that storing elements inside window.elements might be the cause of this, but I did not find any other way to pass those elements into the custom node without having to change the source code for ReactFlow.
So far it works for non-dynamic elements, but currently I got stuck when I have to update dynamic element. It gets occasionally updated, but that is not very good user-experience.
Is there a way to force re-render/or somehow fix this? I am quite new to React, so I don't have a good knowledge of the default hooks etc. I tried to see whether componentDidUpdate gets triggered and it does (so many times, every second).

Can the child component receive old data for manipulation from parent component?

Let's define a Tags component (a fancy checkbox group).
const Tags = ({ tags, selectedIds, onSelectionChange }) => {
const createClickHandler = (id) => () => {
const newSelectedIds = xor(selectedIds, [id]);
const selectedTags = newSelectedIds.map((id) =>
tags.find((tag) => tag.id === id)
);
onSelectionChange(selectedTags);
};
const isSelected = (id) => selectedIds.includes(id);
return (
<div>
{tags.map(({ id, text }) => (
<button
key={id}
type="button"
style={{ backgroundColor: isSelected(id) ? "gray" : "white" }}
onClick={createClickHandler(id)}
>
{text}
</button>
))}
</div>
);
};
This allows us to consume it like this:
export default function App() {
const tags = someUsers.map((user) => ({
id: user.id,
text: user.name,
value: user
}));
const [selectedTags, setSelectedTags] = useState([]);
const selectedIds = selectedTags.map((tag) => tag.id);
return (
<div>
<Tags
tags={tags}
selectedIds={selectedIds}
onSelectionChange={setSelectedTags}
/>
</div>
);
}
You can test this in https://codesandbox.io/s/musing-goldwasser-nmm13
I believe this is a decent design of a component and its props (the main focus is on the ease of consuming for the other components). We could perhaps remove selectedIds and add a selected flag in the tags prop, however this is beyond the question scope.
My colleague on the other hand insists that this can lead to bugs and should be avoided.
The reasoning is as follows: if we want to update the state we must use appropriate API - setState(oldState => //data manipulation to produce new state) from useState (https://reactjs.org/docs/hooks-reference.html#functional-updates)
Since the parent passes the state directly to the children we can't be sure that the child component filters data based on the latest data. Basically, this issue: https://reactjs.org/docs/faq-state.html#why-is-setstate-giving-me-the-wrong-value
His implementation would be something along these lines:
const Tags = ({ tags, selectedIds, onTagClick }) => {
const isSelected = (id) => selectedIds.includes(id);
return (
<div>
{tags.map(({ id, text }) => (
<button
key={id}
type="button"
style={{ backgroundColor: isSelected(id) ? "gray" : "white" }}
onClick={() => onTagClick(id)}
>
{text}
</button>
))}
</div>
);
};
In this case, we lift the whole filtering to a parent component
const handleTagClick = (id) =>
setSelectedTagsIds((oldIds) => {
if (oldIds.includes(id)) return oldIds.filter((oldId) => oldId !== id);
return [...oldIds, id];
});
You can test this in: https://codesandbox.io/s/kind-cdn-j7cg3
or another version:
const Tags = ({ tags, selectedIds, setSelectedIds }) => {
const isSelected = (id) => selectedIds.includes(id);
const handleTagClick = (id) =>
setSelectedIds((oldIds) => {
if (oldIds.includes(id)) return oldIds.filter((oldId) => oldId !== id);
return [...oldIds, id];
});
return (
<div>
{tags.map(({ id, text }) => (
<button
key={id}
type="button"
style={{ backgroundColor: isSelected(id) ? "gray" : "white" }}
onClick={() => handleTagClick(id)}
>
{text}
</button>
))}
</div>
);
};
in this case, we leave the filtering to the Tags component however we pass the function which allows modification of state based on old state.
You can test this code in https://codesandbox.io/s/relaxed-leftpad-y13wo
In my opinion, this case is a completely different scenario that React docs never specifically address.
As far as I understand React rendering engine will always ensure that the child nodes get the newest props so a situation where a child component filters (or does other manipulation) with stale data is simply impossible. I would like to quote some docs for this however I haven't found any information on this specific situation.
All I know is:
with my many years of React experience I have yet to encounter any bugs with my approach
other 3rd party libraries use the same design
Can someone (with deep React knowledge) provide more insight why I am correct or wrong in this instance?
For you to notice the difference, you could simulate a delay in the update of the selection. i.e., the user of your component needs to do some async stuff when selecting a tag
const [selectedTags, setSelectedTags] = useState([]);
const selectedIds = selectedTags.map((tag) => tag.id);
const asyncSelection = (tags) => {
setTimeout(() => setSelectedTags(tags), 1000);
};
...
<Tags
tags={tags}
selectedIds={selectedIds}
onSelectionChange={asyncSelection}
/>
You can try here clicking each option one by one, and when all updates run, not all options will be selected (which is not expected). Since the component didn't render immediately, the handler was not updated and the second click is executed with an old state, therefore, the sequences of the selections are not correctly synced. Of course, this is a contrived example, but it could be the case in a very heavy UI that 2 clicks happen without the Tags component being rerendered.
On the other hand, letting the user have more control over the state would be possible to handle this situation. Once again, if you try here clicking each option one by one, in the end, all will be selected as expected
const handleTagClick = (id) => {
setTimeout(() => {
setSelectedTagsIds((oldIds) => {
if (oldIds.includes(id)) return oldIds.filter((oldId) => oldId !== id);
return [...oldIds, id];
});
}, 1000);
};
As far as I can see the things you're talking about are two different issues.
If the props of a child are updated it will trigger a rerender of that component. There are edge cases where that gets tricky like with useRef or some callbacks but that's besides the point. The filtering and things you're doing will never be different or affected in any way as long as it's dependent on the props changing and if the component receives new props it will rerender the child and reapply the filters without any issues.
The second issue is sort of different from the first one. What could happen is that the tag state is repeatedly updated and only one of those states are passed to the child, that's what you want to avoid. Essentially you have to make sure the parent state has actually updated correctly before passing it to a child. The child will always update and filter and do everything correctly exactly on what's passed to it, your problem here is making sure you're actually passing the correct props.
There's no need to move anything to the parent component, the child will update itself correctly when the parent tag state updates and passes that new state to the child, the only thing you have to look out for here is that you don't update the parent state multiple times and cause https://reactjs.org/docs/faq-state.html#why-is-setstate-giving-me-the-wrong-value and end up passing the wrong props to the child. For example if someone spams the group checkbox on and off quickly. Even then if you pass the wrong props to the child the child will still update itself and reapply the filtering and everything, just on the wrong props.
React will do its batch state update on something like a 10ms interval (I'm not exactly sure how long it is). So if someone clicks the checkbox and it updates the tag state at 6/10ms it will rerender the component 4ms later when it does the batch state update. If hypothetically during those 4ms you click it off again, or if straight after it updated you click it off again, it's where weird things start happening. This is why if you use the increment counter (like in that example) multiple times it won't actually increase it by 3, only by 1, since the code will execute all 3 times on 0 before it did the state update 10ms later. That being said even if you spam that checkbox on and off all the time (spamming the tag array state), I don't see any way how it would go out of sync, every 10ms it will update and rerender the child and the moment you stop spamming it the child will finally rerender on the last current parent state and be correct. I don't see how you could really have an issue with that in your example. It could cause an issue with something like a counter but not with your tags because of the fact that a counter is a cumulative addition on previous values whereas your tags is a static set of values (that is the key difference).

Use GraphQL data in gatsby-browser?

I have an app with some route ID's (basically a bunch of sections in a long SPA) that I have defined manually. I fetch these in gatsby-browser.js and use them in conjunction with shouldUpdateScroll, checking if the route ID exist, and in that case, scroll to the position of the route/section.
Example:
export const shouldUpdateScroll = ({ routerProps: { location } }) => {
const container = document.querySelector('.site')
const { pathname } = location
const projectRoutes = [`project1`, `project2`]
if (projectRoutes.indexOf(pathname) !== -1) {
const target = document.getElementById(pathname)
container.scrollTop = target.offsetTop;
}
return false
}
This works well for my usecase.
Now I want to add something similar for a page where the content is dynamically created (fetched from Sanity). From what I understand I cannot use GraphQL in gatsby-browser.js, so what is the best way to get the ID's from Sanity to gatsby-browser.js so I can use them to identify their scroll positions?
If there's some other better way to achieve the same result I'm open to that of course.
I think that you are over complexing the issue. You don't need the gatsby-browser.js to achieve it.
First of all, because you are accessing directly to the DOM objects (using document.getElementById) and you are creating precisely a virtual DOM with React to avoid pointing the real DOM. Attacking directly the real DOM (like jQuery does) has a huge performance impact in your applications and may cause some issues since in the SSR (Server-Side Rendering) the element may not be created yet.
You are hardcoding a logic part (the ids) on a file that is not intended to do so.
I think you can achieve exactly the same result using a simple function using a few hooks.
You can get the same information as document.getElementById using useRef hook and scrolling to that position once needed.
const YourComponent= (props) => {
const sectionOne = useRef(null);
const sectionTwo = useRef(null);
useEffect(()=>{
if(typeof window !== `undefined`){
console.log("sectionOne data ",sectionOne.current)
console.log("sectionTwo data ",sectionTwo.current)
if(sectionOne) window.scrollTo( 0, 1000 ); // insert logic and coordinates
}
}, [])
return (
<>
<section ref={sectionOne}>Section 1</section>
<section ref={sectionTwo}>Section 2</section>
</>
);
}
You can isolate that function into a separate file in order to receive some parameters and return some others to achieve what you want. Basically, the snippet above creates a reference for each section and, once the DOM tree is loaded (useEffect with empty deps, []) do some stuff based on your logic.
Your document.getElementById is replaced for sectionOne.current (note the .current), initially set as null to avoid unmounting or cache issues when re-hidration occurs.

Dynamically replace elements using jsx

I'm having an array data.info that is being updated over time and I'm trying to replace placeholder rendered elements with another. So by default app.js looks like this
return (
<Fragment>
{data.info.map((index) => {
return <Typography key={index} variant="h6" className={classes.title}>Demo</Typography>
})}
</Fragment>
)
Also I have a hook with async function to subscribed to data.info.length.
useEffect(
() => {
if (!initialRender.current) {
if (data.info.length!==0) {
for (let i = data.info.length-iScrollAmount+1 ; i < data.info.length+1; i++) {
firstAsync(i)
}
}
} else {
initialRender.current = false
}
},
[data.info.length]
)
async function firstAsync(id) {
let promise = new Promise(() => {
setTimeout(() => console.log(document.getElementById(id)), 500)
});
}
With document.getElementById() and id I can get to every element that was rendered and change it. And here goes the problems.
I'm using material-ui so I can't get to <Typography/> because it is transformed into <h6/>. Probably that is not a problem since I need to replace contents, so I can find parent element and remove all children. Is that way correct?
After I delete children how do I add content using jsx? What I mean is that in async function I'll get an array that I want to use in new element <NewCard/> to dynamically put into <Fragment/>. Yet I did not find any example how to do that.
It is not a good practice to change DOM Nodes directly in React, and you need to let React do the rendering for you and you just tell react what to do.
in your case you need to define a React State for your data and set your state inside your firstAsync function and then use your state to render whatever html element or React component which you want
React does not encourage the practice of manipulating the HTML DOM nodes directly.
Basically you need to see 2 things.
State which is a special variable whose value is retained on subsequent refresh. Change in reference in this variable will trigger component and its children a refresh/re-render.
Props which is passed to every Component and is read only. Changing in props causes refresh of component by default.
In your example, based on data.info you want to render Typography component.
Solution
First thing is your map function is incorrect. First parameter of map function is item of list and second is index. If you are not sure if info will always be present in data, you may want to have a null check as well.
{(data.info || []).map((info, index) => {
return <Typography key={index} variant="h6" className={classes.title}>{info.text}</Typography>
})}
You should be passing info from map to Typography component. Or use info value in content of Typography as shown above.
Update data.info and Typography will update automatically. For this, please make sure, data.info is a component state and not a plain variable. Something like
const [data, setData] = React.useState({});
And when you have value of data (assuming from API), then
setData(responseApi);

Reactjs: how to write a method to handle component creation and unmount

So let's say there is acomponent which displays 2 child components: a document list and the selected document. By default the selected document component is not rendered, only when a document is selected from the list. And i also want this whole thing work when a new document is selected from the list.
There is a state which holds the document content and responsible for the selected document rendering, so i thought i'm going to set it to null in the method which handles the list item selection in order to unmount the previously created child component. Like this (excerpts from the parent class):
handleResultListItemClick(docname) {
if (this.state.sectioncontainer != null) this.setState({sectioncontainer: null},()=>{console.log("muhoo");});
var selected_doc = this.state.resultlist.filter((doc) => {
return docname === doc.properties.title;
});
this.setState({sectioncontainer: selected_doc[0].content.sections},()=>{console.log("boohoo");});
}
...
render() {
return (
...
{this.state.sectioncontainer != null && <SectionContainer listOfSections={this.state.sectioncontainer}/>}
);
}
The only problem is that state handling is not fast enough (or something) in react, because putting the state nullification and its new value setting in the same method results in no change in ReactDOM.
With the above code, the component will be created when the parent component first rendered, but after selecting a new doc in the list results in no change.
How should i implement this in way which works and also elegant?
I found this: ReactDOM.unmountComponentAtNode(container) in the official react docs. Is this the only way? If yes, how could i get this container 'name'?
Edit:
Based on the answers and thinking the problem a bit more through, i have to explain more of the context.
As kingdaro explained, i understand why there is no need to unmount a child component on a basic level, but maybe my problem is bit more sophisticated. So why did i want to unmount the child?
The documents consist of several subsections, hence the document object which is passed to the child component is an array of objects. And the document is generated dynamically based on this array the following way (excerpt from the SectionContainer class which is responsible to display the document):
buildSectionContainer() {
return this.props.listOfSections.map((section, index) =>
{
if (section.type === 'editor') return (
<QuillEditor
key={index}
id={section.id}
modules={modules}
defaultValue={section.content}
placeholder={section.placeholder}
/>
);
else if (section.type === 'text') return (
<div key={index}>{section.value}</div>
);
}
);
}
render() {
return (
<div>
{this.buildSectionContainer()}
</div>
);
}
The SectionContainer gets the array of objects and generate the document from it according to the type of these sections. The problem is that these sections are not updated when a different doc is selected in the parent component. I see change only when a bigger length array is passed to the child component. Like the firstly selected doc had an array of 2 elements, and then the newly selected doc had 3 elements array of sections and this third section is added to the previously existing 2, but the first 2 sections remained as they were.
And that’s why i though it’s better to unmount the child component and create a new one.
Surely it can happen that i miss something fundamental here again. Maybe related to how react handles lists. I just dont know what.
Edit2:
Ok, figured out that there is a problem with how i use the QuillEditor component. I just dont know what. :) The document updates, only the content of QuillEditors doesnt.
The reason your current solution doesn't actually do anything is because React's state updates are batched, such that, when setState is called a bunch of times in one go, React "combines" the result of all of them. It's not as much of a problem with being "not fast enough" as it is React performing only the work that is necessary.
// this...
this.setState({ message: 'hello', secret: 123 })
this.setState({ message: 'world' })
// ...becomes this
this.setState({ message: 'world', secret: 123 })
This behavior doesn't really have much to do with the problem at hand, though. As long as your UI is a direct translation of state -> view, the UI should simply update in accordance to the state.
class Example extends React.Component {
state = {
documentList: [], // assuming this comes from the server
document: null,
}
// consider making this function accept a document object instead,
// then you could leave out the .find(...) call
handleDocumentSelection = documentName => {
const document = this.state.documentList.find(doc => doc.name === documentName)
this.setState({ document })
}
render() {
const { document } = this.state
return (
<div>
<DocumentList
documents={this.state.documentList}
onDocumentSelection={this.handleDocumentSelection}
/>
{/*
consider having this component accept the entire document
to make it a little cleaner
*/}
{document && <DocumentViewer document={document.content.sections} />}
</div>
)
}
}

Categories

Resources