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

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

プログラミングを独学する上で実際に役に立った本

この記事を書いた文脈

twitter.com

twitter.com

お二人が書かれたものは以下。

golden-lucky.hatenablog.com

voluntas.medium.com

前提

ブログのタイトルにあるように、30 歳からプログラミングを始めた。
最終学歴は大卒だが、定員割れしている文系私大である。高校は行っていないので、数学は中学レベル。つまり、何の素養もないところから独学を始めた。
いくつかの会社でプログラマとして働いたので、「プログラマになれた」と言っていいと思う。ブラック企業にぶち込まれたわけでもないし。

ここで紹介する技術書以外にも面白かった本はいくつもあるのだが、「何を読むことで、未経験の人間がプログラマとして働けるようになったのか」という文脈で選んだ。
これらの技術書がお勧め、という訳ではなく、自分はこうした、というだけ。

JavaScript

『JavaScript本格入門』

gihyo.jp

自分をプログラミング言語に入門させてくれたのは、間違いなくこの本。
難しすぎるわけでもなく、かといって敷居を下げすぎて単なるレシピ集のようになってしまっているわけでもなく、しっかりと「JavaScript という言語」について教えてくれる入門書。
この本のおかげで、JavaScript という門をくぐることができた。
改訂版が出ているので今読むならこっちだが、自分は未読。

『オブジェクト指向JavaScriptの原則』『開眼! JavaScript』

www.oreilly.co.jp

www.oreilly.co.jp

『JavaScript本格入門』を読み終えたあと、JavaScript の言語仕様をもっと学ぼうと思い、読んだ。
当時既に React や AngularJS が流行っていたが、フレームワークやライブラリの前に言語をちゃんと理解しようと思い、手にとった。
どちらもコンパクトで読みやすく、よかった。
どちらもかなり古い本なので、今更敢えて読むようなものではないと思う。

今から JavaScript をきちんと学ぶのなら、やっぱり JavaScript Primer だろうか。

GitHub

『Web制作者のためのGitHubの教科書』

book.impress.co.jp

Git や GitHub の習得が必須だということは理解していたのだが、ネット上の記事をいくつか読んでも、ピンと来なかった。
そこで、とにかく文字が少なそうなこの書籍を読むことにした。
かなり初歩的かつ具体的な内容で、何とか Git を使えるようになった最初のキッカケはこの本だった気がする。
CUI をろくに使えなかった当時の自分にとっては、Sourcetree の使い方を丁寧に説明してくれる本書はかなりありがたかった。

既にある程度理解した状態で読んだこの書籍も、入門書として質が高かった記憶がある。

numb86-tech.hatenablog.com

Web

『Webを支える技術』

gihyo.jp

HTTP や REST といった、ウェブにおける基本的な概念を分かりやすく説明してくれている名著。
自分はこの本で REST という言葉や HTTP の仕組みに初めて触れた。
近いテーマの本として『Real World HTTP』があるが、初学者には厳しいので、まずは本書を読んだほうがよいと思う。
著者によれば本書も初心者向けではないとのこと。確かに最初に読むような本ではない。とはいえウェブプログラミングをやるのなら、できるだけ早く HTTP の概念に触れたほうが、その後の学習効率が高まると思う。

twitter.com

設計

『オブジェクト指向設計実践ガイド』

gihyo.jp

これは、就職後に読んでよかった本。だが独学していく上で重要な本だったので、書いておく。
設計というものに対して関心が高まり、本書を手にとった。
「オブジェクト指向」というと Java や C++ で語られる印象があるのだが、本書は Ruby で説明してくれているので、ウェブプログラマでも取っ付き易い。
設計のテクニック自体もよかったが、それ以上に、「設計」や「オブジェクト指向」に対する考え方を変えることができたのが、個人的には大きかった。
詳しいことは以前書いたので、そちらを読んで欲しい。

numb86-tech.hatenablog.com

