Data States
Every panel that loads async data has three possible states: loading, error, and empty. All three must be handled. Missing any one creates a broken user experience.
The Three States
State When What to show
─────────────────────────────────────────────────────────────────────
Loading Fetch is in-flight Centered text, spinner, or skeleton
Error Fetch threw or rejected Dismissable error box with message
Empty Fetch succeeded, 0 results Centered text with helpful hint
These are mutually exclusive in order: if loading, show loading; else if error, show error; else if empty, show empty; else show content.
Standard Pattern
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setItems(await invoke<Item[]>("get_items"));
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
// In render:
<div className="panel-body">
{loading && <div className="panel-loading">Loading…</div>}
{error && (
<div className="panel-error">
{error}
<button onClick={() => setError(null)}>✕</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="panel-empty">No items found. Click Refresh to reload.</div>
)}
{!loading && items.map(item => (
<div key={item.id} className="panel-card" style={{ marginBottom: 8 }}>
{item.name}
</div>
))}
</div>
Loading State
<div className="panel-loading">Loading…</div>
.panel-loading {
text-align: center;
padding: 32px 20px;
color: var(--text-secondary);
font-size: var(--font-size-base);
}
Loading with context
<div className="panel-loading">Scanning workspace…</div>
<div className="panel-loading">Analyzing codebase…</div>
<div className="panel-loading">Loading predictions…</div>
Always be specific about what’s loading. “Loading…” by itself is the minimum; prefer "Scanning workspace…" etc.
Initial load vs refresh
// Initial — panel body shows only loading state
{loading && <div className="panel-loading">Loading…</div>}
// Refresh — keep existing content visible, show loading in button
<button className="panel-btn panel-btn-primary panel-btn-sm" disabled={loading}>
{loading ? "Refreshing…" : "↻ Refresh"}
</button>
// Don't replace content with loading state on refresh — use button state only
Error State
<div className="panel-error">
{error}
<button onClick={() => setError(null)}>✕</button>
</div>
.panel-error {
background: var(--error-bg);
border: 1px solid var(--error-color);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-danger);
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel-error button {
background: none;
border: none;
color: var(--text-danger);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
}
Error with retry
<div className="panel-error">
<span>{error}</span>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button
className="panel-btn panel-btn-secondary panel-btn-xs"
style={{ color: "var(--text-danger)" }}
onClick={load}
>
Retry
</button>
<button onClick={() => setError(null)}>✕</button>
</div>
</div>
Error placement
Always place the error box inside panel-body, above the content (or where content would be). Never show error inside a card or below content.
<div className="panel-body">
{error && (
<div className="panel-error" style={{ marginBottom: 10 }}>
{error}
<button onClick={() => setError(null)}>✕</button>
</div>
)}
{/* content below */}
</div>
Empty State
<div className="panel-empty">No items found. Click Refresh to reload.</div>
.panel-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 13px;
}
Empty state copy guidelines
Always answer two questions:
- What’s missing? (“No predictions yet”)
- What should the user do? (“Edit some files to generate predictions”)
// GOOD — explains what and why
<div className="panel-empty">No predictions yet. Edit some files to generate predictions.</div>
<div className="panel-empty">Click Scan to analyze your codebase health.</div>
<div className="panel-empty">No pending AST edits. Use the AI to suggest refactors.</div>
// BAD — unhelpful
<div className="panel-empty">No data.</div>
<div className="panel-empty">Nothing here.</div>
Empty with action button
<div className="panel-empty">
<div style={{ marginBottom: 12 }}>No sessions found.</div>
<button className="panel-btn panel-btn-primary" onClick={handleCreate}>
Start First Session
</button>
</div>
Combined State Logic
The render order for state priority:
{/* Priority 1: Loading — replace content */}
{loading && <div className="panel-loading">Loading…</div>}
{/* Priority 2: Error — show above content */}
{error && (
<div className="panel-error" style={{ marginBottom: error && !loading ? 10 : 0 }}>
{error}
<button onClick={() => setError(null)}>✕</button>
</div>
)}
{/* Priority 3: Empty — only when data loaded and empty */}
{!loading && !error && items.length === 0 && (
<div className="panel-empty">No items yet.</div>
)}
{/* Priority 4: Content — only when loaded */}
{!loading && items.map(item => (
<div key={item.id} className="panel-card" style={{ marginBottom: 8 }}>
{item.name}
</div>
))}
Tab-Specific Empty States
When a panel has tabs, each tab needs its own empty state:
{tab === "predictions" && !loading && predictions.length === 0 && !error && (
<div className="panel-empty">No predictions yet. Edit some files to generate predictions.</div>
)}
{tab === "patterns" && !loading && patterns.length === 0 && !error && (
<div className="panel-empty">No patterns detected yet. Patterns emerge as you edit files.</div>
)}
Optimistic Updates
For accept/reject/delete operations, update the UI immediately and revert on failure:
const handleAction = async (id: string) => {
// 1. Optimistic update
setItems(prev => prev.map(item => item.id === id ? { ...item, status: "accepted" } : item));
try {
await invoke("apply_action", { id });
// 2. Success — optionally refresh
await refresh();
} catch (e) {
// 3. Revert on failure
setItems(prev => prev.map(item => item.id === id ? { ...item, status: "pending" } : item));
setError(String(e));
}
};
Rules
Always
- Handle all three states: loading, error, empty
- Dismiss errors with a
✕button - Use
finally { setLoading(false) }— never set loading in catch/try alone - Reset
errorto null at start of every fetch (setError(null)) - Write helpful empty state copy (what + why + action)
Never
- Leave loading state stuck (always use
finally) - Show empty state while loading is true
- Show content while error is present (show error above, then content below if applicable)
- Use
"..."as loading label in empty state (use “Loading…” with ellipsis…) - Show raw error objects (
String(e)— always convert)