Next.jsのISRを使って業務フローを変えた話

この記事は Next.js アドベントカレンダー 2020 の最終日の記事です。

本記事では、Next.js の ISR の機能を使って業務フローを変えた話を紹介します。Incremental Static Regeneration(以下、ISR) とは、Next.jsアプリケーションをビルドしてデプロイした後も、特定のページのみ定期的に再ビルドする機能です。

ISRでのリクエスト先は Google Apps Script(以下、GAS)にしました。GAS でスプレッドシートのデータを返却する API を作成したので、コードも併せて紹介します。

作ったものは書籍の一覧更新を自動化するもの

開発しているサービス「弁護士ドットコムライブラリー」を紹介します

私は仕事で 弁護士ドットコムライブラリーというサイトを開発しています。このサイトは弁護士の方向けの法律書籍読み放題サービスで、フロントはNext.js、サーバーはPHPで記述しています。

サイトのトップページ

関連記事: 弁護士ドットコムライブラリーのフロントエンドのアーキテクチャ(Next.js + TypeScript)

弁護士ドットコムライブラリーでは、トップページにプロダクトオーナー(以下、PO)が選定した書籍を100冊以上掲載しています。

初期リリース時には、まずハードコードで問題ないという判断をしました。これには2つの理由があります。1つは、書籍のメタ情報を非エンジニアでも変更したいという要件があったためtomlファイルで管理しており、DBにはbookテーブルは存在しないこと。もう1つはトップページの書籍の入れ替え要件が発生していなかったからです。しばらくはこれでうまくいきました。

書籍一覧

ハードコーディングでは課題がある

リリース後、しばらくサービスを運用していくうちに、各出版社様から新しい書籍や新着雑誌、また有名な書籍の掲載許可を数々頂くことができました。このため、トップページの書籍を定期的に入れ替えたいという要望がチーム内から上がってきました。

最初は、POは選び直した書籍のメタ情報をスプレッドシートに記載し、それを元にエンジニアがハードコードしているデータを定期的に書き変えるという運用フローに収まりました。しばらくの間月1回、月初にその対応をしていました。

ただ、PO としては出版社様から掲載許可を頂いたタイミングでアドホックに書籍一覧を更新したいという要望が出てきました。これはもっともな意見です。

弁護士ドットコムライブラリーは毎月更新のサブスクリプションサービスであるため、ユーザーの契約更新のタイミングまでに新着書籍が入ったことを何度かアピールしたいのです。その場合、月1回のみの更新だと書籍一覧の変更サイクルとしては長すぎ、ユーザーが新着書籍に気づかずに退会してしまうかもしれません。

一方、PO としては、月中に何度も変更するとエンジニアにとって負担になって通常業務に支障が出ないか心配だという話を聞きました。

また、この段階でプロダクトは MVP であり、別の新規機能の追加が必要であることがユーザーインタビューを通じてわかっているため、そちらの機能開発を優先したいというフェーズでした。このため、エンジニアがフル稼働しており、管理画面を作る工数を捻出できなかったというチーム事情もありました。

解説パンダくん
解説パンダくん

トップページの書籍更新の頻度を増やしてコンテンツの拡充を周知することで、サブスクリプションの重要な指標である解約率を下げたいという話をチームでしていたので、なんとかしたい気持ちは山々だったんだよ。

ISRを利用することでプロダクトの課題を解決できる

そのような要望が上がり始めたあたりで、Next.js 9.5 のリリースが発表されました。9.5 で実装された ISR の機能と GAS を組み合わせると、書籍の更新フローを PO だけで完結できると考えました。

これで PO も更新頻度を気にせず、自由なタイミングで書籍一覧を更新できます。あとは staging 用、production 用のスプレッドシートを用意し、GASを記述するだけです。

弁護士ドットコムライブラリーは定額の読み放題サービスであり、コンテンツが増えれば増えるほどユーザーにとってお得になるため、新着書籍のお知らせの更新頻度が多いことはユーザーにとって嬉しいはずです。また、PO は自分で反映を確認できる上にエンジニアのタスクを減らせて、まさに「三方よし」です。

ISR はまさにチームが求めていた機能でした。

解説パンダくん
解説パンダくん

