30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React の新しい概念「トランジション」で React アプリの応答性を改善する

React v18 には多くの改善や新機能が盛り込まれる予定だが、そのなかでも特に注目を集めると思われるのが、Concurrent Features と呼ばれる一連の機能。
これらの機能を使うことで、コンポーネントのレンダリングについてより柔軟な設定が可能になり、上手く使えばパフォーマンスや UX の向上を実現できる。
この記事では Concurrent Features のひとつであるstartTransitionと、それを使いこなす上で重要な概念である「トランジション」について説明する。

この記事ではコンセプトの説明や具体例の提示のみを行う。詳細を知りたい場合は以下を参照。

一年前の記事であるため古くなっている部分もあるが、根幹は大きく変わっていないと認識している。
なお、上記の記事には「Concurrent Mode」という用語がタイトルに入っているが、これは今後は使われなくなっていくと思われる。
v18 へのアップグレードをよりスムーズに行えるようにするため、アプローチを変えたとのこと。

我々は段階的な導入に向けてのアップグレード戦略を再設計しました。イチかゼロかの「モード」の代わりに、並行レンダリングは新機能のどれかを利用するような更新がある場合にのみ有効化されるようになりました。実用上、これはつまり書き換えをせずに React 18 を導入し、自分のペースで React 18 の新機能を試していけるようになるということです。

動作確認に使用したライブラリのバージョンは以下の通り。

  • next@12.0.7
  • react@18.0.0-beta-24dd07bd2-20211208
  • react-dom@18.0.0-beta-24dd07bd2-20211208
  • typescript@4.5.4
  • @types/react@17.0.38
  • @types/react-dom@17.0.11

React v18 はまだ正式リリースされていないので、ベータ版を使う。

トランジションとは何か

React は原則的に、データと UI が一対一になっている。データと UI はシンクロしており、データが変化すればそれに対応した UI が新しく作られる。
「状態」もデータの一部なので、「状態変化」によっても、UI の構築は行われる。

以下の例では、ボタンを押す毎にstateがインクリメントされるため、その度に UI が作られ、表示されている数字も増えていく。

import { useState } from "react";

function App() {
  const [state, setState] = useState(1);
  return (
    <>
      <button
        onClick={() => {
          setState((s) => s + 1);
        }}
      >
        count up
      </button>{" "}
      <span>{state}</span>
    </>
  );
}

export default App;

f:id:numb_86:20220102221632g:plain

状態が更新される度にレンダリングが行われ、最新の状態と対になる UI が作られる。これが React の原則である。

上記の例では状態はひとつだけ(state)だったが、現実の React アプリでは複数存在することが多い。
そして、ひとつのイベントによって複数の状態が更新されることも珍しくない。その場合 React は、ひとつずつ UI に反映させるのではなく、全ての状態更新が反映された UI を作る。

例えば、何らかのイベントによってABという二つの状態が更新された場合、まず最新のAを反映した UI を作り、続いて最新のBも反映した UI を作る、のではなく、最新のABが反映された UI を一度に作る。

これはABを同列に扱うということだが、言い換えれば、これまでの React では、レンダリングに対して優先度をつけることが出来なかったのである。
Aについてはすぐに UI に反映させたいからまずはAを優先的にレンダリングする、ということができない。このため、何らかの理由でBのレンダリングに時間がかかってしまう場合、それが終わるまでAの更新も UI には反映されなくなってしまう。例えAの反映そのものはすぐに終わるものだったとしても。

トランジションはこの問題を解決する。
トランジションとは「優先度が低い状態更新」のことであり、どの状態更新がトランジションであるかを指定する形で、レンダリングに優先順位をつけることができる。
React アプリの開発者は、startTransitionを使うことで、「この状態更新はトランジションです」と React に指示を出せるようになるのである。
これを上手く使うことで、アプリの操作性を大幅に改善できる可能性がある。

ここからは、具体的な例を示しながらstartTransitionの使い方を説明していく。

環境のセットアップ

サンプルアプリを作るための環境構築を行っていく。簡便なので今回は Next.js を使っているが、startTransitionを使う上で Next.js は必須ではない。

まずは Next.js の環境を構築する。

$ yarn create next-app --ts

次に、React のベータ版をインストールする。TypeScript で開発するので型もインストールする。

$ yarn add react@beta react-dom@beta
$ yarn add -D @types/react @types/react-dom

最後に、tsconfig.jsonを編集する。

     "resolveJsonModule": true,
     "isolatedModules": true,
     "jsx": "preserve",
-    "incremental": true
+    "incremental": true,
+    "types": ["react/next", "react-dom/next"]
   },
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
   "exclude": ["node_modules"]

Next.js の v12 ではこれだけでstartTransitionを使えるようになる。

サンプルアプリの作成

