Lists, transitions, and the DOM
Coordinate DOM updates and focus
Use refs and nextTick when code must act after Alpine has updated the DOM.
When you change state in Alpine, the DOM doesn’t update on the same line. Alpine batches the change and applies it a moment later. Most of the time you never notice. You notice the first time you try to focus an element you just revealed.
Opening the issue editor should put the cursor in the title field. Let’s make that work reliably.
Name the element with x-ref
x-ref gives an element a name inside its component. $refs.name gets it back:
<form>
<input x-ref="title" name="title" x-model="title">
</form>
No id, no querySelector, no chance of grabbing the wrong row’s input. Refs are scoped to the component, so ten editors can each have a title ref.
The focus that goes nowhere
First attempt:
<div x-data="{ editing: false }">
<button @click="editing = true; $refs.title.focus()">Edit</button>
<form x-show="editing">
<input x-ref="title" name="title">
</form>
</div>
Click Edit and the form appears, but the cursor isn’t in it. When focus() ran, the form still had display: none. Browsers refuse to focus hidden elements. Alpine applied the x-show change right after your handler finished, too late.
With x-if it’s worse: $refs.title is undefined because the input doesn’t exist yet, and you get an error.
Wait one tick with $nextTick
$nextTick takes a function and runs it after Alpine has finished updating the DOM:
<button @click="editing = true; $nextTick(() => $refs.title.focus())">Edit</button>
Now the sequence is: set state, Alpine renders, then focus. The cursor lands in the field every time, with x-show or x-if.
Inside an Alpine.data component you write it as this.$nextTick(...). It also returns a promise, so await this.$nextTick() works in an async method.
Don’t guess with timers
The tempting fix is setTimeout(() => $refs.title.focus(), 50). It works on your machine. On a slow phone Alpine takes 80ms and the focus call fires into a hidden element again. Then someone bumps it to 200, and now keyboard users wait a fifth of a second before every edit.
$nextTick doesn’t guess. It runs exactly when the update is done, whether that took one millisecond or fifty.
Return focus when closing
Focus management is a round trip. When the editor closes, focus shouldn’t fall to <body>. Send it back to the button that opened it:
<div x-data="{ editing: false }">
<button x-ref="edit" @click="editing = true; $nextTick(() => $refs.title.focus())">Edit</button>
<form x-show="editing" @keydown.escape="editing = false; $refs.edit.focus()">
<input x-ref="title" name="title">
</form>
</div>
Closing doesn’t need $nextTick. The Edit button was visible the whole time.
Test it without a mouse: Tab to Edit, press Enter, type, press Escape. Then do it fast, ten times in a row. Focus should never be lost, and there should be no timers anywhere in the code.
Lesson completed