<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>狀態機 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E7%8B%80%E6%85%8B%E6%A9%9F/</link><description>Recent content in 狀態機 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E7%8B%80%E6%85%8B%E6%A9%9F/index.xml" rel="self" type="application/rss+xml"/><item><title>Loading / Empty / End 三狀態的區分</title><link>https://tarrragon.github.io/blog/report/loading-empty-end-state-distinction/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/loading-empty-end-state-distinction/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>「Loading」「Empty」「End」是三個語意不同的狀態、UX 必須區分。&lt;/strong> 三者在資料層代表完全不同的事實、使用者根據哪一個決定下一步動作；共用畫面 = 使用者沒辦法決定。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>資料層事實&lt;/th>
 &lt;th>使用者該採取的下一步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>還在抓、結果未知&lt;/td>
 &lt;td>等&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>抓完了、確認無命中&lt;/td>
 &lt;td>改 query / 改 filter&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>抓完了、有結果但無更多&lt;/td>
 &lt;td>看當前結果、不要再 load more&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>混為一談 = 使用者該等的時候改 query、該改 query 的時候等、該停的時候繼續點 load more。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼三狀態容易被混為一談">為什麼三狀態容易被混為一談&lt;/h2>
&lt;h3 id="視覺上類似">視覺上類似&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>常見視覺&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>空白 + spinner&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>空白 + 「無結果」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>結果 + 灰掉的按鈕&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Loading 跟 Empty 都是「空白為底」、容易共用畫面。實作時如果只寫 &lt;code>{{ if results }}...{{ else }}&amp;lt;empty /&amp;gt;{{ end }}&lt;/code>、Loading 跟 Empty 會被當成同一件事。&lt;/p>
&lt;h3 id="資料層常常沒提供區分訊號">資料層常常沒提供區分訊號&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetch&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">showEmpty&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>r.length === 0&lt;/code> 只區分有 / 無、不區分「為什麼無」。要區分「還沒抓」vs「抓完無命中」、需要顯式追蹤 fetch 的狀態（pending / done / error），不是看 result。&lt;/p>
&lt;p>End 狀態類似：&lt;code>results.length &amp;gt; 0 &amp;amp;&amp;amp; !hasMore&lt;/code> 才是 End、跟「還可以 load more 的當前結果」不同。&lt;/p>
&lt;hr>
&lt;h2 id="三狀態的可區分訊號">三狀態的可區分訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>必要訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>&lt;code>fetchState === 'pending'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>&lt;code>fetchState === 'done' &amp;amp;&amp;amp; results.length === 0&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>&lt;code>fetchState === 'done' &amp;amp;&amp;amp; results.length &amp;gt; 0 &amp;amp;&amp;amp; !hasMore&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>實作上至少需要：&lt;/p>
&lt;ul>
&lt;li>一個 fetch state machine（不能只看 &lt;code>results&lt;/code>）&lt;/li>
&lt;li>一個「還有沒有下一批」的訊號（&lt;code>hasMore&lt;/code> / cursor / total count）&lt;/li>
&lt;li>UI 對三種組合各畫一個樣子&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="多面向三狀態的延伸">多面向：三狀態的延伸&lt;/h2>
&lt;h3 id="面向-1filter-加進來狀態空間擴張">面向 1：Filter 加進來、狀態空間擴張&lt;/h3>
&lt;p>當 view 層有 filter、三狀態擴張為五狀態（Loading / Empty-raw / Empty-filter / Partial / End）。「Empty-filter」跟「Partial」是 &lt;a href="../view-layer-filter-vs-source-layer/">#55 層錯位&lt;/a> 的 UX 表現 — 共用同個 empty 畫面 = 使用者無法判斷「再 load more 會不會有」。&lt;/p>
&lt;p>具體 UX 模板（三數字、五狀態各別 UI）見 &lt;a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX&lt;/a>。&lt;/p>
&lt;h3 id="面向-2streaming--sse-的無更多很難判斷">面向 2：Streaming / SSE 的「無更多」很難判斷&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="k">of&lt;/span> &lt;span class="nx">eventSource&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 跑完了還是斷線了？
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Streaming 通常沒明確的 End 訊號 — 需要 server 主動送一個 &lt;code>event: end&lt;/code>、或 client 用 timeout / heartbeat 判斷。否則使用者看到一段時間沒新資料、不知道是「沒了」還是「還在等」。&lt;/p>
&lt;h3 id="面向-3錯誤狀態應該獨立不混進三狀態">面向 3：錯誤狀態應該獨立、不混進三狀態&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>跟三狀態的關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>獨立第四個狀態、需要不同 UX&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Timeout&lt;/td>
 &lt;td>通常歸 Error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offline&lt;/td>
 &lt;td>獨立、需要 retry UX&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把 Error 顯示成 Empty = 使用者誤以為「沒結果」、不會 retry。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>「Loading」「Empty」「End」是三個語意不同的狀態、UX 必須區分。</strong> 三者在資料層代表完全不同的事實、使用者根據哪一個決定下一步動作；共用畫面 = 使用者沒辦法決定。</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>資料層事實</th>
          <th>使用者該採取的下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td>還在抓、結果未知</td>
          <td>等</td>
      </tr>
      <tr>
          <td>Empty</td>
          <td>抓完了、確認無命中</td>
          <td>改 query / 改 filter</td>
      </tr>
      <tr>
          <td>End</td>
          <td>抓完了、有結果但無更多</td>
          <td>看當前結果、不要再 load more</td>
      </tr>
  </tbody>