ちなみに ISR は [Vercel 以外でも `next start` が実行できるなら使える](https://nextjs.org/blog/next-9-5#stable-incremental-static-regeneration)んだよ。現在、業務では ECS を使っており、Next.js を実行している Node コンテナで ISR の機能を使っているよ。

なお、今回 SSR を使わなかったのは、 GAS が SpreadSheet を呼び出す実行速度が早くないため、トップページのレスポンス速度が遅くなることを懸念したためです。

ISRでのデータ取得と書籍のReactコンポーネントを紹介します

スプレッドシートで管理するデータとJSONの形式

スプレッドシートで管理する書籍データは以下のようなものです。

見出しGAイベント名書籍ID書籍タイトル著者名出版年サムネイル画像
民法civilLawxxx書籍XXXXX2020年XXX.png
民法civilLawyyy書籍YYYYY2020年YYY.png
一般民事civilCasezzz書籍ZZZZZ2020年ZZZ.jpg

スプレッドシートのデータ

これらのデータをトップページで利用します。GASで作成したAPIはスプレッドシートのデータをそのままJSONで返却します。

[
  {
    "heading": "民法",
    "event_name": "civilLaw",
    "id": "xxx",
    "title": "書籍X",
    "author": "XXXX",
    "published_at": "2020年",
    "thumbnail_url": "XXX.png",
  },
  {
    "heading": "民法",
    "event_name": "civilLaw",
    "id": "yyy",
    "title": "書籍Y",
    "author": "YYYY",
    "published_at": "2020年",
    "thumbnail_url": "YYY.png",
  },
  {
    "heading": "一般民事",
    "event_name": "civilCase",
    "id": "zzz",
    "title": "書籍Z",
    "author": "ZZZZ",
    "published_at": "2020年",
    "thumbnail_url": "ZZZ.jpg",
  },
  // ...
]

トップページでISRを利用して5分ごとにGASのAPIをコールする

ISR の機能を使うため、getStaticPropsで GAS のエンドポイントにリクエストを送り、返ってきた上記のJSONをドメインで使う型にマッピングします。

page/index.tsx
import { GetStaticProps, NextPage } from 'next'
import * as R from 'ramda'
import React from 'react'

import { BookListPartLabel } from '~/src/types/domain/googleAnalyticsEvents/Labels'

// ドメインで利用する型
type BookListItem = {
  id: string
  title: string
  author: string
  publishedAt: string
  thumbnailUrl: string
}

type Props = {
  bookGroups: {
    heading: string
    books: BookListItem[]
    part: BookListPartLabel // GA イベント名
  }[]
}

// トップページの表示用のコンポーネント
// 後述します
// const Component: NextPage<Props> = (props) => ( )

// API のレスポンスボディの型
type TopPageBooksBody = {
  heading: string
  event_name: string
  id: string
  title: string
  author: string
  published_at: string
  thumbnail_url: string
}[]

export const getStaticProps: GetStaticProps<Props> = async () => {
  // GOOGLE_APPS_SCRIPT_TOP_PAGE_BOOKS は GAS の API の URL
  const endpoint = GOOGLE_APPS_SCRIPT_TOP_PAGE_BOOKS
  const authKey = process.env.AUTH_KEY
  // プログラムから GAS の API をコールするためには、オプションとして { redirect : 'follow' } が必須
  const res = await fetch(`${GOOGLE_APPS_SCRIPT_TOP_PAGE_BOOKS}?auth_key=${authKey}`, { redirect: 'follow' })
  const json: TopPageBooksBody = await res.json()

  // 変数 groups の中身は以下。ramda.js の groupBy 関数で heading が同じ書籍をまとめる
  // {
  //   '民法': [{ heading: '民法', event_name: 'pickup', ... }, {...}, ... ],
  //   '一般民事' : [{ heading: '一般民事', event_name: 'popular', ... }, {...}, ... ],
  //   // ...
  // }
  const groups = R.groupBy((book: TopPageBooksBody[number]) => book.heading)(json)
  const bookGroups = Object.entries(groups).map(([heading, bookGroup]) => ({
    heading,
    part: bookGroup[0].event_name
    books: bookGroup.map<BookListItem>((book) => ({
      id: book.id,
      title: book.title,
      author: book.author,
      publishedAt: book.published_at,
      thumbnailUrl: book.thumbnail_url,
    })),
  }))

  return {
    props: { bookGroups },
    revalidate: 300, // 5分単位で更新
  }
}

export default Component

書籍表示用のコンポーネントのコード

page/index.tsx
// 上記で省略した表示用のコンポーネントの中身
const Component: NextPage<Props> = (props) => (
  <>
    {/* Top Page */}
    <HeroComponent />

    {/* 書籍一覧 */}
    <section className={css['bookList']}>
      {props.bookGroups.map((group) => (
        // 以下はさらに小さい粒度でコンポーネントとして切り出していますが、
        // ここでは説明のためにコンポーネントとして切り出していない形で記述しています
        <div className={css['bookList__content']} key={group.heading}>
          <h2 className={css['bookList__heading']}>{group.heading}</h2>
          <ul className={css['bookList__list']}>
            {group.books.map((book, i) => (
              <li className={css['bookList__item']} key={i}>
                <Link href={`${PATH.SITE_BOOK}/${book.id}`}>
                  <a className={css['bookList__itemLink']}>
                    <BookListItem
                      part={group.part}
                      src={book.thumbnailUrl}
                      title={book.title}
                      author={book.author}
                      publishedAt={book.publishedAt}
                    />
                  </a>
                </Link>
              </li>
            ))}
          </ul>
        </div>
      ))}
    </section>
  </>
)

5分ごとに API をコールして、スプレッドシートに更新があればページを再ビルドするようにしています。

また、JSON をドメインで利用する型にマッピングするために、ramda.js の groupBy 関数を使っています。ramda.js は関数型プログラミングのスタイルのライブラリです。JS に組み込まれていない便利なロジックを数多く備えています。

groupBy 関数を使って、heading(見出し)ごとに書籍をグルーピングしています。

また、以下は page/index.tsx 内で利用している BookListItem コンポーネントで、書籍の表示を担っています。なお、next/image は利用していません。特に変わったところのない一般的なコンポーネントですが、 pages/index.tsx との整合性のために掲載しています。

BookListItem.tsx
import React from 'react'
import LazyLoad from 'react-lazyload'

import { event } from '~/src/lib/googleAnalytics/gtag'
import { BookListPartLabel } from '~/src/types/domain/googleAnalyticsEvents/Labels'

import css from './style.module.scss'

type Props = {
  part: BookListPartLabel
  src: string
  title: string
  author: string
  publishedAt: string
}

const Component: React.FC<Props> = (props) => (
  <button
    className={css['bookList__itemButton']}
    type="button"
    onClick={() =>
      // GA イベント
      event({
        action: 'click',
        category: 'book',
        label: { part: props.part, title: props.title },
      })
    }
  >
    <LazyLoad>
      <div className={css['bookList__itemCover']}>
        <img className={css['bookList__itemCoverImage']} src={props.src} alt={props.title} />
      </div>
    </LazyLoad>
    <div className={css['bookList__itemInformation']}>
      <p className={css['bookList__itemTitle']}>{props.title}</p>
      <p className={css['bookList__itemAuthor']}>{props.author}</p>
      <small className={css['bookList__itemPublication']}>{props.publishedAt}</small>
    </div>
  </button>
)

export default Component
書籍コンポーネントの表示

onClick のイベントハンドラで Google Analytics のイベントを発火させています。

関連記事: Next.jsでGoogle Analyticsを使えるようにする

これで ISR で5分ごとに API をコールし、スプレッドシートに変更があればページを再ビルドするトップページを作成できました🎉

スプレッドシートのデータをJSONで返却するGASのコード

最後に、スプレッドシートのデータを返却するコードを掲載します。GAS は clasp を使って TypeScript で記述し、デプロイしています。

関連記事: GASをclasp(CLIツール)+ TypeScriptでローカルで開発する

スプレッドシートの権限については、チーム内は編集権限、また社内のリンクを知っている人には閲覧権限を付与しています。

一方、API は全てのデータを返却するため、SSG、ISR でのリクエスト時に GET のパラメータでauth_keyを渡すようにします。SSG、ISR はサーバーからのリクエストなので、auth_key がユーザーに漏れることはありません。

なお、プロジェクト内で@types/google-apps-scriptを install しています。

main.ts
const AUTH_KEY = 'some_key'
const SHEET_ID = 'sheet_id'
const SHEET_NAME = '書籍一覧'

type Book = {
  heading: string
  event_name: string
  id: string,
  title: string,
  author: string,
  published_at: string,
  thumbnail_url: string,
}

type Books = Book[]

const doGet = (e) => {
  // GET のパラメータ
  const authKey = e.parameter.auth_key

  // 認証 key が一致しない場合
  if (!authKey || authKey !== AUTH_KEY) {
    // GAS ではレスポンスの status code を設定できないため、text を返却している
    return createText('401 unauthorized. Invalid auth_key.')
  }

  // スプレッドシートのデータを全て取得
  const rows = findAll(SHEET_ID)
  // API で返却する値に変換する
  const books = rows.map(row => array2Obj(row))
  // JSONにする
  return encode(books)
}

const findAll = (sheetId: string): string[][] => {
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(SHEET_NAME)
  const lastRow = sheet.getLastRow()

  return sheet.getRange(2, 1, lastRow - 1, 7).getValues()
}

const array2Obj = (array: string[]): Book => {
  // カラムごとの値に名前をつける
  const [heading, event_name, id, title, author, published_at, filename] = array
  return {
    heading,
    event_name,
    id,
    title,
    author,
    published_at,
    thumbnail_url: `/book/thumbnail/${id}/${filename}`
  }
}

const encode = (data: Books): GoogleAppsScript.Content.TextOutput => {
  const json = JSON.stringify(data)
  const output = ContentService.createTextOutput(json)
  return output.setMimeType(ContentService.MimeType.JSON)
}

const createText = (text): GoogleAppsScript.Content.TextOutput => {
  const output = ContentService.createTextOutput(text)
  return output.setMimeType(ContentService.MimeType.TEXT)
}

React Server Componentについて

この記事を執筆する数日前に React Server Components が発表されました。上記、 ISR で実現したことはまさに React Server Component のユースケースに合致しそうだなと思いました。

まとめ

今年は Next.js と Vercel 社にとって飛躍の年でした。Next.js は SSR の他にも SSG、ISR の機能追加や、 Dynamic Routing が実装されたり、Chrome チームと共同開発した Image コンポーネントや、Web Vitals アナリティクスの組み込み関数、i18n 対応のための機能など便利な機能を備えることで、React のフレームワークとしての地位を確固たるものにしています。10月には初の Next.js カンファレンスが開催されたことも記憶に新しいです。

また、Next.js を開発している Vercel 社は 6月に約20億円の調達を発表しましたが、12月にさらに約40億円を調達したそうです。調達した資金を使って、Web の開発体験をさらに発展させて欲しいですね。

Vercel 社が Web 開発者に大きな力を与える一方、Next.js や Vercel を利用する私たち(このブログは Vercel にデプロイしています)一般の開発者も情報を発信して周囲に広めたり、初学者の疑問に答えることで Next.js コミュニティを発展させていければと思います。

嬉しいことに、サーバーサイドエンジニアが多数在籍する弊社の新プロジェクトで Next.js の採用が決まったと聞きました。来年も引き続き Next.js を使い、情報発信をして、この素晴らしい OSS を応援していきたいと思います!

Next.jsパーカー

ちょうど数日前に、Next.js カンファレンスのパーカーが届きました!デベロップ・プレビュー・シップ!

今年も一年お疲れ様でした。またどこか、Next.jsに関するところでお会いしましょう😊

Next.jsReact
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Redux Toolkitとは、Reduxのエコシステムの集大成である

Redux ToolkitはReduxのエコシステムから選りすぐりの技術を集大成したライブラリ。単にReduxのボイラープレートを減らすだけのライブラリではない。

以下ではRedux Toolkitの構成要素となるライブラリの基本的な使い方を確認していく。注意して頂きたいのは、以下の記述はRedux Toolkitでの書き方ではない点だ(それなら公式ドキュメントをご覧いただくのが一番である)。

複雑なものに遭遇したときは常に基本に立ち返るのが一番だ。

reselect: Storeから値を取得する処理をメモ化する

reselectは関数をメモ化をする、1ファイル100行程度の薄いライブラリ。Storeから必要な値を取得するためのロジックを記述する。使い方はテストを見てもらうのが良い(memoized composite argumentsというテストがわかりやすい)。

メモ化した関数の引数に前回と同じ値を渡すと、その関数内の処理をスキップしてメモリから前回の計算結果を返してくれる。結果、どんなに重い処理をしている関数でも、実行時間はO(1)になる。

大体以下のような書き方になる。Storeから完了したTodoのみを取得する処理を例とする。

import { createSelector } from 'reselect'

const store = {
  todos: [
    { title: 'foo', isCompleted: true },
    { title: 'bar', isCompleted: false },
    { title: 'baz', isCompleted: true },
  ]
}

const todosSelector = (store: Store) => store.todos
const getDoneTodos = createSelector(
  todosSelector,
  (todos) => todos.filter(todo => todo.isCompleted)
)

store.todosの値が同じである限り、2度目以降はtodos.filter(todo => todo.isDone)というfilter処理は再実行されない、と理解している。

Reactコンポーネント内で以下のように書くと、再レンダリングされる度にfilterの O(n) の処理 が実行されるが、reselectだとメモ化されているためO(1)であるという認識だ。

const completedTodos = useSelector(
  state => state.todo.filter(todo => todo.isCompleted)
)

処理がfilterのみの場合はtodoが2億個ある場合でやっと恩恵があるかもしれないが、Storeからの値取得のロジックはともすると重くなりがちである。

Storeの一部の値が変わっていないのに再計算を毎回実行すると処理が重くなる。reselectは、その問題を回避するのに役立つ。

ちなみに「関数が重い」というとき、実行時間が長い場合とロジックが複雑であるという2つの意味がある。前者はreselectで対策できるが、後者は別の解決策が必要だ。

selector内のビジネスロジック、というかグローバルなStoreからフロントで利用する値に変換するドメインロジックが複雑になるという課題に対しては、仕様を調整するか、せめてSelectorのテストをしっかり書いておくのが良い。

なお、プレゼンテーションロジックはselectorの中に書くべきではない。Reactコンポーネントの中に書くべきだ。ViewModelのロジックをObjectMapperに書くとクリーンなコードにならず、保守性が悪化することは想像に難くない。

Immer: オブジェクトの更新をイミュータブルにする

JavaScriptオブジェクトをイミュータブルに扱えるFacebook製のライブラリ。Storeを更新するReducerと組み合わせて使う。ネストが深いオブジェクトの値を更新する際、ピンポイントで更新する値を指定できる。

いちいち{...store, foo: {...store.foo, bar: 'newValue' }}などと書いてられない。2階層目でこれなのだから、さらに深くなると先が思いやられる。これがいわゆる spread hell である。

Immerを使うと以下のように書ける。

const reducer = (draft: State = initialState, action: Action) => {
  switch (action.type) {
    case 'SOME_ACTION':
      draft.foo.bar = 'newValue'
      break
      // ...
    }
}

このReducerをuseImmerReducerというReact Hooksに渡す。

import { useImmerReducer } from 'use-immer'

const [state, dispatch] = useImmerReducer(reducer, initialState)

使い方はuseReducerと変わらない。しかし、注意して欲しいのは、useImmerReducerに渡すreducerは返り値を返さない点である。

(draftの中身をconsole.logで確認すると { draft: foo: { proxy: {} } } }のような形式になっていたが、内部処理を追っていないのでよくわからない。)

