0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tech Radar 試してみた #4 — Server-driven UI を 12 widget の vanilla JS インタプリタで動かす

0
Posted at

Thoughtworks Technology Radar Vol 34 (April 2026) の Trial 枠に Server-driven UI が "戻ってきている"。サーバが JSON で UI を返し、クライアントが widget としてレンダリングするパターン。Airbnb / Lyft / Uber のような大手モバイルチームが app store の review cycle をスキップするために何年も使っているが、Radar が改めて Trial に置いたのは proprietary "god-protocol" でない、ライト級のパターン が成熟してきたから。500 行 vanilla JS で JSON spec を貼ると 12 種類の widget が render される live editor を書いて感触を見てみた。

🌐 Demo: https://sen.ltd/portfolio/server-driven-ui/
📦 GitHub: https://github.com/sen-ltd/server-driven-ui

Screenshot

なぜモバイルチームはこれを使うのか

3 つの動機が積み重なる:

  1. App Store / Play Store の review cycle をスキップ。UI 変更を JSON レスポンスのデプロイで出せる。新しい binary は要らない。
  2. iOS / Android / Web で 1 つの設計。各プラットフォームクライアントは同じ JSON spec を native widget に翻訳する。画面デザインが 1 箇所 にある。
  3. A/B テストがコード変更ゼロ。サーバが cohort A に variant A の spec を、cohort B に variant B の spec を返す。アプリは何も知らない。

代償は明確: サーバが emit する全 widget type はクライアントに既に存在していないといけない。レイアウトとコピーは自由に変えられるが、新 widget は新 release が必要。Radar が "ライト級なものから始めろ" と書いているのはここの規律の話。spec は versioned contract、bumping は public API の bump と同じ shape の仕事。

spec の形

最小限の widget セットで実用的なレイアウトが組める形にする:

export const WIDGETS = {
  vstack:    { props: { spacing: "number?", align: "string?" }, hasChildren: true },
  hstack:    { props: { spacing: "number?", align: "string?" }, hasChildren: true },
  text:      { props: { content: "string", style: "string?" }, hasChildren: false },
  heading:   { props: { content: "string", level: "number?" }, hasChildren: false },
  button:    { props: { label: "string", variant: "string?", action: "string?" }, hasChildren: false },
  card:      { props: { title: "string?", variant: "string?" }, hasChildren: true },
  image:     { props: { url: "string", alt: "string?", aspect: "string?" }, hasChildren: false },
  badge:     { props: { label: "string", tone: "string?" }, hasChildren: false },
  divider:   { props: {}, hasChildren: false },
  spacer:    { props: { size: "number?" }, hasChildren: false },
  list:      { props: { spacing: "number?" }, hasChildren: true },
  link:      { props: { label: "string", url: "string" }, hasChildren: false },
};

? は optional、無印は required。hasChildren がそのまま leaf vs container の判別。catalog が single source of truth で、validator も renderer も in-app reference panel もここを読む。

実例: feed list