「設計」や「オブジェクト指向」といった言葉にビビる必要はないし、「オブジェクト指向とは何か」という論争に参加する必要もない。自分にとって必要なことを、必要になったときに学べばよいのだ。

React の Concurrent Mode を使ってみる(2020年12月版)

React で開発が進められている Concurrent Mode。
まだリリース前の開発中の機能だが、「実験的機能」として提供されており、Experimentalビルドをインストールすることで利用できる。
Experimentalはリリース間の安定性を何も保証しておらず、破壊的変更が行われる可能性がある。Concurrent Mode の動作も、大きく変わる可能性がある。
記事のタイトルに「2020年12月版」と入れたのは、そのため。

公式ドキュメントでは「並列モード」と翻訳されているが、まさに、並列的にレンダリングを行えるようになる。
ネットワークからデータを取得して要素をレンダリングする際に、ユーザーに見えないところで新しいレンダリングの準備をしつつ、データが取得できるまでは古いレンダリングを表示しておく、といったことが可能になる。
この記事では、どういった仕組みでそのようなことが可能になっているのか、ひとつずつ見ていく。

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

  • react@0.0.0-experimental-4ead6b530
  • react-dom@0.0.0-experimental-4ead6b530
  • typescript@4.1.3

Concurrent Mode を有効にする

createRootを使って React 要素をマウントすると、その要素全体で Concurrent Mode が有効になる。
従来はReactDOM.render(element, container)だったものが、ReactDOM.createRoot(container).render(element)になる。
以下のコードでは、<App />全体で Concurrent Mode が有効になる。今回は TypeScript を使うため、トリプルスラッシュディレクティブで型定義も読み込んでいる。

/// <reference types="react-dom/experimental" />

import {unstable_createRoot} from 'react-dom';

import {App} from './components/App';

unstable_createRoot(document.querySelector<HTMLDivElement>('#app')!).render(
  <App />
);

createRootunstable_という接頭語が付いているのは、この API がまだ実験的なものであり安定版ではないことを意味している。
バージョニングポリシー – React

Suspense はスローされた Promise をキャッチする

Concurrent Mode においては、Suspenseコンポーネントが重要な役割を果たす。

Suspenseコンポーネント自体は以前から存在したが、コンポーネントの Dynamic Import を行うために使われていた。

numb86-tech.hatenablog.com

Concurrent Mode では、コンポーネント以外のリソース(例えば、ネットワークから取得するデータ)の取得を待機することができるようになった。

それを可能にしているのが、「スローされたPromiseをキャッチする」というSuspenseの機能である。

React ツリーのなかでPromiseがスローされると、ツリーを上に辿っていき、一番最初に到達したSuspensefallbackが表示される。
もし最後までSuspenseが見つからなかった場合、ツリー全体がアンマウントされる。

例えば以下のコードでは、ThrowPromiseコンポーネントがPromiseをスローしている。
そこからツリーを上に辿っていくと<Suspense fallback={<div>Fallback</div>}>が見つかるため、そのfallbackに設定されている<div>Fallback</div>が表示される。

/// <reference types="react/experimental" />

import {Suspense} from 'react';

function ThrowPromise() {
  throw Promise.resolve(1);
  return <div>foo</div>;
}

export function App() {
  return (
    <Suspense fallback={<div>Fallback</div>}>
      <ThrowPromise />
    </Suspense>
  );
}

「一番最初に到達したSuspensefallbackが表示される」ため、以下のコードでは2が表示される。

export function App() {
  return (
    <Suspense fallback={<div>1</div>}>
      <Suspense fallback={<div>2</div>}>
        <ThrowPromise />
      </Suspense>
    </Suspense>
  );
}

スローされたものをキャッチしてフォールバックを表示する、という点で Error Boundary に近い機能だと言える。

numb86-tech.hatenablog.com

だが Error Boundary と違い、SuspensePromise以外のものがスローされた場合はキャッチしない。
そしてもうひとつ大きな違いが、Promiseの状態が変化すると、Suspenseでラップされた要素のレンダリングを再び試みる、という点である。