「reducerは純関数だ」と叩き込まれている身としては、reducerが返り値を返さない点、あたかも変数に(しかも関数の引数に!)値を再代入しているように見える書き方に最初は抵抗があった。

しかし、ピンポイントでStoreの値を更新できるので一度使ってみるとこれが便利なのだ。なお、内部では新しくオブジェクトが生成されている。これがイミュータブルなオブジェクトの更新と言われる所以である。

Redux Thunk: Reduxで非同期処理を扱う

Redux Thunkは非同期処理を扱うライブラリだ。Reduxを入れるなら必須であるといえる。ただ、もちろん非同期処理をしないフロントのアプリケーションには不要。また、Thunkを入れない場合はuseEffectの中でactionをdispatchする書き方になる(それも悪くない)。React開発者なら経験人数も多いため採用には困らない。

(2年前はReduxで非同期処理を扱うならRedux ThunkかRedux Sagaのどちらかという印象があったが、私はSaga経験者を採用市場で見かけたことがないので、新規で採用するには覚悟のいる技術だろう)

Redux ToolkitがRedux Thunkを組み込んだことにより、「Reduxで非同期処理ならThunk」というトレンドは今後も続くと見ている。

さて、Thunk自体の解説は日本語での記述も豊富なのでそちらを参照してもらうとして、ここでは所感を書く程度に留めたい。