</table>
<p>混為一談 = 使用者該等的時候改 query、該改 query 的時候等、該停的時候繼續點 load more。</p>
<hr>
<h2 id="為什麼三狀態容易被混為一談">為什麼三狀態容易被混為一談</h2>
<h3 id="視覺上類似">視覺上類似</h3>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>常見視覺</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td>空白 + spinner</td>
      </tr>
      <tr>
          <td>Empty</td>
          <td>空白 + 「無結果」</td>
      </tr>
      <tr>
          <td>End</td>
          <td>結果 + 灰掉的按鈕</td>
      </tr>
  </tbody>
</table>
<p>Loading 跟 Empty 都是「空白為底」、容易共用畫面。實作時如果只寫 <code>{{ if results }}...{{ else }}&lt;empty /&gt;{{ end }}</code>、Loading 跟 Empty 會被當成同一件事。</p>
<h3 id="資料層常常沒提供區分訊號">資料層常常沒提供區分訊號</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="nx">showEmpty</span><span class="p">();</span></span></span></code></pre></div><p><code>r.length === 0</code> 只區分有 / 無、不區分「為什麼無」。要區分「還沒抓」vs「抓完無命中」、需要顯式追蹤 fetch 的狀態（pending / done / error），不是看 result。</p>
<p>End 狀態類似：<code>results.length &gt; 0 &amp;&amp; !hasMore</code> 才是 End、跟「還可以 load more 的當前結果」不同。</p>
<hr>
<h2 id="三狀態的可區分訊號">三狀態的可區分訊號</h2>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>必要訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td><code>fetchState === 'pending'</code></td>
      </tr>
      <tr>
          <td>Empty</td>
          <td><code>fetchState === 'done' &amp;&amp; results.length === 0</code></td>
      </tr>
      <tr>
          <td>End</td>
          <td><code>fetchState === 'done' &amp;&amp; results.length &gt; 0 &amp;&amp; !hasMore</code></td>
      </tr>
  </tbody>
</table>
<p>實作上至少需要：</p>
<ul>
<li>一個 fetch state machine（不能只看 <code>results</code>）</li>
<li>一個「還有沒有下一批」的訊號（<code>hasMore</code> / cursor / total count）</li>
<li>UI 對三種組合各畫一個樣子</li>
</ul>
<hr>
<h2 id="多面向三狀態的延伸">多面向：三狀態的延伸</h2>
<h3 id="面向-1filter-加進來狀態空間擴張">面向 1：Filter 加進來、狀態空間擴張</h3>
<p>當 view 層有 filter、三狀態擴張為五狀態（Loading / Empty-raw / Empty-filter / Partial / End）。「Empty-filter」跟「Partial」是 <a href="../view-layer-filter-vs-source-layer/">#55 層錯位</a> 的 UX 表現 — 共用同個 empty 畫面 = 使用者無法判斷「再 load more 會不會有」。</p>
<p>具體 UX 模板（三數字、五狀態各別 UI）見 <a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX</a>。</p>
<h3 id="面向-2streaming--sse-的無更多很難判斷">面向 2：Streaming / SSE 的「無更多」很難判斷</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="kr">await</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="nx">eventSource</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 跑完了還是斷線了？
</span></span></span></code></pre></div><p>Streaming 通常沒明確的 End 訊號 — 需要 server 主動送一個 <code>event: end</code>、或 client 用 timeout / heartbeat 判斷。否則使用者看到一段時間沒新資料、不知道是「沒了」還是「還在等」。</p>
<h3 id="面向-3錯誤狀態應該獨立不混進三狀態">面向 3：錯誤狀態應該獨立、不混進三狀態</h3>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>跟三狀態的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Error</td>
          <td>獨立第四個狀態、需要不同 UX</td>
      </tr>
      <tr>
          <td>Timeout</td>
          <td>通常歸 Error</td>
      </tr>
      <tr>
          <td>Offline</td>
          <td>獨立、需要 retry UX</td>
      </tr>
  </tbody>