以下のコードを実行すると、1秒間Fallbackを表示したあと、fooが表示される。

/// <reference types="react/experimental" />

import {Suspense} from 'react';

let flag = false;

function ThrowPromise() {
  if (!flag) {
    throw new Promise((resolve) => {
      setTimeout(() => {
        flag = true;
        resolve(null);
      }, 1000);
    });
  }
  return <div>foo</div>;
}

export function App() {
  return (
    <Suspense fallback={<div>Fallback</div>}>
      <ThrowPromise />
    </Suspense>
  );
}

ThrowPromiseをレンダリングしようとすると、flagfalseのため、Promiseがスローされる。そしてそれをキャッチしたSuspenseがフォールバックを表示する。
ここまでは、先程の例と同じ。
異なるのは、1秒後にPromiseの状態が変化すること。
そしてPromiseの状態が変化したことで、改めてThrowPromiseをレンダリングしようとする。その際にはflagの値がtrueになっているためPromiseはスローされず、div要素が返される。

レンダリングのトリガーになるのはPromiseの状態変化であり、fulfilledになるかrejectedになるかは、関係ない。
そのため、ThrowPromiseを以下のように書き換えても、同じように動作する。

function ThrowPromise() {
  if (!flag) {
    throw new Promise((_, reject) => {
      setTimeout(() => {
        flag = true;
        reject(new Error());
      }, 1000);
    });
  }
  return <div>foo</div>;
}

Suspense とデータ取得を組み合わせる

ここまで説明したSuspenseの仕組みを使って、ネットワークからのデータ取得を実装してみる。

まず、API サーバを用意する。

const http = require('http');

function resJson(res, data, ms) {
  setTimeout(() => {
    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
    });
    res.write(JSON.stringify(data));
    res.end();
  }, ms);
}

http
  .createServer((req, res) => {
    switch (true) {
      case /^\/1$/.test(req.url):
        resJson(res, {id: 1, name: 'Alice'}, 1000);
        break;
      case /^\/2$/.test(req.url):
        resJson(res, {id: 2, name: 'Bob'}, 1000);
        break;
      case /^\/3$/.test(req.url):
        resJson(res, {id: 3, name: 'Carol'}, 1000);
        break;
      case /^\/4$/.test(req.url):
        resJson(res, {id: 4, name: 'Dave'}, 1000);
        break;
      default:
        res.writeHead(404);
        res.end();
    }
  })
  .listen(3000);

挙動が分かりやすくなるように、1秒経過してからレスポンスを返すようにしてある。

そして、この API サーバを叩いてデータを取得する関数が、以下のfetchUser

type Profile = {
  id: number;
  name: string;
};

export function fetchUser(id: number) {
  let status = 'pending';
  let result: Profile;
  let error: Error;

  const suspender = fetch(`http://localhost:3000/${id}`)
    .then((r) => {
      r.json().then((res) => {
        status = 'success';
        result = res;
      });
    })
    .catch((e) => {
      status = 'error';
      error = e;
    });
  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw error;
      }
      return result;
    },
  };
}

重要な点は、fetchUserの返り値はPromiseではないということ。{read() {...}}というオブジェクトを返す。
そしてreadメソッドがどのように動くのかは、呼び出したタイミングによって異なる。
fetchが返したPromiseの状態が変化する前に呼び出すとstatuspendingなので、suspenderPromise)をスローする。
Promiseが解決されたあとに呼び出すとstatussuccessになっているので、result、つまり API からの返り値を返す。

fetchUserを利用する側のコードは以下の通り。
1秒間Loading...を表示したあと、1: Aliceを表示する。

/// <reference types="react/experimental" />

import {Suspense, useState} from 'react';

import {fetchUser} from '../api';

function Profile({resource}: {resource: ReturnType<typeof fetchUser>}) {
  const profile = resource.read();
  return (
    <div>
      {profile.id}: {profile.name}
    </div>
  );
}