なお、Reduxでは「Actionをdispatch → Storeを更新する」のに対して、Redux Thunkは「Async Actionをdispatch → 非同期処理 → Storeを更新する」という理解である。

Redux ThunkはReduxの世界で非同期処理を扱うライブラリである。この点を意識すると、Redux Toolkitでbuilder.addCase(asyncThunk.pending)といった一見奇怪な書き方がボイラープレートを減らしていることを理解できるだろう。

まず確認すべきことは、Reduxは「ReactやVue.jsといったフロントエンドのライブラリから独立した、状態管理のライブラリである」という点だ。

状態管理とは詰まるところ、Storeというグローバルなオブジェクトに保持した値の一群をアプリケーションの状態と見なすことだ。内部のアプリケーションの状態がどのようであれ、表示とは無関係なのだ。

非同期処理の中でも特にバックエンドへのリクエストを考えると、idle(リクエストを送る準備ができている状態)、pending(返却を待っている状態)、fulfilled(値が帰ってきた状態)、rejected(値の取得に失敗した状態)の4つに大別できる。

例えば、ボタンをクリックすると新着メッセージを取得するアプリケーションを想像して欲しい。

それぞれの状態をUIに対応させると、idleはボタンをクリックできることがわかる(disabledではない)、pendingはボタンがdisabledになると同時にローダーがぐるぐる回っている、fulfilledはボタンが再度クリックできるようになり、メッセージが表示される、rejectedは赤いトーストが表示されて、失敗の原因をユーザーに伝える。

これらのUIは1つの例である。fulfilledに緑のトーストで表示しても良い。状態は1つである一方、表現方法は多種多様だ。簡単に切り分けると、Reduxは前者、Reactは後者をJavaScriptで扱うライブラリなのである。