{
  "type": "vstack",
  "spacing": 12,
  "children": [
    { "type": "heading", "content": "Recent activity", "level": 2 },
    {
      "type": "list",
      "spacing": 10,
      "children": [
        {
          "type": "card",
          "children": [
            {
              "type": "hstack",
              "spacing": 10,
              "align": "center",
              "children": [
                { "type": "image", "url": "/avatars/a.png", "alt": "Alice", "aspect": "1/1" },
                {
                  "type": "vstack",
                  "spacing": 2,
                  "children": [
                    { "type": "text", "content": "Alice merged PR #482", "style": "bold" },
                    { "type": "text", "content": "2 minutes ago", "style": "muted" }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

これで「avatar + 2 行のテキスト × 3 カード」の標準的なフィード行が出来上がる。サーバが avatar URL を切り替えても、配置を align: "start" に変えても、再 deploy 不要。

バリデータの中身

spec を render する前にバリデートする。エラーは path 付きで:

export function validateSpec(spec, path = "$") {
  const issues = [];
  if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
    issues.push({ path, message: "node must be an object" });
    return issues;
  }
  const type = spec.type;
  if (typeof type !== "string") {
    issues.push({ path, message: "node missing 'type' string" });
    return issues;
  }
  const def = WIDGETS[type];
  if (!def) {
    issues.push({ path, message: `unknown widget type: ${type}` });
    return issues;
  }
  for (const [prop, kind] of Object.entries(def.props)) {
    const required = !kind.endsWith("?");
    const baseKind = kind.replace(/\?$/, "");
    const value = spec[prop];
    if (value === undefined) {
      if (required) issues.push({ path: `${path}.${prop}`, message: `required prop missing` });
      continue;
    }
    if (!typeOk(baseKind, value)) {
      issues.push({ path: `${path}.${prop}`, message: `expected ${baseKind}, got ${actualKind(value)}` });
    }
  }
  if (def.hasChildren && Array.isArray(spec.children)) {
    spec.children.forEach((child, i) => {
      issues.push(...validateSpec(child, `${path}.children[${i}]`));
    });
  } else if (!def.hasChildren && spec.children !== undefined) {
    issues.push({ path: `${path}.children`, message: `widget '${type}' does not accept children` });
  }
  return issues;
}

エラーは $.children[1].content — required prop missing のような JSONPath 風で返す。サーバ側に直接フィードバックできる形式 にしておくのが SDUI の運用では効く — クライアントが受け取った spec が壊れていたら、その path をログに送って次の deploy で修正、というループが成立する。

レンダラの分岐

renderer は dispatcher 関数を type → fn の Map で持つだけ:

export function render(spec) {
  const fn = RENDERERS[spec.type];
  if (!fn) {
    const fallback = document.createElement("div");
    fallback.className = "sdui-unknown";
    fallback.textContent = `[unknown: ${spec.type}]`;
    return fallback;
  }
  return fn(spec);
}

const RENDERERS = {
  vstack(spec) {
    const el = document.createElement("div");
    el.className = "sdui-vstack";
    if (spec.spacing != null) el.style.gap = `${spec.spacing}px`;
    if (spec.align) el.style.alignItems = mapAlign(spec.align);
    for (const child of spec.children || []) el.appendChild(render(child));
    return el;
  },
  // ... 11 more
};

unknown widget は静かに失敗しない[unknown: <type>] を表示する。SDUI で最も怖いのは「クライアントが知らない widget type をサーバが返す」状況 (= サーバ側が先走って deploy されたケース)。クライアントが silent skip すると 画面の一部が消える が、明示的に "[unknown]" を出すと ユーザにも開発者にも見える形で残る。Logging に流せばロールバックの判断もしやすい。

これが本ツールの sdui-unknown クラス。CSS で赤い破線枠が付くようにしてある。

"god-protocol" の罠と avoid

Radar の本文がわざわざ警告しているのが "god-protocol" — 何でも表現できる proprietary な仕様 に発展してしまうケース。Airbnb の SDUI が初期に陥った状況で、widget が数百種類になって誰もメンテできなくなる。

回避策は widget セットを意図的に小さく保つ:

  • 出来ることを増やすより、今ある widget の組み合わせで何を表現できるか を増やす
  • 「flexible card」「universal container」みたいな god-widget を作らない
  • 新 widget の追加は API バージョン bump 同等 として扱う

このツールは 12 widget。これだけで promo / feed / pricing / empty state は全部組める。実運用でも 20-30 個に収まるのが健全な範囲。

21 件のテスト

カテゴリ:

  • happy paths 3 件 — text / vstack / button
  • required prop errors 3 件 — text / button / link
  • type errors 3 件 — wrong prop type / unknown widget
  • children handling 3 件 — leaf に children / array でない / nested error path
  • top-level shape 3 件 — null / array / missing type
  • metrics 4 件 — countNodes / maxDepth
  • catalog integrity 2 件 — props 必須 / widget 数

特に "nested error path" テスト は実運用で効く: validator が $.children[1].content のような正確な path を返すことを test で固定しているので、サーバチームがエラーを見て一発で修正箇所を特定できる。

アーキテクチャ

spec.js     ← validator + metrics + WIDGETS catalog (DOM 非依存、21 tests)
render.js   ← type → renderer dispatcher
presets.js  ← 4 種の sample spec
app.js      ← UI グルー

spec.js は DOM を一切触らない。render.jsdocument.createElement だけ呼ぶ。app.js は両者を繋ぐだけ。新 widget 追加は catalog にエントリ 1 行 + render 関数 1 つ + テスト 1-2 件 で完結する。

まとめ

  • Server-driven UI は app store review をスキップ + 1 つの設計を多 platform に push + コード変更ゼロでの A/B test の 3 つで採用される
  • god-protocol 化が最大の罠。widget セットを小さく保つ規律が必要
  • catalog を single source of truth に置くと validator / renderer / reference panel が自動で一貫する
  • バリデータの error は path 付き にしてサーバへのフィードバックループを作る
  • unknown widget は silent fail させない[unknown: <type>] を出して可視化
  • 12 widget × vstack/hstack の組み合わせで promo / feed / pricing / empty state は全部組める

リポジトリ: https://github.com/sen-ltd/server-driven-ui

このツールは弊社の OSS ポートフォリオ #250 として作成しました。Tech Radar 試してみた シリーズ第 4 弾。前回は #249 Schema → LLM Prompt#248 Markdown → Typst#247 TOON コンバータ。次回は Mutation testing 予定。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?