const initialResource = fetchUser(1);

export function App() {
  const [resource] = useState(initialResource);
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile resource={resource} />
    </Suspense>
  );
}

以下のような処理の流れになる。

  1. fetchUser(1)を実行し、データの取得を開始する
  2. fetchUser(1)の返り値をresourceにセットし、Profileコンポーネントに渡す
  3. Profileコンポーネントはresource.read()を実行してデータを取得しようとするが、まだデータ取得中なのでPromiseがスローされる
  4. スローされたPromiseSuspenseがキャッチして、フォールバックを表示する
  5. 1秒後、スローされたPromiseの状態がfulfilledに変化し、再度Profileコンポーネントをレンダリングしようとする
  6. Profileコンポーネントがresource.read()を実行すると、先程とは違って API からの返り値を取得でき、profile変数に代入される
  7. profile.idprofile.nameを使ってレンダリングが行われ、Promiseもスローされていないので、フォールバックではなくProfileが表示される

データ取得中からデータ取得済みへと状態が変わり、それに伴って表示内容も変わっている。
だがstateへのセットは一度しか行われていない。従来は状態の変化に合わせて開発者がstateを更新し、状態に応じた表示の出し分けも開発者が書く必要があった。if (!profile) return <div>Loading ...</div>のように。
それが不要になったのは、状態の遷移に応じた処理を React が行うようになったため。データ読み込み中はfallbackを表示し、データ読み込みが完了したらfallbackの表示を止めてデータに基づいたレンダリングを行ってくれる。

コンポーネント側はシンプルに書けるようになった反面、データ取得側はSuspenseに対応した処理が必要になる。
「データを取得できるまではPromiseをスローし、取得後はデータを返す」という仕組みが重要なので、素朴にPromiseを返すだけでは、Suspenseと連携できない。
公式ドキュメントによると、Facebook では Relay を使ってSuspenseと連携させているとのこと。

並列的なレンダリングによるユーザ体験の向上

useTransitionは、 Concurrent Mode で追加された Hooks のひとつ。
これを使うことで、より柔軟に UI を設計できるようになる。

題材として、先程のコードを拡張し、ボタンを押下する度に次の ID のユーザが表示されるようにする。

まずは、useTransitionを使わずに書く。

/// <reference types="react/experimental" />

import {Suspense, useState} from 'react';

import {fetchUser} from '../api';

function Profile({
  nextId,
  resource,
  handleClick,
}: {
  nextId: number;
  resource: ReturnType<typeof fetchUser>;
  handleClick: () => void;
}) {
  const profile = resource.read();
  const onClick = handleClick;
  return (
    <>
      <button type="button" onClick={onClick}>
        Next
      </button>{' '}
      <i>Next ID: {nextId}</i>
      <div>
        {profile.id}: {profile.name}
      </div>
    </>
  );
}

const INITIAL_ID = 1;

const initialResource = fetchUser(INITIAL_ID);

export function App() {
  const [resource, setResource] = useState(initialResource);
  const [nextId, setNextId] = useState(INITIAL_ID + 1);

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));
    setResource(fetchUser(nextId));
  };

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile nextId={nextId} resource={resource} handleClick={handleClick} />
    </Suspense>
  );
}

基本的な仕組みは先程と変わらない。
ボタンを押下するとstateが更新されるため、再レンダリングが発生する。だがresource.read()Promiseがスローされるため、フォールバックが表示される。Promiseの状態が変わったら、つまり API からレスポンスが返ってきたら、改めてレンダリングを試みる。そうすると今度はresource.read()で API からのレスポンスを取得できるので、無事にレンダリングされる。

f:id:numb_86:20201216194759g:plain

次に、useTransitionを使った形に書き換える。
useTransitionの返り値の最初の要素である、startTransitionを使う。このstartTransitionには関数を渡すのだが、そのなかでresourceの更新を行うようにする。

/// <reference types="react/experimental" />

import {Suspense, useState, unstable_useTransition} from 'react';