</table>
<p>把 Error 顯示成 Empty = 使用者誤以為「沒結果」、不會 retry。</p>
<hr>
<h2 id="設計取捨ux-該怎麼呈現三狀態">設計取捨：UX 該怎麼呈現三狀態</h2>
<h3 id="a每個狀態獨立的-ui-元件">A：每個狀態獨立的 UI 元件</h3>
<ul>
<li><strong>機制</strong>：Loading 顯示 spinner、Empty 顯示 illustration + 「改 query」CTA、End 顯示「all results loaded」、Error 顯示 retry button</li>
<li><strong>選 A 的理由</strong>：四個狀態語意完全清楚、使用者下一步明確</li>
<li><strong>代價</strong>：UI 元件多、設計成本高</li>
</ul>
<h3 id="b用文字--細節區分共用-layout">B：用文字 + 細節區分、共用 layout</h3>
<ul>
<li><strong>機制</strong>：同一個 container、不同狀態填不同文字（&ldquo;Loading&hellip;&rdquo; / &ldquo;No results for X&rdquo; / &ldquo;Showing all 23 results&rdquo;）</li>
<li><strong>跟 A 的取捨</strong>：B 設計簡單、但區分性弱（使用者要讀文字才知道狀態）</li>
<li><strong>B 才合理的情境</strong>：簡單 UI、使用者願意讀文字</li>
</ul>
<h3 id="c只用視覺-cuespinner--空白">C：只用視覺 cue（spinner / 空白）</h3>
<ul>
<li><strong>機制</strong>：spinner = loading、空白 = 沒結果、結果列表 = 有</li>
<li><strong>跟 A 的取捨</strong>：C 沒區分 Empty vs End vs Partial</li>
<li><strong>C 才合理的情境</strong>：source 沒分批、結果一次給完</li>
</ul>
<h3 id="d完全不區分三狀態反模式">D：完全不區分三狀態（反模式）</h3>
<ul>
<li><strong>為什麼是反模式</strong>：把「使用者下一步該做什麼」這個決策丟給使用者自己猜、違反「UI 必須回答下一步問題」原則</li>
<li><strong>看起來吸引人的原因</strong>：UI 寫起來最簡單、不用畫 Loading / Empty / End 三版、<code>{{ if results }}...{{ else }}empty{{ end }}</code> 一行解決</li>
<li><strong>實際發生的代價</strong>：使用者操作不知所措、support tickets 增加、使用者信任損失（「這網站到底有沒有在 load」）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UI 寫 <code>{{ if results }}...{{ else }}&lt;empty /&gt;{{ end }}</code></td>
          <td>補：Loading / Error / End / Partial 各一個分支</td>
      </tr>
      <tr>
          <td>沒有 <code>fetchState</code> / <code>hasMore</code> 變數</td>
          <td>加 — 否則無法區分三狀態</td>
      </tr>
      <tr>
          <td>Empty UI 上沒有「下一步該做什麼」的 CTA</td>
          <td>補：「改 query」「reset filter」「retry」等行動建議</td>
      </tr>
      <tr>
          <td>Loading 共用 Empty 畫面（都是空白）</td>
          <td>加區分（spinner vs 文字）</td>
      </tr>
      <tr>
          <td>Streaming / async iterator 沒明確 End 訊號</td>
          <td>加：server-side 送 end event、或 client timeout</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：三狀態（Loading / Empty / End）是不同事實、不同 UX。共用畫面 = 把「使用者該做什麼」這個決策丟給使用者自己猜。實作要從資料層追蹤 state、不能只看 <code>results</code>。</p>
<p>跟 <a href="../aria-live-for-dynamic-content/">#38 動態內容變動的 aria-live region 設計</a> 同源：兩者都是「狀態變動需要告知使用者」、本卡告訴的是 sighted 使用者（視覺區分）、#38 告訴 screen reader（aria-live 廣播）。</p>
]]></content:encoded></item></channel></rss>