Loading States
Gale provides two directives for handling loading states: x-loading for declarative
show/hide behavior, and x-indicator for programmatic state management. Both
automatically track requests within their scope.
The x-loading Directive
The x-loading directive shows elements during loading and hides them when idle.
It listens for gale:started and gale:finished events automatically.
<div x-data="{ query: '' }">
<input x-model="query" placeholder="Search...">
<button @click="$get('/search')">Search</button>
<!-- Shows during loading, hidden when idle -->
<span x-loading>Searching...</span>
</div>
<div x-data="{ query: '' }">
<input x-model="query" placeholder="Search...">
<button @click="$get('/search')">Search</button>
<!-- Shows during loading, hidden when idle -->
<span x-loading>Searching...</span>
</div>
Inverse Loading (.remove)
Use the .remove modifier to hide content during loading and show it when idle.
This is useful for swapping button text or hiding content.
<button @click="$postx('/save')">
<!-- Normal text, hidden during loading -->
<span x-loading.remove>Save Changes</span>
<!-- Loading text, shown during loading -->
<span x-loading>Saving...</span>
</button>
<button @click="$postx('/save')">
<!-- Normal text, hidden during loading -->
<span x-loading.remove>Save Changes</span>
<!-- Loading text, shown during loading -->
<span x-loading>Saving...</span>
</button>
Adding Classes (.class)
The .class modifier adds CSS classes during loading and removes them when idle.
This is perfect for visual feedback like reducing opacity or changing cursor.
<!-- Add opacity and cursor classes during loading -->
<button
@click="$postx('/submit')"
x-loading.class="opacity-50 cursor-wait"
class="bg-blue-500 text-white px-4 py-2 rounded">
Submit
</button>
<!-- Add loading state to entire form -->
<form x-loading.class="pointer-events-none opacity-60">
<!-- Form fields -->
</form>
<!-- Add opacity and cursor classes during loading -->
<button
@click="$postx('/submit')"
x-loading.class="opacity-50 cursor-wait"
class="bg-blue-500 text-white px-4 py-2 rounded">
Submit
</button>
<!-- Add loading state to entire form -->
<form x-loading.class="pointer-events-none opacity-60">
<!-- Form fields -->
</form>
Setting Attributes (.attr)
The .attr modifier sets an attribute during loading and removes it when idle.
Commonly used to disable buttons and prevent double submissions.
<!-- Disable button during loading -->
<button @click="$postx('/save')" x-loading.attr="disabled">
Save
</button>
<!-- Set aria-busy for accessibility -->
<div x-loading.attr="aria-busy">
<!-- Content -->
</div>
<!-- Make form readonly during loading -->
<input x-loading.attr="readonly" type="text">
<!-- Disable button during loading -->
<button @click="$postx('/save')" x-loading.attr="disabled">
Save
</button>
<!-- Set aria-busy for accessibility -->
<div x-loading.attr="aria-busy">
<!-- Content -->
</div>
<!-- Make form readonly during loading -->
<input x-loading.attr="readonly" type="text">
Delay Loading State (.delay)
The .delay modifier prevents loading indicators from flashing on fast requests.
The loading state only shows if the request takes longer than the specified delay.
<!-- Only show if request takes more than 150ms -->
<span x-loading.delay.150ms>Loading...</span>
<!-- 500ms delay for slower operations -->
<div x-loading.delay.500ms class="spinner">
<!-- Spinner animation -->
</div>
<!-- Combine with other modifiers -->
<button x-loading.delay.200ms.class="opacity-50">
Submit
</button>
<!-- Only show if request takes more than 150ms -->
<span x-loading.delay.150ms>Loading...</span>
<!-- 500ms delay for slower operations -->
<div x-loading.delay.500ms class="spinner">
<!-- Spinner animation -->
</div>
<!-- Combine with other modifiers -->
<button x-loading.delay.200ms.class="opacity-50">
Submit
</button>
The x-indicator Directive
The x-indicator directive creates a reactive state variable that tracks loading status.
This gives you full programmatic control over loading behavior.
<div
x-data="{ loading: false, results: [] }"
x-indicator="loading">
<button @click="$get('/search')" :disabled="loading">
Search
</button>
<!-- Use loading state in templates -->
<span x-show="loading">Searching...</span>
<!-- Use in conditional classes -->
<div :class="loading && 'animate-pulse'">
<!-- Content -->
</div>
</div>
<div
x-data="{ loading: false, results: [] }"
x-indicator="loading">
<button @click="$get('/search')" :disabled="loading">
Search
</button>
<!-- Use loading state in templates -->
<span x-show="loading">Searching...</span>
<!-- Use in conditional classes -->
<div :class="loading && 'animate-pulse'">
<!-- Content -->
</div>
</div>
Default State Name
When no expression is provided, x-indicator defaults to using loading
as the state variable name.
<!-- These are equivalent -->
<div x-data="{ loading: false }" x-indicator>
<span x-show="loading">Loading...</span>
</div>
<div x-data="{ loading: false }" x-indicator="loading">
<span x-show="loading">Loading...</span>
</div>
<!-- These are equivalent -->
<div x-data="{ loading: false }" x-indicator>
<span x-show="loading">Loading...</span>
</div>
<div x-data="{ loading: false }" x-indicator="loading">
<span x-show="loading">Loading...</span>
</div>
Custom State Names
Use custom state names to track different operations independently:
<div x-data="{ saving: false, deleting: false }">
<!-- Track save operation -->
<div x-indicator="saving">
<button @click="$postx('/save')" :disabled="saving">
<span x-text="saving ? 'Saving...' : 'Save'"></span>
</button>
</div>
<!-- Track delete operation separately -->
<div x-indicator="deleting">
<button @click="$deletex('/item')" :disabled="deleting">
<span x-text="deleting ? 'Deleting...' : 'Delete'"></span>
</button>
</div>
<!-- Disable entire form during any operation -->
<fieldset :disabled="saving || deleting">
<!-- Form fields -->
</fieldset>
</div>
<div x-data="{ saving: false, deleting: false }">
<!-- Track save operation -->
<div x-indicator="saving">
<button @click="$postx('/save')" :disabled="saving">
<span x-text="saving ? 'Saving...' : 'Save'"></span>
</button>
</div>
<!-- Track delete operation separately -->
<div x-indicator="deleting">
<button @click="$deletex('/item')" :disabled="deleting">
<span x-text="deleting ? 'Deleting...' : 'Delete'"></span>
</button>
</div>
<!-- Disable entire form during any operation -->
<fieldset :disabled="saving || deleting">
<!-- Form fields -->
</fieldset>
</div>
Loading Scope
Both x-loading and x-indicator track requests from the element
and any of its descendants. They use the Alpine x-data scope to determine
which requests are relevant.
<div x-data="{ items: [] }">
<!-- This loading indicator responds to any request inside this div -->
<span x-loading>Loading...</span>
<button @click="$get('/items')">Load Items</button>
<button @click="$postx('/refresh')">Refresh</button>
</div>
<!-- Separate scope - its loading state is independent -->
<div x-data="{ user: null }">
<span x-loading>Loading user...</span>
<button @click="$get('/user')">Load User</button>
</div>
<div x-data="{ items: [] }">
<!-- This loading indicator responds to any request inside this div -->
<span x-loading>Loading...</span>
<button @click="$get('/items')">Load Items</button>
<button @click="$postx('/refresh')">Refresh</button>
</div>
<!-- Separate scope - its loading state is independent -->
<div x-data="{ user: null }">
<span x-loading>Loading user...</span>
<button @click="$get('/user')">Load User</button>
</div>
Multiple Concurrent Requests
Both directives correctly handle multiple concurrent requests. The loading state stays active until all requests complete.
<div x-data="{ users: [], posts: [], comments: [] }">
<!-- Shows while ANY request is in progress -->
<div x-loading class="text-center py-4">
<div class="spinner"></div>
Loading data...
</div>
<!-- Load multiple resources at once -->
<button @click="
$get('/users');
$get('/posts');
$get('/comments');
">
Load All Data
</button>
</div>
<div x-data="{ users: [], posts: [], comments: [] }">
<!-- Shows while ANY request is in progress -->
<div x-loading class="text-center py-4">
<div class="spinner"></div>
Loading data...
</div>
<!-- Load multiple resources at once -->
<button @click="
$get('/users');
$get('/posts');
$get('/comments');
">
Load All Data
</button>
</div>
x-loading vs x-indicator
| Feature | x-loading | x-indicator |
|---|---|---|
| Approach | Declarative | Programmatic |
| State variable | Not needed | Required in x-data |
| Show/hide | Automatic | Manual (x-show) |
| Class toggle | .class modifier | :class binding |
| Attribute toggle | .attr modifier | :disabled binding |
| Delay support | .delay modifier | Not built-in |
| Use in JS | Not possible | Full access |
| Best for | Simple UI feedback | Complex logic |
Combining Both Directives
You can use both directives together for maximum flexibility:
<div
x-data="{ loading: false, data: null }"
x-indicator="loading">
<!-- Use x-indicator for logic -->
<button
@click="$get('/data')"
:disabled="loading"
:class="loading && 'cursor-wait'">
<!-- Use x-loading for text swapping -->
<span x-loading.remove>Fetch Data</span>
<span x-loading>Loading...</span>
</button>
<!-- Use loading state for conditional rendering -->
<template x-if="!loading && data">
<div x-text="data.message"></div>
</template>
</div>
<div
x-data="{ loading: false, data: null }"
x-indicator="loading">
<!-- Use x-indicator for logic -->
<button
@click="$get('/data')"
:disabled="loading"
:class="loading && 'cursor-wait'">
<!-- Use x-loading for text swapping -->
<span x-loading.remove>Fetch Data</span>
<span x-loading>Loading...</span>
</button>
<!-- Use loading state for conditional rendering -->
<template x-if="!loading && data">
<div x-text="data.message"></div>
</template>
</div>
Complete Example
<div
x-data="{
query: '',
results: [],
loading: false
}"
x-indicator="loading"
class="max-w-lg mx-auto">
<!-- Search form -->
<div class="flex gap-2">
<input
x-model="query"
x-loading.attr="readonly"
type="text"
placeholder="Search..."
class="flex-1 border rounded px-3 py-2">
<button
@click="$get('/search')"
:disabled="loading || !query"
x-loading.class="opacity-50 cursor-wait"
class="bg-blue-500 text-white px-4 py-2 rounded">
<span x-loading.remove>Search</span>
<span x-loading>Searching...</span>
</button>
</div>
<!-- Loading indicator with delay to prevent flicker -->
<div x-loading.delay.200ms class="mt-4 text-center">
<div class="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto"></div>
<p class="mt-2 text-gray-600">Searching for "<span x-text="query"></span>"...</p>
</div>
<!-- Results -->
<ul x-loading.remove class="mt-4 space-y-2">
<template x-for="result in results" :key="result.id">
<li class="p-3 bg-gray-50 rounded" x-text="result.title"></li>
</template>
</ul>
<!-- Empty state -->
<p
x-show="!loading && query && results.length === 0"
class="mt-4 text-gray-500 text-center">
No results found
</p>
</div>
<div
x-data="{
query: '',
results: [],
loading: false
}"
x-indicator="loading"
class="max-w-lg mx-auto">
<!-- Search form -->
<div class="flex gap-2">
<input
x-model="query"
x-loading.attr="readonly"
type="text"
placeholder="Search..."
class="flex-1 border rounded px-3 py-2">
<button
@click="$get('/search')"
:disabled="loading || !query"
x-loading.class="opacity-50 cursor-wait"
class="bg-blue-500 text-white px-4 py-2 rounded">
<span x-loading.remove>Search</span>
<span x-loading>Searching...</span>
</button>
</div>
<!-- Loading indicator with delay to prevent flicker -->
<div x-loading.delay.200ms class="mt-4 text-center">
<div class="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto"></div>
<p class="mt-2 text-gray-600">Searching for "<span x-text="query"></span>"...</p>
</div>
<!-- Results -->
<ul x-loading.remove class="mt-4 space-y-2">
<template x-for="result in results" :key="result.id">
<li class="p-3 bg-gray-50 rounded" x-text="result.title"></li>
</template>
</ul>
<!-- Empty state -->
<p
x-show="!loading && query && results.length === 0"
class="mt-4 text-gray-500 text-center">
No results found
</p>
</div>