import {fetchUser} from '../api';

function Profile({
  nextId,
  isPending,
  resource,
  handleClick,
}: {
  nextId: number;
  isPending: boolean;
  resource: ReturnType<typeof fetchUser>;
  handleClick: () => void;
}) {
  const profile = resource.read();
  const onClick = handleClick;
  return (
    <>
      <button type="button" onClick={onClick} disabled={isPending}>
        {isPending ? 'Loading...' : 'Next'}
      </button>{' '}
      <i>Next ID: {nextId}</i>
      <div>
        {profile.id}: {profile.name}
      </div>
    </>
  );
}

const INITIAL_ID = 1;

const initialResource = fetchUser(INITIAL_ID);

export function App() {
  const [resource, setResource] = useState(initialResource);
  const [nextId, setNextId] = useState(INITIAL_ID + 1);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));

    // resource の更新を startTransition でラップした
    startTransition(() => {
      setResource(fetchUser(nextId));
    });
  };

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile
        nextId={nextId}
        isPending={isPending}
        resource={resource}
        handleClick={handleClick}
      />
    </Suspense>
  );
}

そうすると、先程とは挙動が変わる。

f:id:numb_86:20201216194902g:plain

具体的には、ボタンを押下してもフォールバックが表示されなくなった。

なぜこのような結果になるのか、ひとつずつ見ていく。

useTransition の仕組み

原則として、startTransitionを実行するとレンダリングが2回発生する(例外もあるので後述する)。startTransitionのなかで状態を更新しているかどうかは、無関係。

そのため以下のコードの場合、ボタンを押す度にダイアログが2回表示される。

function Child({count, handleClick}: {count: number; handleClick: () => void}) {
  useEffect(() => {
    alert(count);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [startTransition] = unstable_useTransition();

  const handleClick = () => {
    startTransition(() => {});
  };

  return <Child count={0} handleClick={handleClick} />;
}

f:id:numb_86:20201216195034g:plain

次にこのコードに状態管理を組み合わせる。

function Child({
  foo,
  bar,
  handleClick,
}: {
  foo: number;
  bar: number;
  handleClick: () => void;
}) {
  useEffect(() => {
    alert(`foo: ${foo}, bar: ${bar}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(0);
  const [startTransition] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar((s) => s + 1);
    });
  };

  return <Child foo={foo} bar={bar} handleClick={handleClick} />;
}

イベントハンドラのなかでfoobarをインクリメントするのだが、fooの更新はstartTransitionでラップせず、barの更新はラップした。
この状態でボタンを押下すると、どうなるか。

f:id:numb_86:20201216195102g:plain

最初のダイアログではfooのインクリメントのみが反映されており、2回目のダイアログでようやく、barのインクリメントが反映されている。
つまり、startTransitionを実行すると、ラップされていない状態更新が反映されたレンダリングを行い、その後、ラップされた状態更新も反映されたレンダリングを行う。

さらにここに、useTransitionの返り値の 2 番目の要素であるisPendingも組み合わせてみる。

function Child({
  foo,
  bar,
  isPending,
  handleClick,
}: {
  foo: number;
  bar: number;
  isPending: boolean;
  handleClick: () => void;
}) {
  useEffect(() => {
    alert(`foo: ${foo}, bar: ${bar}, isPending: ${isPending}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(0);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar((s) => s + 1);
    });
  };

  return (
    <Child
      foo={foo}
      bar={bar}
      isPending={isPending}
      handleClick={handleClick}
    />
  );
}

f:id:numb_86:20201216195143g:plain

最初のダイアログではisPendingtrue2回目のダイアログではfalseになっている。

ここまでの内容をまとめると、次のようになる。

  • startTransitionを実行するとレンダリングが発生する
    • その際、startTransitionでラップされている状態更新以外の更新が、反映される
    • isPendingtrueとして、レンダリングされる
  • 上記のレンダリングが終わると、再びレンダリングが行われる
    • 今度は、startTransitionでラップされている状態更新も反映される
    • isPendingfalseとして、レンダリングされる

そして最後に、useTransitionの最大の特徴について説明する。
それは、2回目のレンダリング時にPromiseがスローされると、Suspenseがそれをキャッチするのではなく、1回目のレンダリングが表示され続け、Promiseの状態が変化した時点で改めてレンダリングを試みる、というものである。

以下のコードでそれを確認できる。

function countUp(arg: number) {
  let status = 'pending';

  const suspender = new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, 3000);
  }).then(() => {
    status = 'success';
  });
  return {
    get() {
      if (status === 'pending') {
        throw suspender;
      }
      return arg + 1;
    },
  };
}