さて、状態と表示が分離されていることがわかったところで、接続のことを考えなければならない。非同期処理の状態をStoreに格納し、ReactコンポーネントがStoreの変更を検知して、状態に応じた表現をする。

Redux Thunkでは、非同期処理の状態に応じてStoreの値を変更するActionを、サーバーへのリクエストの数だけ記述しなければならなかった。つまり、エンドポイント × 3状態 の数だけStoreを更新するActionの記述が必要だった。

Redux Toolkitはそのボイラープレートを減らす書き方を用意している。それがbuilder.addCase(asyncThunk.pending)などだ(公式ドキュメントと合わせてcreateAsyncThunkのテストを読めば、理解が深まるはず)。

技術選定に当たって

Redux Toolkitに関する技術選定のポイントを簡単に記述する。覚書程度だが、幾分かでも参考になれば幸いだ。

  • Redux ToolkitはReduxのエコシステムの集大成
  • 小・中規模のアプリケーションには、学習コストが大きいため不向きかもしれない。
    • 開発速度、リリース時期、プロダクトのライフサイクル、チームのRedux経験者の数と実力など、その他の要素の方が導入検討の要素としては大きな意味を持つため、状況による
  • 公式ドキュメントにサンプルコードが多く掲載されているため、開発者間で記述にブレが少なくなるのはメリット
  • useEffectの中のロジックはasync actionに記述することになる
  • reselectが組み込まれているのでselectorのメモ化できるものの、プレゼンテーションロジックのメモ化は引き続きuseMemoを推奨
  • React QueryやSWRといったデータフェッチをするライブラリと相性はよくない(どちらを使うべきか迷う場面が出てくるはず。迷うなら最初から全部Redux Toolkitに寄せた方が無難)

ちなみに、React Suspenseは従来のFetch on Renderをしないようにする技術であるため、Redux Toolkit(Redux Thunk)を使う限り、Suspenseの書き方はできなそうだと懸念している。ただ、Suspenseが導入されたらデータフェッチのメンタルモデルが変わるため、それから考えてもいいかもしれない。なお、SuspenseはReactの話で、Reduxとは無関係だということを付記しておく。

なお、私は本業でNext.js + SWRで中規模のアプリケーションを開発しており、エンジニアのメンバーは2人。また、フロントエンドエンジニアが4人の副業でRedux Toolkitを触っているという前提を共有したい。

Redux Toolkitに対して少し控えめなのはポジショントーク。実際の導入検討に当たってはチームの状況と相談するのが良いだろう。

ReduxReactRedux Toolkit
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Vercel製のuseSWRはReactの非同期データ取得をラクにする

SWRとは、Next.jsを作成しているVercel製のライブラリです。**SWRはuseSWRというReact Hooksを提供し、APIを通じたデータの取得をラクに記述する手助けをしてくれます。**このライブラリはなんとGitHubスター数を10,700も獲得しています。

SWRはライブラリ名で、stale-while-revalidateというRFC 5861で策定されたキャッシュ戦略の略称です。このSWRがデータ取得の扱いをラクにしてくれて最高なのです。

React開発者が嬉しいuseSWRの書き心地

useSWRは外部APIからのデータ取得、ローディング状態、エラーが発生した時をシンプルに記述できます。これがあらゆるReact開発者にとって(というか、ReactでAPIにリクエストを頻繁に送るアプリケーションを実務で書いている自分が)とても嬉しいことです。

例えば、Suspenseを使わず、useEffectで取得したデータをuseStateに格納して利用するコードがあるとします。

Profile.jsx
const Profile = () => {
  const [profile, setProfile] = useState(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    (async () => {
      setLoading(true)

      const res = await fetch('/api/user')
      setProfile(await res.json())

      setLoading(false)
    })()
  }, [])

  if (profile === null || loading) {
    return <div>loading</div>
  }

  return <div>hello {profile.name}!</div>
}

記述量が多くて辛いですね。しかも通信エラーが起きた時のハンドリングをしていません。それでももうこの長さです。

しかし、useSWRを使うともっと短く記述できるんです。

import useSWR from 'swr'

const fetcher = () => fetch('/api/user')

function Profile() {
  const { data, error } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>

  return <div>hello {data.name}!</div>
}

**たったこれだけでローディング、通信エラー、データ取得の状態を表せます。**とってもシンプルなインターフェースです。

なお、useSWRの第一引数はキャッシュのキーです。第二引数は、データを取得する関数を返すPromiseです。

**嬉しいことに、useSWRは第二引数で与えたfetcherが一度取得したデータをクライアント側でキャッシュしてくれます。**これで、APIを通じて取得したデータをstoreに格納せずに済むのです。

useSWRによるキャッシュの流れ