サンプルアプリとして、メンバー毎のタスクを表示するアプリを開発する。

f:id:numb_86:20220102221619p:plain

まずはメンバーとタスクの型を定義する。

type Member = "Alice" | "Bob" | "Carol";

type Task = {
  id: number;
  assignee: Member;
  title: string;
  description: string;
};

続いてタスクリストを作成。

const allTasks: Task[] = [
  {
    id: 1,
    assignee: "Alice",
    title: "React 学習",
    description: "v18 の機能についてキャッチアップする",
  },
  // 同じ要領で適当にタスクを作っていく
]

あとは、これらを元にして UI を作っていく。
重要な箇所は適宜説明していくので、一旦読み飛ばしても構わない。

function TaskCard(task: Task) {
  const { assignee, title, description } = task;

  const getBorderColor = (member: Member): string => {
    if (member === "Alice") return "aqua";
    if (member === "Bob") return "lime";
    return "orange";
  };

  return (
    <div
      style={{
        border: `5px solid ${getBorderColor(assignee)}`,
        borderRadius: "18px",
        margin: "20px",
        padding: "10px",
        width: "200px",
      }}
    >
      <div style={{ fontSize: "22px" }}>{title}</div>
      <div style={{ fontSize: "14px", fontWeight: "bold" }}>{assignee}</div>
      <hr />
      <div style={{ fontSize: "14px" }}>{description}</div>
    </div>
  );
}

function TaskCardList({ taskList }: { taskList: Task[] }) {
  return taskList.length > 0 ? (
    <ul style={{ listStyleType: "none" }}>
      {taskList.map((task) => {
        return (
          <li key={task.id}>
            <TaskCard {...task} />
          </li>
        );
      })}
    </ul>
  ) : null;
}

function App() {
  const [selectedMember, setSelectedMember] = useState<Member>();
  const [headline, setHeadline] = useState<string>("");
  const [taskList, setTaskList] = useState<Task[]>([]);

  const members: Member[] = ["Alice", "Bob", "Carol"];

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const member = e.currentTarget.value as Member;
    setSelectedMember(member);
    setHeadline(`${member}'s task`);
    setTaskList(() => {
      return allTasks.filter((t) => t.assignee === member);
    });
  };

  return (
    <>
      {members.map((m) => {
        return (
          <Fragment key={m}>
            <input
              type="radio"
              value={m}
              onChange={handleChange}
              checked={m === selectedMember}
            />
            {m}{" "}
          </Fragment>
        );
      })}
      <h1>{headline}</h1>
      <TaskCardList taskList={taskList} />
    </>
  );
}

export default App;

問題なく動いている。

f:id:numb_86:20220102221233g:plain

応答性の悪さは UX を大きく損なう

快適に動いているこのアプリだったが、何らかの理由でタスク一覧の表示に時間がかかるようになってしまったとする。
アプリが複雑になって処理が重くなったのかもしれないし、通信している API サーバが遅くなってしまったのかもしれない。ユーザーが使っている端末の性能が高くない場合にも、同様の問題は起こり得る。

その状況を擬似的に再現するため、自作のsleep関数で処理を遅延させてみる。

function sleep(ms: number) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const member = e.currentTarget.value as Member;
    setSelectedMember(member);
    setHeadline(`${member}'s task`);
    setTaskList(() => {
      sleep(1500); // これを追加
      return allTasks.filter((t) => t.assignee === member);
    });
  };

このようにして改めて触ってみると、操作性が大幅に悪化していることが分かる。

f:id:numb_86:20220102221114g:plain

表示速度が遅いこと自体が望ましくないのだが、より大きな問題は、ユーザーがラジオボタンをクリックしたときにアプリが何も反応しないことである。
ユーザーに対するフィードバックが何もないため、自分が操作を間違ったのか、アプリが固まってしまったのか、単に処理に時間が掛かっているだけなのか、ユーザーからは何も分からない。これは大きなストレスになるし、お世辞にも使いやすいアプリとは言い難い。
ウェブアプリのパフォーマンスを考えるときは、絶対的な速度だけでなく、ユーザーがどのように感じるのか、ユーザーが快適に操作できるかも考慮しなければならないが、このアプリではそれが出来ていない。

トランジションによる解決

トランジションによって、この問題をある程度改善できる。

ラジオボタンをクリックしたとき、以下の 3 つの状態が変化している。

  1. selectedMember
  2. headline
  3. taskList

このうち、処理に時間が掛かっているのはtaskListのみである。他の 2 つは、状態更新そのものも、それに伴うレンダリングも、すぐに行える。
にも関わらず全てのレンダリングをまとめて行おうとするため、taskListに関する処理が終わるまで UI を全く更新できなくなってしまうのである。

既に述べたように、トランジションでは状態更新に優先順位をつけることができる。

今回の例だと、ラジオボタンが選択されたことはすぐに UI に反映させたいため、selectedMemberは優先度が高い。
また、headlineも処理に時間が掛かっているわけではないので、すぐに反映させることにする。
そして処理に時間が掛かっており、相対的に見て優先度や緊急性が低いtaskListの状態更新を、トランジションとして扱うことにする。

まず、startTransitionをインポートする。

import { Fragment, useState, startTransition } from "react";

そして、startTransitionsetTaskListをラップする。
これにより、setTaskListはトランジションであると、React に伝えることができる。

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const member = e.currentTarget.value as Member;
    setSelectedMember(member);
    setHeadline(`${member}'s task`);
    // startTransition で setTaskList をラップする
    startTransition(() => {
      setTaskList(() => {
        sleep(1500);
        return allTasks.filter((t) => t.assignee === member);
      });
    });
  };

この状態で再びサンプルアプリを触ってみる。

f:id:numb_86:20220102220944g:plain

ユーザーに操作に応じてラジオボタンと見出しが即座に更新されるようになった。

useTransition を使ったさらなる改善

だがまだ気になる点がある。メンバーを切り替えた際に、古いメンバーのタスクリストが表示され続けてしまっている。
これを改善し、選択したメンバーのタスクリストはまだ読込中であることをユーザーに伝えることができれば、より親切である。

useTransitionを使うことで、それを実現できる。

まず、startTransitionではなくuseTransitionをインポートする。

import { Fragment, useState, useTransition } from "react";

名前から類推できるようにuseTransitionは Hooks なのだが、返り値の最初の要素には、isPendingと呼ばれる真偽値が入っている。
次の要素はstartTransitionなので、これはそのまま使えばいい。

isPendingは、トランジションがレンダリングされている間はtrueになり、レンダリングが終わるとfalseになる。
これをTaskCardListに渡すことで、読み込み中であることをユーザーに伝えることができる。

