kasuのブログ

勉強していく

React Router v6.4 で v5 から脱出したい

React Router v6 がリリースされてから凡そ半年が立ちました。

そして、v6.4 の足音が近づいてきています。 まだ v5 を利用している方は、これを機にバージョンを上げてみませんか?

上げたくても上げられない

もちろん訳あって上げられない場合もあるかと思います。

私が参加しているプロジェクトでも、流行に遅れてつい先日まで v5 でした。 なぜなら、v6 には preload API がなかったのです。*1

これは、Render-as-you-fetch パターンを導入しているアプリケーションにとって v6 バージョンアップへのハードルを上げていました。

v5 では、以下のように Route コンポーネントが持つ render prop*2 からデータ取得(Relay の loadQuery など)を挟んだ関数を渡すことができました。

<Route path="/home" render={() => {
  const queryRef = loadQuery(environment, homeQuery, ...)
  return <Home queryReference={queryRef} ... />
  }} />

そして v6.4 では、これを解決した loader prop がやってきます。 それを提供してくれるのが、DataBrowserRouter です。

現時点(2022/06/19)ではまだ beta ですが、公式ドキュメントが用意されているので見てみてください。 Data Quick Start | React Router

以下のように、loader prop に渡した関数が返すデータを useLoaderData フックを使って取得することができます。

// routing
  ...
  <Route element={App} loader={getAppData} />

// App.tsx
  const data = useLoaderData()
  return <div>{data.name}</div>

はい簡単ですね。ではサクッと v5 から脱出してしまいましょう。

React Router v6.4 と遷移アニメーション

しかし、然うは問屋が卸さないのがメジャーバージョンアップの作業です。

例として、v5 の Switch コンポーネントと framer-motion を組み合わせて、ルーティング遷移にアニメーションを実装している場合を考えてみます。 (もちろん render func でデータ取得も行っているとします)

<AnimatePresence>
  <Switch location={location} key={...} >
    <Route path="/" ... />
    <Route path="/:id" ... />
  </Switch>
</AnimatePresence>

さて、これを v6 用に直すと以下のように書けます。

<AnimatePresence>
  <Routes location={location} key={...} >
    <Route path="" ... />
    <Route path=":id" ... />
  </Routes>
</AnimatePresence>

Switch を Routes に置き換えるだけです。はい簡単ですね。

しかし、DataBrowserRouter は Route コンポーネントしか受け付けていません。そうです、Routesコンポーネントが使えないのです。 Switch を使ってアニメーションを行っていた場合は Route コンポーネントのみで実装する必要があるのです。

ここで、React Router v6 で追加された Outlet という機能が生きてきます。これを使うと、コンポーネントとルーティングの実装を分離させることができます。

先程 v6 用に書き直したものをさらに DataBrowserRouter とアニメーションを組み合わせた、v6.4 向けに書き直してみます。

// routing
<Route path="*" element={Wrapper} >
  <Route path="" element={...} />
  <Route path=":id" element={...} />
</Route>