useSWRがキャッシュからデータを取得する流れは以下です。

  • キャッシュからデータを返そうとする(Stale
  • キャッシュにデータがなければ、データを取得する
  • キャッシュにデータがあれば、再度データを取得してキャッシュを更新する(Revalidate

SWRは「stale-while-revalidate」の略でしたね。これだけを理解すればあなたはもうuseSWRを使いこなせること間違いなしです。

さらに嬉しいuseSWRの機能

useSWRはデフォルトでRevalidateに関する嬉しい機能を備えています。Revalidateは(データ取得+キャッシュ更新)を意味します。

  • ブラウザをクリックしたり、タブを移動して戻ってきたときにRevalidateする(Focus Revalidation)
  • 指定した時間ごとにRevalidateする(pollingができる)
  • mutate関数を使ってデータ更新時にキャッシュも更新できる(Local mutation)
  • 無限スクロールの場合、ページ遷移後もスクロール位置を保存する
  • エラー時にリトライしてくれる
  • タイムアウトの設定が簡単

よく使うオプションを紹介します

useSWRを使う際によく使うであろうオプションを紹介します。

プロパティ役割デフォルト値
initialDataデータfetch前に表示する初期データなし
revalidateOnFocuswindowのフォーカス時にRevalidateするtrue
revalidateOnReconnect通信が切れて復活したらRevalidateするtrue
refreshIntervalpollingの期間0(pollingしない)
focusThrottleInterval1度だけRevalidateする期間5000
loadingTimeoutタイムアウトする時間3000

エラー時の対応も柔軟に設定できます。

プロパティ役割デフォルト値
shouldRetryOnErrorエラー時にリトライするかtrue
errorRetryIntervalリトライのインターバル5000
errorRetryCountリトライするmaxの回数なし

useSWRの第三引数にオプションとして指定することで、Revalidateを柔軟に設定できます。

const { data, error } = useSWR('/api/user', fetcher, {
  initialData: { name: 'React Developer' }
})

全てのoptionを確認するにはSWRのREADMEをご覧ください。

useSWRはSSRで利用できる

useSWRはSSR(Sever Side Rendering)で利用できます。ただし、optionのinitialDataの指定をしないとバグの原因になります。

Next.jsのgetServerSidePropsというサーバーでしか実行されない関数と一緒に用いた例は以下の通りです。

export async function getServerSideProps() {
  const data = await fetcher('/api/data')
  return { props: { data } }
}

function App (props) {
  const initialData = props.data
  const { data } = useSWR('/api/data', fetcher, { initialData })

  return <div>{data}</div>
}

getServerSidePropsでNext.jsのビルド時にAPIからデータを取得し、それを初期データとして表示します。

しかし、ユーザーのプロフィールなどをユーザー自身が編集したときデータは更新されます。

クライアントでuseSWRを使うことで、バックエンドでデータ更新があった時もSWRがRevalidateしてくれるので、常に最新のデータをユーザーに表示できるのです。

useSWRはGraphQLに対応している

また、SWRはGraphQLにも対応しています。サンプルコードを掲載しておきます。

import { request } from 'graphql-request'
import useSWR from 'swr'

const API = 'https://api.graph.cool/simple/v1/movies'

function MovieActors() {
  const { data, error } = useSWR(
    `{
      Movie(title: "Inception") {
        actors {
          id
          name
        }
      }
    }`,
    (query) => request(API, query)
  )

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return data.Movie.actors
    .map((actor) => <li key={actor.id}>{actor.name}</li>)
}

SWRのコードを読んでキャッシュの仕組みを理解する

SWRのキャッシュの仕組みを知るために、実際にSWRのコードを読んでみましょう。index.tsのuseSWRの定義でcache.set(key, newData, false)とあり(322行目)、cacheにデータを格納していますね。

index.ts
function useSWR<Data = any, Error = any>(
  key: keyInterface,
  fn?: fetcherFn<Data>,
  config?: ConfigInterface<Data, Error>
): responseInterface<Data, Error> {
  // ...
  // start a revalidation
  const revalidate = useCallback(
    async (
      revalidateOpts: RevalidateOptionInterface = {}
    ): Promise<boolean> => {
      // ...
      cache.set(key, newData, false)
      cache.set(keyErr, undefined, false)
      // ...
      return true
    },
    [key]
  )
  // ...
  return useMemo(() => {
    // ...
  }, [revalidate])
}

このcacheを追ってみます。cacheはconfig.tsからimportされていますので、config.tsを見てます。

config.ts
import Cache from './cache'
// cache
const cache = new Cache()

export {
  // ...
  cache
}

そして、cache.tsには以下のようにCacheクラスの定義がありました。

cache.ts
export default class Cache implements CacheInterface {
  private __cache: Map<string, any>

  constructor(initialData: any = {}) {
    // Mapオブジェクトを生成している
    this.__cache = new Map(Object.entries(initialData))
  }

  get(key: keyInterface): any {
    const [_key] = this.serializeKey(key)
    return this.__cache.get(_key)
  }

  set(key: keyInterface, value: any, shouldNotify = true): any {
    const [_key] = this.serializeKey(key)
    this.__cache.set(_key, value)
    if (shouldNotify) mutate(key, value, false)
    this.notify()
  }

  keys() {
    return Array.from(this.__cache.keys())
  }

  serializeKey(key: keyInterface): [string, any, string] {
    // keyが関数や配列の時にシリアライズする処理
    return [key, args, errorKey]
  }
}

コンストラクタでthis.__cache = new Map(Object.entries(initialData))と定義されていますね。これがSWRのキャッシュの正体です。

Mapとは、key/Valueのデータ構造で、HashやHashMapとも呼ばれます。値はキーに対して、常に一意に決まります。

「useSWRがクライアントでキャッシュする」という意味は、ブラウザで実行するJavaScriptがメモリ上に揮発性のデータを確保するということです。

まとめ

いかがでしたでしょうか。useSWRはReactでデータフェッチを楽に扱える強力なHooksです。

実務ではFetchしてきたデータをstoreに格納していますが、SWRを使ってクライアントキャッシュに格納するように書き換えています。

SWRはVercel製ということもあり、そのシンプルなインターフェースはVercelのプロダクト「Vercel」や「Next.js」に共通しているZero Configを彷彿とさせるものです。

ぜひ本番環境でも使ってみてくださいね。

ReactNext.js
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

自宅と会社のパソコンでキー配列を一致させたかったので忘れないようにメモ。

キー設定をエクスポートする方法

Karabiner-Elementsでは設定ファイルはjson形式で管理されている。 下記のファイルを設定を反映させたいパソコンに送る。

$ ~/.config/karabiner/karabiner.json

オートメーションリロード機構 Karabiner-Elementsは〜/ .config / karabiner / karabiner.jsonを監視し、更新されたらそれをリロードします。 メカニズムは、Karabiner-ElementsがファイルシステムイベントAPIを使用して親ディレクトリ〜/ .config /karabinerを監視するというものです。

出典:karabiner.jsonリファレンスマニュアル

Karabiner-Elementsに設定をインポートする方法

反映させる側のパソコンでkarabiner.jsonを先ほどのディレクトリに格納。

$ ~/.config/karabiner/karabiner.json

すでにファイルが作成されている場合は、そのファイルを上書きする。 ファイルシステムイベントAPIがkarabinerディレクトリを監視しているので、ファイルを置くと設定は即時反映される

私の設定(karabiner.json)

私はHHKBのJIS配列を使っています。参考までに設定ファイルkarabiner.jsonを記載します。

karabiner.json
{
    "global": {
        "check_for_updates_on_startup": true,
        "show_in_menu_bar": true,
        "show_profile_name_in_menu_bar": false
    },
    "profiles": [
        {
            "complex_modifications": {
                "parameters": {
                    "basic.simultaneous_threshold_milliseconds": 50,
                    "basic.to_delayed_action_delay_milliseconds": 500,
                    "basic.to_if_alone_timeout_milliseconds": 1000,
                    "basic.to_if_held_down_threshold_milliseconds": 500,
                    "mouse_motion_to_scroll.speed": 100
                },
                "rules": []
            },
            "devices": [
                {
                    "disable_built_in_keyboard_if_exists": false,
                    "fn_function_keys": [],
                    "identifiers": {
                        "is_keyboard": true,
                        "is_pointing_device": false,
                        "product_id": 3,
                        "vendor_id": 11240
                    },
                    "ignore": false,
                    "manipulate_caps_lock_led": false,
                    "simple_modifications": [
                        {
                            "from": {
                                "key_code": "delete_forward"
                            },
                            "to": {
                                "key_code": "delete_or_backspace"
                            }
                        },
                        {
                            "from": {
                                "key_code": "f1"
                            },
                            "to": {
                                "consumer_key_code": "display_brightness_decrement"
                            }
                        },
                        {
                            "from": {
                                "key_code": "f2"
                            },
                            "to": {
                                "consumer_key_code": "display_brightness_increment"
                            }
                        },
                        {
                            "from": {
                                "key_code": "f10"
                            },
                            "to": {
                                "consumer_key_code": "mute"
                            }
                        },
                        {
                            "from": {
                                "key_code": "f11"
                            },
                            "to": {
                                "consumer_key_code": "volume_decrement"
                            }
                        },
                        {
                            "from": {
                                "key_code": "f12"
                            },
                            "to": {
                                "consumer_key_code": "volume_increment"
                            }
                        },
                        {
                            "from": {
                                "key_code": "insert"
                            },
                            "to": {
                                "key_code": "delete_or_backspace"
                            }
                        },
                        {
                            "from": {
                                "key_code": "japanese_pc_katakana"
                            },
                            "to": {
                                "key_code": "right_command"
                            }
                        },
                        {
                            "from": {
                                "key_code": "japanese_pc_nfer"
                            },
                            "to": {
                                "key_code": "japanese_eisuu"
                            }
                        },
                        {
                            "from": {
                                "key_code": "japanese_pc_xfer"
                            },
                            "to": {
                                "key_code": "japanese_kana"
                            }
                        }
                    ]
                },
                {
                    "disable_built_in_keyboard_if_exists": false,
                    "fn_function_keys": [],
                    "identifiers": {
                        "is_keyboard": true,
                        "is_pointing_device": false,
                        "product_id": 13,
                        "vendor_id": 1278
                    },
                    "ignore": false,
                    "manipulate_caps_lock_led": false,
                    "simple_modifications": [
                        {
                            "from": {
                                "key_code": "caps_lock"
                            },
                            "to": {
                                "key_code": "left_control"
                            }
                        },
                        {
                            "from": {
                                "key_code": "international2"
                            },
                            "to": {
                                "key_code": "right_command"
                            }
                        },
                        {
                            "from": {
                                "key_code": "international4"
                            },
                            "to": {
                                "key_code": "japanese_kana"
                            }
                        },
                        {
                            "from": {
                                "key_code": "international5"
                            },
                            "to": {
                                "key_code": "japanese_eisuu"
                            }
                        },
                        {
                            "from": {
                                "key_code": "left_alt"
                            },
                            "to": {
                                "key_code": "left_command"
                            }
                        },
                        {
                            "from": {
                                "key_code": "left_control"
                            },
                            "to": {
                                "key_code": "caps_lock"
                            }
                        },
                        {
                            "from": {
                                "key_code": "left_gui"
                            },
                            "to": {
                                "key_code": "left_option"
                            }
                        }
                    ]
                }
            ],
            "fn_function_keys": [
                {
                    "from": {
                        "key_code": "f1"
                    },
                    "to": {
                        "consumer_key_code": "display_brightness_decrement"
                    }
                },
                {
                    "from": {
                        "key_code": "f2"
                    },
                    "to": {
                        "consumer_key_code": "display_brightness_increment"
                    }
                },
                {
                    "from": {
                        "key_code": "f3"
                    },
                    "to": {
                        "key_code": "mission_control"
                    }
                },
                {
                    "from": {
                        "key_code": "f4"
                    },
                    "to": {
                        "key_code": "launchpad"
                    }
                },
                {
                    "from": {
                        "key_code": "f5"
                    },
                    "to": {
                        "key_code": "illumination_decrement"
                    }
                },
                {
                    "from": {
                        "key_code": "f6"
                    },
                    "to": {
                        "key_code": "illumination_increment"
                    }
                },
                {
                    "from": {
                        "key_code": "f7"
                    },
                    "to": {
                        "consumer_key_code": "rewind"
                    }
                },
                {
                    "from": {
                        "key_code": "f8"
                    },
                    "to": {
                        "consumer_key_code": "play_or_pause"
                    }
                },
                {
                    "from": {
                        "key_code": "f9"
                    },
                    "to": {
                        "consumer_key_code": "fastforward"
                    }
                },
                {
                    "from": {
                        "key_code": "f10"
                    },
                    "to": {
                        "consumer_key_code": "mute"
                    }
                },
                {
                    "from": {
                        "key_code": "f11"
                    },
                    "to": {
                        "consumer_key_code": "volume_decrement"
                    }
                },
                {
                    "from": {
                        "key_code": "f12"
                    },
                    "to": {
                        "consumer_key_code": "volume_increment"
                    }
                }
            ],
            "name": "Default profile",
            "parameters": {
                "delay_milliseconds_before_open_device": 1000
            },
            "selected": true,
            "simple_modifications": [],
            "virtual_hid_keyboard": {
                "country_code": 0,
                "mouse_key_xy_scale": 100
            }
        }
    ]
}
Tips
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

「みんなのデータ構造」でプログラミングで使うデータ構造を学ぶ

「みんなのデータ構造」(Amazonリンク)とは、コンピュータ・サイエンスの基礎となるデータ構造の教科書「Open Data Structure」の日本語訳です。Introduction to Algorithmsといったアルゴリズムの名著への橋渡しになるような、実用的なテーマが丁寧に説明されています。

この本でデータ構造を学ぶ意義は、訳者まえがきで以下のように説かれています。

  1. ソフトウェアのほとんどはシンプルなデータ構造の組み合わせでできている。
  2. 「みんなのデータ構造の内容がだいたいわかれば、いいエンジニアになれる。

また、わからない部分は読み飛ばしていいとも書かれています。さらに嬉しいことに、この書籍の中でも実務や学術研究で頻繁に登場する内容がピックアップされています。

書籍のサンプルコードはC++ですが、何か1つプログラミング言語を知っていれば問題なく読み進めることができます。

解説パンダくん
解説パンダくん

弁護士ドットコムでは、エンジニア有志で本書の輪読会をしています。この本の内容をマスターして、競技プログラミングに挑戦するぞ!

ソートアルゴリズム

データ構造の本であるが、整列アルゴリズムを紹介することには意義がある。例えば、BinaryHeapを使って要素を全てadd(x)し、remove()すれば順番に要素を取り出せる。しかし、BinaryHeapの限られた機能しか使っていないし、メモリ効率も良くない。シンプルに整列をするアルゴリズムを考えることには意義がある。

この節では、以下の3つの整列アルゴリズムを紹介する。

  • マージソート
  • クイックソート
  • ヒープソート

配列aを入力すると、いずれも比較による整列であり、 O(nlogn)O(n\log n) の期待実行時間でaの要素を昇順にソートする。

なお値を比較するメソッドcompare(a, b)は、以下のように振る舞うものとする。

  • a < b なら -1 を返す
  • a > b なら 1 を返す
  • a = b なら 0 を返す

マージソート

マージソートは、再帰的な分割統治法の例として古典的なアルゴリズムである。配列aを半分ずつに分け、その配列a0, a1を再帰的に整列し、a0, a1を併合することで、整列済みの配列aを得る。

定理: mergeSort(a)の実行時間は O(nlogn)O(n\log n) であり、最大で nlognn\log n 回の比較を行う

void mergeSort(array<T> &a) {
	if (a.length <= 1) return; // 要素数が1なら整列済み
	array<T> a0(0);
	array<T>::copyOfRange(a0, a, 0, a.length/2); // a0にaのindexが0からn/2までを割り当てる
	array<T> a1(0);
	array<T>::copyOfRange(a1, a, a.length/2, a.length);
	mergeSort(a0);
	mergeSort(a1);
	merge(a0, a1, a); // a0とa1を併合して、aに格納する
}

a0, a1の併合は、aに要素を1つずつ加えていけばいい。aに追加する要素は、a0かa1の小さい方である。

void merge(array<T> &a0, array<T> &a1, array<T> &a) {
	int i0 = 0, i1 = 0; // a0, a1のindex
	for (int i = 0; i < a.length; i++) {
		if (i0 == a0.length) // i0がa0の長さと同じとき
			a[i] = a1[i1++];
		else if (i1 == a1.length) // i1がa1の長さと同じとき
			a[i] = a0[i0++];
		else if (compare(a0[i0], a1[i1]) < 0) // a0[i0] < a1[i1] のとき
			a[i] = a0[i0++];
		else  // a0[i0] >= a1[i1] のとき
			a[i] = a1[i1++];
	}
}

クイックソート

クイックソートはマージソートと異なり、事前に全ての処理を済ませる。クイックソートでは、配列aからランダムにx(軸, pivot)を選ぶ。そして、xより小さい要素、同じ要素、大きい要素の3つにaを分割する。そして、分割の1つめと3つ目を再帰的に整列する。

定理: quickSort(a)の実行時間の期待値は O(nlogn)O(n\log n) である。また、実行される比較の回数の期待値は2n ln n+ O(n)O(n) 以下である

void quickSort(array<T> &a) {
	quickSort(a, 0, a.length);
}
void quickSort(array<T> &a, int i, int n) {
	if (n <= 1) return; // 要素1は整列済み
	T x = a[i + rand() % n];
	int p = i-1, j = i, q = i+n; // pは増加、qは減少していく

	while (j < q) {
		int comp = compare(a[j], x); // ランダムに抽出したxとa[i]と比較する
		if (comp < 0) {
			a.swap(j++, ++p); // 配列の前方に移す
		} else if (comp > 0) {
			a.swap(j, --q); // 配列の後方に移す
		} else {
			j++;
		}
	}

	quickSort(a, i, p-i+1);
	quickSort(a, q, n-(q-i));
}

クイックソートは、入力配列a以外にはどの時点でも定数個の変数しか使わない。省メモリなソートアルゴリズム。ランダム二分探索木と深い関係がある。

Algorithmみんなのデータ構造
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer