Optimistic UI

서버 왕복이 끝나기 전에 사용자에게 반응을 돌려주는 장치들이다. 로딩 클래스, wire-disabled-with, 그리고 즉시 실행되는 JS 명령 셋으로 나뉜다.

로딩 클래스#

이벤트가 발생하면 그것을 일으킨 엘리먼트에 로딩 클래스가 자동으로 붙는다.

<button {% on "click" "save" %} class="btn">저장</button>

서버 왕복이 진행되는 동안 이 버튼이 갖는 클래스는 다음과 같다.

클래스 언제
wireview-loading 항상
wireview-click-loading click 이벤트
wireview-submit-loading 폼 제출
wireview-change-loading change 이벤트

폼 제출이면 폼과 함께, 폼 안에서 wire-disabled-with를 단 submit 버튼에도 붙는다. 서버 핸들러를 부르는 바인딩뿐 아니라 끝에 push가 있는 JS() 체인도 같다.

클래스와 wire-disabled-with는 그 이벤트의 응답이 오면 지워진다. 응답은 이벤트와 같은 ref를 단 렌더나 오류다. 그 사이 같은 컴포넌트에 다른 렌더가 와도(먼저 보내진 join의 답, 처리 중이던 브로드캐스트) 로딩 표시는 남는다. 그 렌더의 morph가 서버 HTML로 요소를 다시 쓰면 다시 붙인다. 연결이 끊기거나 그 컴포넌트가 오류로 다시 join하면 응답이 오지 않으므로 바로 지운다. ref를 되돌려 주지 않는 옛 서버에서는 그 컴포넌트의 다음 렌더(join의 답은 빼고)가 응답을 대신한다(#118).

로딩 상태 꾸미기#

/* 로딩 중 흐리게 */
.wireview-loading {
  opacity: 0.7;
  cursor: wait;
}

/* 스피너 아이콘 */
.wireview-click-loading::after {
  content: "";
  display: inline-block;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.75s linear infinite;
  margin-left: 0.5em;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

wire-disabled-with#

wire-disabled-with는 세 가지를 한다.

  1. 클릭 즉시 엘리먼트를 비활성화한다
  2. 버튼 텍스트를 로딩 문구로 바꾼다
  3. 작업이 끝나면 원래 상태로 되돌린다

기본 사용#

<button
  {% on "click" "save" %}
  wire-disabled-with="저장 중..."
>
  저장
</button>

클릭하면 버튼이 disabled가 되고 텍스트가 "저장 중..."으로 바뀌었다가, 서버 응답이 오면 "저장"으로 돌아오며 다시 활성화된다.

폼 제출#

바인딩은 폼에 있어도 wire-disabled-with는 submit 버튼에 둔다. 제출하는 동안 그 버튼이 비활성화되고 문구가 바뀐다.

<form {% on "submit" "create_post" %}>
  <input type="text" name="title" placeholder="글 제목">
  <textarea name="content"></textarea>

  <button
    type="submit"
    wire-disabled-with="글 만드는 중..."
  >
    글 쓰기
  </button>
</form>

아이콘과 함께 (Tailwind/Heroicons)#

<button
  {% on "click" "delete_item" id=item.id %}
  wire-disabled-with="삭제 중..."
  class="flex items-center gap-2"
>
  <svg class="w-4 h-4"><!-- trash icon --></svg>
  삭제
</button>

주의. wire-disabled-with는 텍스트 콘텐츠만 교체한다. 로딩 중에도 아이콘을 유지해야 하면 이 속성 대신 CSS 기반 로딩 표시를 쓴다.

로딩 클래스와 함께#

둘은 겹쳐 쓸 수 있다.

<button
  {% on "click" "process" %}
  wire-disabled-with="처리 중..."
  class="btn"
>
  데이터 처리
</button>
/* 버튼을 흐리게 하고 커서를 바꾼다 */
.btn.wireview-loading {
  opacity: 0.6;
  cursor: wait;
}

즉시 반응은 JS 명령으로#

서버를 기다리지 않고 UI를 바로 바꾸려면 JS 명령을 쓴다.

from wireview import JS, Component


class Menu(Component):
    @property
    def toggle_menu_js(self) -> JS:
        # 템플릿은 인자를 받는 호출을 쓸 수 없으므로 JS 체인은 컴포넌트가 만든다
        return JS().toggle_class("#menu", "hidden").push("toggle_menu")
<button {% on "click" this.toggle_menu_js %} wire-disabled-with="여는 중...">
  메뉴 토글
</button>

JS() 명령은 그 자리에서 실행되고, 끝의 push가 서버 핸들러를 부른다. 서버 왕복에 대한 피드백은 wire-disabled-with가 맡는다. 같은 요소에 {% on "click" … %}을 두 번 쓰면 속성 이름이 같아 브라우저가 두 번째를 버리므로, 클라이언트 동작과 서버 호출은 이렇게 한 체인으로 묶는다.

권장 사항#

  1. 의미 있는 문구를 쓴다. "로딩 중..."보다 "저장 중..."이 낫다
  2. 짧게 쓴다. 긴 문구는 레이아웃을 흔든다
  3. 동작을 그대로 반영한다. 버튼이 하는 일과 로딩 문구를 맞춘다
  4. 시각적 신호를 더한다. opacity·cursor를 바꾸는 CSS와 함께 쓴다

로딩 문구 예#

버튼 로딩 문구
저장 저장 중...
삭제 삭제 중...
제출 제출 중...
계정 만들기 계정 만드는 중...
메시지 보내기 보내는 중...
장바구니에 담기 담는 중...

Phoenix LiveView 대응#

Phoenix LiveView django-wireview
phx-disable-with wire-disabled-with
phx-click-loading wireview-click-loading
phx-submit-loading wireview-submit-loading
phx-change-loading wireview-change-loading

응답 시간 측정#

내장 프로파일링으로 실제 왕복 시간을 잴 수 있다.

// 브라우저 콘솔에서 켠다
wireview.debug.enableProfiling();

// 페이지를 조작한 뒤...

// 리포트를 본다
wireview.debug.profilingReport();

// 끈다
wireview.debug.disableProfiling();

리포트에 담기는 것:

  • patch 시간 — DOM morph를 적용하는 데 걸린 시간
  • 왕복 시간 — 이벤트를 보낸 시점부터 응답을 받은 시점까지
  • 통계 — 항목별 최소·최대·평균·중앙값

관련#