function App() {
  const [selectedMember, setSelectedMember] = useState<Member>();
  const [headline, setHeadline] = useState<string>("");
  const [taskList, setTaskList] = useState<Task[]>([]);
  const [isPending, startTransition] = useTransition(); // これを追加

  // 中略

      <TaskCardList taskList={taskList} isPending={isPending} /> // isPending を渡す
// isPending を受け取るようにした
function TaskCardList({
  taskList,
  isPending,
}: {
  taskList: Task[];
  isPending: boolean;
}) {
  if (isPending) return <div>Loading...</div>; // ペンディング中はその旨を表示する

  return taskList.length > 0 ? (
    <ul style={{ listStyleType: "none" }}>
      {taskList.map((task) => {
        return (
          <li key={task.id}>
            <TaskCard {...task} />
          </li>
        );
      })}
    </ul>
  ) : null;
}

f:id:numb_86:20220102220835g:plain

参考資料

お知らせ

今後の React は Concurrent Features だけでなく、streaming HTML や Server Component など、面白そうな機能の追加が多く予定されています。
積極的に勉強や検証を行っていきたいと考えているので、同様の関心を持っている方はぜひこちらの Meety に申し込んでみてください。

meety.net

『THE MODEL』を読んだ

「科学的な営業」に興味があり、その分野の定番のひとつである『THE MODEL』を読んだ。
どのように営業プロセスを構築し機能させるのかについてコンパクトにまとまっているので、特に BtoB SaaS を提供している企業で働いている開発者は、一度読んでおくとよいと思う。

www.shoeisha.co.jp

なんとなくの印象だが、「営業」というものについて、自分とは縁遠いもの、別の世界のもの、という感覚を持っている開発者は多いかもしれない。
自分もそうだった。むしろ、かなり悪い印象を抱いていた。

新卒で入った信用金庫の営業スタイルが絵に描いたような根性論、精神論だったのが大きい。
「飛び込み営業をすれば嫌がられるし、何度も訪問すれば怒られる。それでも諦めずに通い続けることで根性を認めてもらえて、取引してもらえるんだ」ということを役員が真顔で語っていたし、「昔は「契約するまで帰りません」と玄関に座り込んだもんだ」みたいな「武勇伝」をよく聞かされた。ビジネスモデルや対象顧客が違いすぎるから、SaaS 事業者と比較するのはフェアではないかもしれない。それでも、ひどかったと思う。
そもそも、顧客の利益など誰も考えていなかった。とにかくノルマのことしか考えていなかった。使いもしないカードローンを契約してもらう、月末の融資残高を増やすためだけに不要な借り入れをしてもらって翌月にすぐに全額返済してもらう、みたいなことを支店ぐるみ、いや会社ぐるみでやっていた。今もやっているのかもしれない。誰のためにもなっていない、数字をいじるためだけの「営業」だった。本当にバカバカしかったし、嫌だった。ノルマ未達だと罵声や暴力が待っているし。
そうではない営業も存在するのかもしれないが、それはフィクションの世界の話というか、それこそビジネス書のなかのお話であり、自分にとってのリアルな「営業」というのは、上記のようなものを指していた。

だが現職では「そうではない営業」を実践しており、イメージが変わった。
「THE MODEL」型のプロセスを構築して営業活動を行っている(今日現在)。そしてちゃんと数字を積み上げている。

SaaS 事業者で働いたことがなかったこともあり、「THE MODEL」という概念自体を知らなかったのだが、入社直後の研修で説明してもらって興味を持った。
新卒時のトラウマもあって自分が営業をやってみたいという気持ちにはならないが、「学び、理解する対象」として面白そうだなと思った。そこでまず手始めに、本書を手に取った。

本書のことも当然知らなかったのだが、「THE MODEL」という概念を日本に広めた本らしい。
「THE MODEL」の概要を大雑把に説明すると、営業プロセスをマーケティング(MK)、インサイドセールス(IS)、フィールドセールス(FS)、カスタマーサクセス(CS)に分解し、各プロセスが協力して売上を伸ばしていくモデルのことを指す。
従来の営業は、一人の担当者が全ての領域を担当していた。見込み客の開拓、商談、既存顧客の管理、全てを行う。私が所属していた信用金庫もそうだった。そうではなく、分業体制を敷き、そのプロセスを緻密に管理していくことに、「THE MODEL」の特徴がある。

本書では、「分業(顧客ステージの分類)」の他、「客観的な指標による計測」、「リサイクル」、などが重要な概念として何度も出てくる。
既に述べたように 4 つのプロセスに分解するのだが、各プロセスのなかでもさらに細分化を行い、「顧客は今どのステージにいるのか」ということを緻密に管理する。そしてセミナーやアポイントメント、商談、オンボーディングなどの各種コミュニケーションは全て、顧客を次のステージに進めるために行われる。そして、提供すべきコミュニケーションやコンテンツはステージ毎に異なるため、正確な顧客管理が重要になる。プロセス管理を細かく行うことで、どこがボトルネックになっているのかも把握しやすくなる。
そしてそれを行うためには、客観的な指標が必要になる。ステージが遷移したと判定するための明確な基準がなければ、管理や分析は上手く機能しない。性質上どうしても主観が入るものもあるが、それでも、担当者毎のバラツキを抑えるための取り組みを行うことが求められる。
そして、顧客ステージの遷移は直線的にのみ行われるものではなく、循環する。具体的には、様々な理由で受注にまで至らなかった顧客を「リサイクル」というステージに遷移させる。そしてそのステージの顧客を、再び検討プロセスに戻す。例えば、顧客の事業フェーズの問題で受注にまで至れなかった案件が、半年後には状況が変わって受注できる状態になっているかもしれない。このような失注した案件の掘り起こしは目新しいものではないが、これを仕組みとして管理することで、新規開拓だけに頼らない成長が可能になる。

著者自身が「自分の会社にとっての「ザ・モデル」を創造することを目指してほしい(「はじめに」より)」と述べているように、本書で紹介されているやり方をそのまま模倣すればいいというものではない。
それでも、基本というか、定番の考え方や方法論を知ることで、議論を理解しやすくなるとは思うし、読んでよかった。

自分は開発者として本書を読んでいたが、「開発者がよいプロダクトを作らないとどうにもならないんだよな」と改めて思った。
営業組織がいくら高い志を持ち、ロジカルに戦略や戦術を立て、頑張ったところで、商材であるプロダクトがポンコツで顧客のニーズに全く応えられておらず、そして競合にもあらゆる面で負けていたら、どうしようもない。
価値のあるプロダクトを作り続けないと、自分がすごく嫌だった「数字を作るため、顧客にとって明らかに不要なものを売り込む」という状態を生み出しかねない。

「何を作るか」は全社的に決めていくことだが、「どう作るか」は基本的には開発組織の責任だと思う。
スピード感を持って開発できなくなる要因はいくらでもある。開発者としての実務経験は 3 年にも満たないが、それでも、何度も見てきた。純粋な技術力不足もあれば、組織が硬直化して機動的に動けないこともある。過去の雑な実装や設計が積み重ねって身動きが取れなくなることもある。採用や育成に失敗して開発組織のキャパシティを大きくできず、事業が縮小していったケースも見た。

最終的には、営業組織と開発組織は独立して個別に存在するものではない、お互いに影響を受け合う、みたいな当たり前過ぎる感想になった。だけどこういう話をしている開発者ブログはあまり読んだことがないから、関心を持っている人は少ないのかもしれない。
私も、自分が営業を経験していなかったら、興味を持つことはなかったかもしれない。そう考えると、とにかく苦痛だった信金時代にも少しくらいは意味があったのかもしれない。