function Child({
  foo,
  bar,
  isPending,
  handleClick,
}: {
  foo: number;
  bar: {get(): number};
  isPending: boolean;
  handleClick: () => void;
}) {
  const barValue = bar.get();

  useEffect(() => {
    alert(`foo: ${foo}, bar: ${barValue}, isPending: ${isPending}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

const initialBar = countUp(-1);

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar(countUp(foo));
    });
  };

  return (
    <Suspense fallback={<div>Fallback</div>}>
      <Child
        foo={foo}
        bar={bar}
        isPending={isPending}
        handleClick={handleClick}
      />
    </Suspense>
  );
}

f:id:numb_86:20201216195302g:plain

ボタンを押下するとまず、fooが更新された状態でレンダリングされる。これは先程までと同じ。
次にbarが更新された状態でレンダリングを試みるのだが、そうすると、const barValue = bar.get();の部分でPromiseがスローされる。
すると、Suspenseがキャッチしてフォールバックを表示する、のではなく、1回目のレンダリングが表示され続ける。つまり、fooのみが更新された状態のレンダリングが、そのまま表示され続ける。
そしてPromiseの状態が変化したとき(この例だと3秒後)に、barが更新された状態でのレンダリングを改めて試みる。今度はbar.get()Promiseをスローしないので、問題なくレンダリングされる。

注意しなければならないのは、2回目のレンダリングではなく1回目のレンダリングでPromiseがスローされた場合、また異なった挙動になるということである。

1回目のレンダリングでPromiseがスローされると、それはSuspenseにキャッチされ、フォールバックが表示される。
そしてPromiseの状態が変化した際に改めてレンダリングが行われるのだが、その際にはstartTransitionでラップされた状態更新も反映した形で、レンダリングされる。isPendingfalseになる。

handleClickを以下のように書き換えることで、確認できる。

  const handleClick = () => {
    setBar(countUp(foo));
    startTransition(() => {
      setFoo((s) => s + 1);
    });
  };

f:id:numb_86:20201216195347g:plain

ここまで説明してきた内容を踏まえて、改めてデータ取得の例を見てみる。

ボタンを押下すると、以下のコードが実行される。

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));
    startTransition(() => {
      setResource(fetchUser(nextId));
    });
  };

setNextIdstartTransitionでラップされていない。そのため、Next IDの変更はすぐに画面に反映される。
その後、setResourceによる更新を反映させてレンダリングを行おうとするが、ProfileコンポーネントがPromiseをスローする。そのため、先程のレンダリング結果(Next IDが更新された画面)をそのまま表示し続ける。そしてPromiseの状態が変化すると改めてレンダリングが行われ、新しいユーザの情報が画面に表示される。
また、Promiseの状態変化を待っている間はisPendingtrueであるため、その間だけボタンの文字列はLoading....になる。

f:id:numb_86:20201216194902g:plain

useTransitionによって実現されたこの挙動は、「レンダリングが並列的に行われている」と捉えることができる。
resourceが更新されたバージョンのProfileを準備しつつ、nextIdは更新されたがresourceが更新されていないバージョンのProfileを表示している。2 つのProfileが存在している。
この仕組みを上手く使うことで、不完全な状態の画面が表示されてしまうのを回避したり、逆に少しでも速くユーザの操作に対するフィードバックを返したり、といったことが可能になる。

useDeferredValue を使ったレスポンシブ性の向上

useTransitionの他にuseDeferredValueという Hooks が追加されており、こちらを使うことでも、状態の更新を遅延させることができる。
優先度が低い上に処理に時間がかかる更新を後回しにして、優先度が高い更新をできるだけ早く行って表示に反映させる。そうすることで、アプリのレスポンス性を高めることが企図されている。

stateuseDeferredValueに渡すと、deferredValueを得られる。
そしてuseDeferredValueが存在する状態でstateを更新すると、まず、stateだけを更新した状態でレンダリングを行う。その後、deferredValueの値を更新後のstateと同じにした上で、またレンダリングを行う。

例を示す。

/// <reference types="react/experimental" />

import {Fragment, useState, unstable_useDeferredValue} from 'react';

export function App() {
  const [state, setState] = useState(1);
  const deferredValue = unstable_useDeferredValue(state);

  const countUp = () => {
    setState((s) => s + 1);
  };

  useEffect(() => {
    alert(`state: ${state}, deferredValue: ${deferredValue}`);
  });

  return (
    <>
      <button type="button" onClick={countUp}>
        count up
      </button>
      <div>state: {state}</div>
      <div>deferredValue: {deferredValue}</div>
    </>
  );
}

f:id:numb_86:20201216195604g:plain

まずstate:2, deferredValue:1でレンダリングを行い、その直後にstate:2, deferredValue:2でレンダリングを行っている。

上記の例ではuseEffectでダイアログを出していたので、レンダリングが2回行われたことを確認できた。
だがuseEffectを削除してしまうと、statedeferredValueがほぼ同時に更新されるため、違いを知覚できない。

f:id:numb_86:20201216195639g:plain

レンダリングのための処理が重く、stateが頻繁に更新されると画面の更新が遅れてしまうような状況で、useDeferredValueは力を発揮する。
そのような状況では、処理が落ち着くまでuseDeferredValueの更新が遅延される。そのため、表示の更新が遅れても問題ないコンポーネントにはdeferredValueを渡してレンダリングを抑制し、優先的に表示を更新したいコンポーネントにのみstateを渡すことで、更新を遅れを減少させることができる。

優先的に表示すべき情報の典型例として、ユーザの操作に対するフィードバックがある。
例えば、テキストボックスに文字列を入力したら、その内容が即座に表示されることが求められる。

以下のテキストボックスは、文字列を入力しても反映までに時間が掛かってしまう。

/// <reference types="react/experimental" />

import {memo, useState} from 'react';

const ExpensiveComponent = memo(({text}: {text: string}) => {
  // わざと処理に時間がかかるようにしている
  const startTime = performance.now();
  while (performance.now() - startTime < 120);

  if (text === '') {
    return (
      <div>
        <i>Please input.</i>
      </div>
    );
  }

  return (
    <div>
      Text is <b>{text}</b>.
    </div>
  );
});

export function App() {
  const [text, setText] = useState('foo');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.currentTarget.value);
  };

  return (
    <>
      <input value={text} onChange={handleChange} />
      <ExpensiveComponent text={text} />
    </>
  );
}

f:id:numb_86:20201216195802g:plain

ExpensiveComponentの処理に時間がかかってしまっているのが原因。
ExpensiveComponentの処理が終わる前に次々とtextの更新が発生するため、表示の更新が追いつかない。ユーザによる入力が一段落して、ようやく表示が更新される。

このように、テキストボックスへの反映が遅れてしまうと、目に見えて操作性が悪くなる。
このとき、ExpensiveComponentの更新だけを遅延させることが許容されるなら、useDeferredValueを使うことで操作性を改善できる。
具体的には、以下のようにtextではなくdeferredTextExpensiveComponentに渡すようにすればよい。

import {memo, useState, unstable_useDeferredValue} from 'react';

// 中略

export function App() {
  const [text, setText] = useState('foo');
  const deferredText = unstable_useDeferredValue(text);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.currentTarget.value);
  };

  return (
    <>
      <input value={text} onChange={handleChange} />
      <ExpensiveComponent text={deferredText} />
    </>
  );
}

そうすると、ExpensiveComponentの更新にある程度の遅れが発生する代わりに、テキストボックスの更新は迅速に行われるようになる。

f:id:numb_86:20201216195835g:plain

先程と同じようにtextは次々と更新されていくが、deferredTextは更新されず、textだけが更新された状態でレンダリングが行われていく。
そのため、ユーザのタイピングに合わせてinput.valueには最新のtextが次々と渡されるが、ExpensiveComponent.textには常に同じ値('foo')が渡される。
そしてExpensiveComponentmemoでラップされているため、propsが変わらない限りは再レンダリングされない。
そのため、ExpensiveComponentによる重い処理の影響を受けることなくテキストボックスが更新されていき、textの更新が一段落した段階でdeferredTextが更新され、ようやくExpensiveComponentの再レンダリングが行われる。

今回のようなケースの他に、Promiseがスローされてレンダリングが中断された際にも、deferredValueの更新が遅延される。

復習を兼ねてまず、useDeferredValueを使わないパターンの挙動について見てみる。

/// <reference types="react/experimental" />

import {Suspense, useState, useEffect} from 'react';

function countUp(arg: number) {
  let status = 'pending';

  const suspender = new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, 2000);
  }).then(() => {
    status = 'success';
  });
  return {
    get() {
      if (status === 'pending') {
        throw suspender;
      }
      return arg + 1;
    },
  };
}

function Child({resource}: {resource: {get(): number}}) {
  const value = resource.get();

  useEffect(() => {
    alert(value);
  });

  return <span>{value}</span>;
}

const initialBar = countUp(-1);

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);

  const handleClick = () => {
    setFoo((s) => s + 1);
    setBar(countUp(foo));
  };

  return (
    <>
      <button type="button" onClick={handleClick}>
        count up
      </button>
      <Suspense fallback={<div>Fallback</div>}>
        <div>
          bar: <Child resource={bar} />
        </div>
      </Suspense>
    </>
  );
}

上記のコードの場合、count upボタンを押下するとPromiseがスローされ、レンダリングが中断される。
Promiseの状態が変化すると改めてレンダリングが試みられ、今度はresource.get()Promiseをスローしないのでレンダリングに成功する。
そしてuseEffectが実行され、ダイアログが表示される。

f:id:numb_86:20201216195946g:plain

これを書き換えて、ChildコンポーネントにbarではなくdeferredBarを渡すようにする。

import {Suspense, useState, useEffect, unstable_useDeferredValue} from 'react';

// 中略

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);
  const deferredBar = unstable_useDeferredValue(bar);

  const handleClick = () => {
    setFoo((s) => s + 1);
    setBar(countUp(foo));
  };

  return (
    <>
      <button type="button" onClick={handleClick}>
        count up
      </button>
      <Suspense fallback={<div>Fallback</div>}>
        <div>
          bar: <Child resource={deferredBar} />
        </div>
      </Suspense>
    </>
  );
}

そうすると挙動が変わり、フォールバックが表示されなくなる。
そして、ボタンを押下する度にダイアログが2回表示されるようになっている。

f:id:numb_86:20201216200025g:plain

PromiseがスローされるとdeferredBarの更新が遅延されるため、このような挙動になる。
ボタンを押下してもdeferredBarを更新せず、前回のレンダリング時と同じ値で、レンダリングを行う。
当然、resource.get()は前回と同じ値を返すため、レンダリングの結果は前回と変わらない。
そして、Promiseの状態が変化するとdeferredBarが更新される。そうするとChildも再レンダリングされるが、今度はresource.get()が新しい値を返すため、それを使ったレンダリングが行われる。
そのため、1回目のダイアログでは前回と同じ値が使われ、2回目のダイアログでは値が更新されているのである。