HomeAbout Me

Advanced React APIs 8: Focus Management

By Daniel Nguyen
Published in React JS
May 28, 2025
2 min read
Advanced React APIs 8: Focus Management

Focus Management

Helping the user’s focus stay on the right place is a key part of the user experience. This is especially important for users who rely on screen readers or keyboard navigation. But even able users can benefit from a well-thought focus management experience.

Sometimes, the element you want to focus on only becomes available after a state update. For example:

function MyComponent() {
const [show, setShow] = useState(false)
return (
<div>
<button onClick={() => setShow(true)}>Show</button>
{show ? <input /> : null}
</div>
)
}

Presumably after the user clicks “show” they will want to type something in the input there. Good focus management would focus the input after it becomes visible.

It’s important for you to know that in React state updates happen in batches. So state updates do not necessarily take place at the same time you call the state updater function.

As a result of React state update batching, if you try to focus an element right after a state update, it might not work as expected. This is because the element you want to focus on might not be available yet.

function MyComponent() {
const inputRef = useRef<HTMLInputElement>(null)
const [show, setShow] = useState(false)
return (
<div>
<button
onClick={() => {
setShow(true)
inputRef.current?.focus() // This probably won't work
}}
>
Show
</button>
{show ? <input ref={inputRef} /> : null}
</div>
)
}

The solution to this problem is to force React to run the state and DOM updates synchronously so that the element you want to focus on is available when you try to focus it.

You do this by using the flushSync function from the react-dom package.

import { flushSync } from 'react-dom'
function MyComponent() {
const inputRef = useRef<HTMLInputElement>(null)
const [show, setShow] = useState(false)
return (
<div>
<button
onClick={() => {
flushSync(() => {
setShow(true)
})
inputRef.current?.focus()
}}
>
Show
</button>
{show ? <input ref={inputRef} /> : null}
</div>
)
}

What flushSync does is that it forces React to run the state update and DOM update synchronously. This way, the input element will be available when you try to focus it on the line following the flushSync call.

In general you want to avoid this de-optimization, but in some cases (like focus management), it’s the perfect solution.

Learn more in 📜 the flushSync docs.

flushSync

🧝‍♂️ I’ve put together a new component we need. It’s called <EditableText /> and it allows users to edit a piece of text inline. We display it in a button and when the user clicks it, the button turns into a text input. When the user presses enter, blurs, or hits escape, the text input turns back into a button.

Right now, when the user clicks the button, the button goes away and is replaced by the text input, but because their focus was on the button which is now gone, their focus returns to the <body> and the text input is not focused. This is not a good user experience.

👨‍💼 Thanks Kellie. So now what we need is for you to properly manage focus for all of these cases.

  • When the user submits the form (by hitting “enter”)
  • When the user cancels the form (by hitting “escape”)
  • When the user blurs the input (by tabbing or clicking away)

Additionally, when the user clicks the button, we want to select all the text so it’s easy for them to edit.

🧝‍♂️ I’ve added some buttons before and after the input so you have something to test tab focus with. Good luck!

This example was uses code from [trellix](https://github.com/remix-run/example-trellix/blob/3379b3d5e9c0173381031e4f062877e8a3696b2e/app/routes/board.%24id/components.tsx).🚨 Because this deals with focus, you'll need to expand the test and then run it for it to pass.
import { useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import * as ReactDOM from 'react-dom/client'
function EditableText({
id,
initialValue = '',
fieldName,
inputLabel,
buttonLabel,
}: {
id?: string
initialValue?: string
fieldName: string
inputLabel: string
buttonLabel: string
}) {
const [edit, setEdit] = useState(false)
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
return edit ? (
<form
method="post"
onSubmit={(event) => {
event.preventDefault()
flushSync(() => {
setValue(inputRef.current?.value ?? '')
setEdit(false)
})
buttonRef.current?.focus()
}}
>
<input
required
ref={inputRef}
type="text"
id={id}
aria-label={inputLabel}
name={fieldName}
defaultValue={value}
onKeyDown={(event) => {
if (event.key === 'Escape') {
flushSync(() => {
setEdit(false)
})
buttonRef.current?.focus()
}
}}
onBlur={(event) => {
flushSync(() => {
setValue(event.currentTarget.value)
setEdit(false)
})
buttonRef.current?.focus()
}}
/>
</form>
) : (
<button
aria-label={buttonLabel}
ref={buttonRef}
type="button"
onClick={() => {
flushSync(() => {
setEdit(true)
})
inputRef.current?.select()
}}
>
{value || 'Edit'}
</button>
)
}
function App() {
return (
<main>
<button>Focus before</button>
<div className="editable-text">
<EditableText
initialValue="Unnamed"
fieldName="name"
inputLabel="Edit project name"
buttonLabel="Edit project name"
/>
</div>
<button>Focus after</button>
</main>
)
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)
main {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 3rem;
}
.editable-text {
button {
/* remove button styles. Make it look like text */
background: none;
border: none;
padding: 4px 8px;
font-size: 1.5rem;
font-weight: bold;
}
input {
/* make it the same size as the button */
font-size: 1.5rem;
font-weight: bold;
padding: 4px 8px;
border: none;
}
}

Tags

#React

Share

Previous Article
Advanced React APIs 7: Imperative Handles

Table Of Contents

1
Focus Management
2
flushSync

Related Posts

React Testing 8: Testing custom hook
September 09, 2025
1 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media