In this post we'll discuss about the flushSync
utility provided by react-dom
.
Let's try and understand what flushSync
is and how it can useful through an example.
As always, it's a simple todo example but the point to note here is that the todo container has fixed height and is scrollable.
So, there's our App
component that has a todos
state and returns a list of todos along with a form.
export default function App() {
const [todos, setTodos] = useState(mockTodos);
const onAdd = (newTask) => {
setTodos([...todos, { id: uuid(), task: newTask }]);
};
return (
<section className="app">
<h1>Todos</h1>
<ul style={{ height: 200, overflowY: "auto" }}>
{todos.map((todo) => (
<li key={todo.id}>{todo.task}</li>
))}
</ul>
<AddTodo onAdd={onAdd} />
</section>
);
}
The AddTodo
component is also fairly simple, it just manages the input state and once the form is submitted it calls the onAdd
prop with the new todo.
const AddTodo = ({ onAdd }) => {
const [taskInput, setTaskInput] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (!taskInput.trim()) return;
setTaskInput("");
onAdd(taskInput.trim());
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Your Task"
value={taskInput}
onChange={(e) => setTaskInput(e.target.value)}
/>
<button>Add Task</button>
</form>
);
};
Now that we've an understanding of how our code works, suppose we want to add a functionality where every time a new todo is added, the container is scrolled to its bottom so that the newly added todo is visible to the user.
Think for while and figure out how you would go about implementing this functionality.
Using useEffect
hook
You might be thinking of using the effect hook. So, every time the todos
change just scroll the container to the bottom.
useEffect(() => {
listRef.current.scrollTop = listRef.current.scrollHeight;
// listRef is simply a ref attached to the ul
}, [todos]);
OR
useEffect(() => {
const lastTodo = listRef.current.lastElementChild;
lastTodo.scrollIntoView();
}, [todos]);
Both of the above scrolling logics work fine (you might even want to use the useLayoutEffect
hook in this situation in case you observe any jitters in scrolling).
But, I would not want to put this in either of these hooks, let me explain why.
The DOM manipulation (scrolling in this case) that we're trying to do here is a side effect (something that doesn't happen during rendering) and in React side effects usually happen inside event handlers, so in my opinion the best place to put this would be inside the onAdd
handler.
Also, if you go by the docs useEffect
should be your last resort, when you've exhausted all other options but haven't found the right event handler.
If you’ve exhausted all other options and can’t find the right event handler for your side effect, you can still attach it to your returned JSX with a
useEffect
call in your component. This tells React to execute it later, after rendering, when side effects are allowed. However, this approach should be your last resort. DOCS
Scrolling logic inside the handler
If you simply put the scrolling logic inside the handler (as shown below), you would notice that you're not exactly getting the desired results.
const onAdd = (newTask) => {
setTodos([...todos, { id: uuid(), task: newTask }]);
listRef.current.scrollTop = listRef.current.scrollHeight;
};
Because setTodos
is not synchronous, what happens is you scroll first and then the todos
actually get updated. So, what's in view is not the last todo but second to last.
So, to get it working as expected we would have to make sure that the logic for scrolling runs only after the todos
state has been updated. And that's where flushSync
comes handy.
Using flushSync
To use flushSync
, we need to import it from react-dom
: import { flushSync } from "react-dom";
And now we can wrap the setTodos
call inside flushSync
handler (as shown below).
const onAdd = (newTask) => {
flushSync(() => {
setTodos([...todos, { id: uuid(), task: newTask }]);
});
listRef.current.scrollTop = listRef.current.scrollHeight;
};
Now we have made sure that the state update happens synchronously and the logic for scrolling is executed only after the state has been updated.
That's it for this post, let me know situations where you would want to use flushSync
.
Peace ✌
Top comments (13)
Why dont you just use
setTimeout(()=> { listRef.current.scrollTop = listRef.current.scrollHeight;
}, 100)
Is this bad or any complications involved?
I don't see a reason why you would want to use
setTimeout
, this clearly a side effect and there are better options to handle side effects in React.And also how would you know what the exact timeout value should be? Let's say if you choose
100ms
(as mentioned in your example) and React does all the state update in5ms
, then the user of your app has to wait for95ms
(which is unnecessary) and suppose React takes200ms
then the whole purpose of usingsetTimeout
gets nullified.I've also elaborated the "Using
effect
hook" section, you might want to give it a read again.Ok Bro, Why so serious. I'm a beginner! I just asked a doubt there!!
Thanks for your response though!
One more doubt I've got here, Can we use async on functoin and await on the
setTimeout(()=> { listRef.current.scrollTop = listRef.current.scrollHeight;
}, 100)
line?
Would that work? If yes, is this considered a bad practice?
No no, you got me wrong, I didn't mean to be rude, I was just trying to answer your question.
Event handlers can be
async
functions, no problem in that, butsetTimeout
doesn't return a promise so there's no point of using anawait
with it (setTimeout
returns the timer ID which you can use to clear the timeout).Example below shows an
async
handler:No, I meant is it ok to have the code like this :
const onAdd = async (newTask) => {
await setTodos([...todos, { id: uuid(), task: newTask }]);
listRef.current.scrollTop = listRef.current.scrollHeight;
};
then the scrolling will wait till the settodo is done rght? or am I wrong?
Thats actually bad but not too bad cause you are not mutationg dom tree. Assume you are mutating dom tree in setimeout then it would have became worst.
Also settimeout is not guaranteed to run exactly after 100ms it may drift all the way beyond 300ms mark (minimal threshhold to show feedback to user of their action) which would irritate user. Try putting a long empty loop after setTimeout and you can see that happening.
const cancel = () => {
flushSync(() => {
changeModalRef.current?.close();
});
};
when i am opening cancelModal focus is not setting correctly
if we use multiple flushSync inside same method. Does it work as expected
The demo scroll Dev.to post page when click Add Task. It's worth fixing this issue.
Good article anyway.
Yeah I've noticed that too, but I don't know how to fix it 😅. Let me know if you have any solution to it.
I have no idea, but from my experience if the page is iframe the only way for outer page to know what the iframe is doing is listen to post messages that are send by the codesandbox, but that's just a guess, maybe looking at forem source code will give you a clue what the page is doing. I would check if the same works with CodePen or something similar that is inside iframe.
I found why this is happening, it's because of
scrollIntoView
. It's scrolls the outer page as well to bring thelastTodo
into view.Swapping it with
listRef.current.scrollTop = listRef.current.scrollHeight
solves this. So, I guess its better to use the latter.nice!
just keep posting with Sandbox example for readers' playground. keep it up! :D