// Wrapper.tsx
const Wrapper = () => {
const outlet = useOutlet()
return (
<AnimatePresence>
  <div key={location.pathname}>
    {outlet}
  </div>
</AnimatePresence>

上記のように、useOutlet が返すものを div や Suspense で囲って location.pathname を key に渡してあげると、 ルーティング遷移のタイミングでアニメーションを行うことができます。

注意

location が変化すると、ルーティング定義に応じて loader prop も変わるので useLoaderData が返す値も変化してしまいます。 そのため、遷移アニメーションでは旧ページが unmount されるまで useLoaderData が返す値をキャッシュしておく必要があります。

次のデモアプリでは useLoaderData を wrap した、カスタム hook を用意しました。*3

デモ

上記の実装を用いて簡単なアニメーション付きのアプリを作ってみたので参考にしてみてください。

実際のリポジトリこちら

さあ、あなたも React Router v5 から脱出しませんか?

参考資料

Remix & Cloudflare Pages で KV を触る

作業ログです。

Remix のアプリを Cloudflare Pages にデプロイして KV にアクセスするまでをやってみました。

github.com

結果としては、id:leader22 さんの記事*1と同じところにハマりました。 こちらの記事も参考になるかと思います。

違いはTypeScript用に型定義を用意したくらいでしょうか。

create-remix

とりあえず、脳死create-remixしていきます。

> npx create-remix@latest`
> Just the basics
> Cloudflare Pages
> TypeScript
> Run npm install ? > n

yarn を使いたいのでnpm installは断っておきます。

さて、テンプレートが展開されて完成しました。よかったですね。

とはいえ、このままyarn devyarn buildを行うとNo matches found: "dev:*"のように怒られてしまいますので修正しておきます。

- "build": "run-s build:*",
+ "build": "run-s 'build:*'",
- "dev": "remix build && run-p dev:*",
+ "dev": "remix build && run-p 'dev:*'",

azu on Twitter: "No matches found: "dev:*"ってなるのなんでだろな。 npm-run-all -p dev:api dev:worker みたいな個別指定ならいけるな。 * がどこかで展開されてるのかな?"

KV access in Pages Functions

間違ったやり方

私が躓いた、Cloudflare Pages にとって間違っていた方法です。 「remix cloudflare kv」などで調べると、その多くが Cloudflare Workers での実装なのでそのまま写すと動いてくれません。

Remix | Data Loading

Remix のドキュメントでは KV の名前をそのまま呼べると書かれており、加えてそれ用の型定義を宣言しておくことをオススメされます。

例えば KV の名前がGOOD_KVNAMEだとすると、実装イメージは以下のような感じです。

// global.d.ts
declare const GOOD_KVNAME: KVNamespace

// app/routes/index.tsx
export const loader: LoaderFunction = async () => {
  const cached = await GOOD_KVNAME.get<Cache>('cache-key', 'json')
  ...
}

RemixをCloudflare Workersで動かす & KVでデータをキャッシュする

しかし、これらは Cloudflare Pages の環境では動きません。ではどうしたら良いのでしょうか?

正しいやり方

まずはローカルで実行するために、package.jsonにあるdevスクリプトに手を加えます。

https://developers.cloudflare.com/pages/platform/functions/#develop-and-preview-locally

ドキュメントに従って、静的アセットのディレクトリを指定した後ろにKV_NAMESPACEを渡します。

package.json
- "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public",
+ "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public -k GOOD_KVNAME",

ローカルでの準備が終わったので、yarn devで開発環境を立ち上げてみます。すると、以下のようなエラーが返ってきます。

You must use the 2nd `env` parameter passed to exported handlers/Durable Object constructors, or `context.env` with Pages Functions.

どうやら、Pages Functions から KVNamespace を指定するには context.env から取得する必要があるようです。

プロジェクトルートにある、テンプレートから生成されたserver.jsを見てみると以下のような実装になっています。 この実装から、ルーティングファイルにあるloaderに渡ってくるリクエストのcontextcontext.envの値が入っているのが分かります。

// server.js
const handleRequest = createPagesFunctionHandler({
  build,
  mode: process.env.NODE_ENV,
  getLoadContext: context => context.env,
})

つまり、ルーティングファイルのloaderに渡ってくる context から、以下のように呼び出すことが出来ます。

const loader: LoaderFunction = async ({context}) => {
  const kv = context.GOOD_KVNAME as KVNamespace
  ...
}

とはいえ、context: anyを無理やりKVNamespaceへキャストするのもムズムズするので型定義を用意します。

// global.d.ts
import type {AppData, DataFunctionArgs} from '@remix-run/cloudflare'

interface LoaderFunctionArgs extends Omit<DataFunctionArgs, 'context'> {
  context: {
    GOOD_NAMESPACE: KVNamespace
  }
}

type LoaderFunctionResult = Promise<Response> | Response | Promise<AppData> | AppData

declare global {
  type LoaderFunction = (args: LoaderFunctionArgs) => LoaderFunctionResult
}

型定義を用意したことで、キャストの必要がなくなり以下のような実装になりました。

// app/routes/index.tsx
import {useLoaderData} from '@remix-run/react'

type Cache = {
  a: number
}

type LoaderData = {
  pageCache: Cache
}

export const loader: LoaderFunction = async ({context}): Promise<LoaderData> => {
  const kv = context.GOOD_KVNAME
  const cached = await kv.get<Cache>('cache-key', 'json')
  const updateCache = {...cached, a: (cached?.a ?? 0) + 1}
  await kv.set('cache-key', updateCache)
  return updateCache
}

以上でとりあえずは動くようになりました。

やっと開発が始められます。やったね。

cdkで定義したDynamoDBのテーブルをDynamoDBLocalに作る

この記事はPRを含みます

cdkを使っている場合のDynamoDBLocalには、どのようにテーブルを作ってますか?

皆さんは、アプリケーションの構成をcdkによって定義している際のローカル開発環境のDynamoDBのテーブル作成はどうしていますか。

LocalStack - A fully functional local cloud stack

localstackを用いた、cdk deployによってローカル開発環境のDynamoDBを用意している人も多いかと思います。

しかし、localstackはDynamoDBを使うだけなのに起動サービスにcloudformationを必要としていたりと、DynamoDBだけ欲しいんじゃーというかたには大げさではないでしょうか。

amazon/dynamodb-local

localstack以外でローカル環境にDynamoDBを用意する方法としては、Dockerイメージのamazon/dynamodb-localを使うのが一般的でしょうか。*1

dynamodb-localaws dynamodbコマンドで操作が出来ます。

例えばテーブルを作成するには、テーブル定義を書いたtable.jsonを用意してaws dynamodb create-table --cli-input-json file://table.jsonとコマンドを実行します。 以後紹介する方法はこれを利用してテーブルを作成することになります。

cdk synth(synthesize)

aws dynamodbでテーブルを作成するにはjsonファイルが必要になります。

そこで、cdk synthを実行してみましょう。 すると、cdk.outディレクトリへDynamoDBのテーブル定義が吐き出されます。

このファイル自体はCloudFormationテンプレートなので、ブログタイトルのcdkで定義したテーブルというよりはCloudFormationテンプレートのテーブル定義からDynamoDBLocalにテーブルを作成する方法と言った方が正しいかもしれません。

とはいえ、cdk synsthした結果をDynamoDBLocalのテーブルを作成するにはどんな手段があるのか見ていきましょう。

cloudformation-dynamodb-export · PyPI

これはPythonで書かれたスクリプトです。使用する際は、pipでインストールします。 最後に紹介する方法に変更する前はこちらを利用していました。

CloudFormationの定義からDynamoDB localのTableを作成する

こちらはRuby製のスクリプトです。MacならRubyが既に入っているので、そのまま使えそうですね。

npm scriptsで完結させる

いろいろ方法はありましたが、せっかくaws-cdkをnpmで管理しているので、npm完結したいですよね?

なにか良さげなパッケージはないのでしょうか?

ここに良さげなパッケージがあるので、こちらを使いましょう。

ErgoFriend/cf-to-dynamodb-schema

www.npmjs.com

実際にaws-cdkを使った/exampleフォルダがあるのでそこを見るとよくわかると思います。

使う際には、下記のようにcdk synthした結果のCloudFormationテンプレートを指定すると、ローカル環境のDynamoDBにテーブルが出来上がります。

cf-to-dynamodb-schema create-table ./cdk.out/SampleStack.template.json -e http://localhost:8000 -p c2dexample

*1:serverless-dynamodb-localもあると思いますが、今回はcdkをメインとしたいのでおいておきましょう。

TypeScript: 渡した値が返ることを保証したい

メモです

例えばstyle要素のように、渡せる値が限られているプロパティに動的な値を渡したいとします。

このとき、渡す型を明示しないで戻り値の型を判別可能な関数を用意します。 イメージとしては以下のような型を持つpositionに、indexに応じて動的に値を渡すことになります。

type Position = "absolute" | "static"
type Top = number
type Style = {position: Position; top: Top;}
const style: Style = {
  // error: Type 'number | "static"' is not assignable to type 'Position'.
  position: getStyle(index, {first: 100, second: "static"}),
  // ok
  top: getStyle(index, {first: 100, second: 50}),
}

上記の例では、getStyle()にはpositionとtopが取りうるPosition,Topどちらもを渡すことができますが、positionプロパティはPosition型のみを受け入れるのでgetStyleへ渡した数値100が返る可能性があるとエラーで教えてくれます。

1. Generics

TypeScript: Documentation - Generics

最初に思いついたGenericsを下記のように使ってみます。

const getStyle = <T extends StyleValue>(index, style: { first: T; second: T; }): T => { .. }
const style: Style = { position: getStyle(0, { first: "static", second: 0 })}
> Type 'number' is not assignable to type '"static"'.

もちろんgetStyle<Position | Top>(...)とすれば可能ですが、今回は渡した値によって返す型を決めたいのでこの方法は取りません。

2. プロパティごとにGenericsを用意するする

TypeScript: Documentation - Generics

Genericsは複数の型を渡せるので、それぞれのプロパティに用意してみます。

const getStyle = <A extends StyleValue, B extends StyleValue>(index,
  style: { first: A; second: B; }): A | B => { ... }

戻り値をUnion型で指定できるのでよさそうです。 しかしstyleへ渡すプロパティが増えていくと、A|B|C|D|...冗長化しそうです。

3. Indexed Access Types

TypeScript: Documentation - Indexed Access Types

冗長化を防ぐ方法として、Indexed Access Typesというものがあります。 これはその型のプロパティを呼ぶことで、その型を入手することが出来ます。 type User = { id: number; name: string; }という型を定義すると、User["id"]でidの型であるnumberを取得できます。 また、keyof UserでUser型が持つプロパティ名のUnion型を得ることが出来ます。それをUser型に渡すと以下ようになります。

User[keyof User] ≒ User["id"|"name"] ≒ User["id"] | User["name"] ≒ number | string
type StyleMap = {
  first: StyleValue;
  second: StyleValue;
};
const getStyle = <T extends StyleMap>(index, style: T): T[keyof StyleMap] => { ... };

プロパティの数だけ宣言するよりもスッキリました。 Indexed Access Typesでも、渡したstyleの各プロパティが持つ値のUnion型を返すことが出来ているのでよさそうです。

使っているnpmパッケージの名前が変わってた

依存パッケージのアップデートはしていますか?

フロントエンドの開発を行っていく中で依存パッケージのアップデートは欠かせないものだと思います。

例えば、以下のようなnpm updateyarn upgradenpm-check-updatesコマンドラインから実行したり、GitHub Appで自動化するならRenovate や Dependabot を使っているのではないでしょうか。

今回は、4ヶ月もアップデートから取り残されていたパッケージを見つけたお話です。

パッケージ名が変わっていた

今回は開発中にreact-use-gestureのドキュメントを読みに行く際に、パッケージ名前が@use-gesture/reactへと変更していたことに気が付きました。

名前が変更された後の最初の安定版10.0.0が4ヶ月前にリリースされていたようです。

パッケージ名の変更を知る

一般的にnpmではパッケージが非推奨になるとdeprecatedが付きます。

今回パッケージ名が変更されていた react-use-gesture では、以下のように表示されています。

This package has been deprecated
Author message:

This package is no longer maintained. Please use @use-gesture/react instead

最近更新されていないパッケージを察してnpmレジストリのページへ毎回見に行くわけにもいきません。 では、どのような方法で非推奨かを知ると良いのでしょうか?

パッケージの情報をみる

Changelogや噂を追ってない限りは、 npm レジストリに対してパッケージの情報を問い合わせて非推奨かどうかを確認するしかないようです。

❯ npm info react-use-gesture | grep -i deprecated
DEPRECATED ⚠️  - This package is no longer maintained. Please use @use-gesture/react instead

❯ yarn info react-use-gesture | grep deprecated
  deprecated: 'This package is no longer maintained. Please use @use-gesture/react instead'

jq

会社のSlackで聞いた際に、id:utgwkk が教えてくださったissueには jq を駆使して グラフィカルに表示してくれるスクリプトがありました。 npmコマンドを叩いて、使用しているパッケージが非推奨かをビジュアライズしてくれます。

github.com

check-is-deprecated

これも内部的にはnpmコマンドを叩いているようです。 package.jsonのファイル名指定すると、非推奨のパッケージを検出してくれます。

github.com

❯ check-is-deprecated -f package.json
react-use-gesture:
✖ npm
❯ check-is-deprecated -f package.json
🎉 All is OK!

自動化したい

cliを実行する

最初に思いつくのは、上記の手動でcliを叩くものをGitHub Actionsなどで実行する方法です。

tinovyatkin/action-check-deprecated-js-deps

似たようなことを GitHub Actions で公開されているものがありました。 npm レジストリに対してパッケージの情報を問い合わせて、非推奨かどうかを確認しているようです。

github.com

Renovate Replacement Presets

今回、調べていくなかで Renovate の issue に似たような話題をみつけました。

github.com

これは、なんらかのパッケージ名が変更された場合に変更後のパッケージ名に置き換える機能を用意しよう。というものでした。

issueが作られたのが2018年、PRの作成日が2020年2月でマージされたのが2021年11月だったので長らく議論していたようです。

紆余曲折あり最終的にリリースされたのは、変更前後のパッケージ名をマッピング出来るReplacement Presetsという機能のようです。

docs.renovatebot.com

この機能は、自動という訳ではなく変更後のパッケージ名が判明してから設定する必要があります。

今回のreact-use-gestureなら以下のように書くことが出来ます。

{
  "packageRules": [
    {
      "matchDatasources": [
        "npm"
      ],
      "matchPackageNames": [
        "react-use-gesture"
      ],
      "replacementName": "@use-gesture/react",
      "replacementVersion": "10.0.0"
    }
  ]
}

まとめ

長く開発・運用をしていると、いつのまにか非推奨になっているパッケージもあるかと思います。 普段の依存更新に加えて、パッケージ名の変更はどうのように追えばよいのか調べてみました。

現時点では、使用しているパッケージの名前が変更されているかはnpmレジストリを直接確認していくしかないようです。

かっこよく自動で変更後のパッケージ名をsuggestする機能を作りたいですね。

Vue3でFirabaseログイン

zenn.dev

2年前に書いたVue vuexでfirebaseのログイン保持を、Vue3+Composition APIで書き直したものです。

未だにちょくちょく読まれているようなのですが、流石に2年前のバージョンのチュートリアルは申し訳ないと感じたので書き直すことにしました。 需要があるかはさておき。

1年ぶりくらいにVueを書きましたが、やっぱり書きやすいですね。 TypeScriptから逃げられないのでReactを選んでいますが、Vueが完全に対応したらVueを選んでもいいかもしれない。