From 6a06427190f95cce827954c4b36e8b49475b3593 Mon Sep 17 00:00:00 2001 From: Hideaki Noshiro <46040697+noshiro-pf@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:47:51 +0900 Subject: [PATCH] docs: update Zenn articles (#1368) --- .prettierrc | 10 + articles/.prettierrc | 11 - articles/engine-building-board-game.md | 54 ++++ articles/numeric-input-interface.md | 279 +++++++++++------- articles/slider-input.png | Bin 0 -> 2504 bytes articles/state-management-in-react.md | 11 + ...ake-full-advantage-of-typescript-eslint.md | 6 +- articles/typescript-branded-type-int.md | 37 ++- 8 files changed, 268 insertions(+), 140 deletions(-) delete mode 100644 articles/.prettierrc create mode 100644 articles/engine-building-board-game.md create mode 100644 articles/slider-input.png diff --git a/.prettierrc b/.prettierrc index 4865ae5ec2..10b1b7973e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -23,6 +23,16 @@ "options": { "tabWidth": 4 } + }, + { + "files": ["articles/**/*"], + "options": { + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-packagejson" + ], + "tabWidth": 2 + } } ] } diff --git a/articles/.prettierrc b/articles/.prettierrc deleted file mode 100644 index 75eaa59bf8..0000000000 --- a/articles/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "plugins": [ - "prettier-plugin-organize-imports", - "prettier-plugin-packagejson" - ], - "semi": true, - "endOfLine": "lf", - "singleQuote": true, - "jsxSingleQuote": true, - "tabWidth": 2 -} diff --git a/articles/engine-building-board-game.md b/articles/engine-building-board-game.md new file mode 100644 index 0000000000..f18b55c5f1 --- /dev/null +++ b/articles/engine-building-board-game.md @@ -0,0 +1,54 @@ +--- +title: '拡大再生産ゲームが満たすべき性質の考察' +emoji: '🐈' +type: 'idea' # tech: 技術記事 / idea: アイデア +topics: ['boardgame'] +published: false +--- + +拡大再生産系のボードゲームの投資行動(生産力アップ)と得点行動について、先日友人と会話していてふと気になったので考察してみる。 + +AI同士の対戦ならともかく、人間同士で行う対戦ゲームで一番重要な要素は終盤で逆転が起こり得るということだと思う。 +逆転の余地が無くなった(より正確には逆転の目があると思えなくなった)ゲームがその後も長く続かないことが、最後まで全プレイヤーが楽しめるために必要である(負けているプレイヤーがゲームを続ける意欲が無くなることを避ける)。 + +これを踏まえると、拡大再生産ゲームの投資と得点アクションの設計で満たすと良さそうな性質は以下のようなものがあると思われる(AND条件ではない)。 + +- (性質A)そもそもの1ゲームの時間が短い + - 1ゲーム全体が短ければ大差が付いてからのゲームが長く続くことも無いので(=リセマラできる)。ただし、短すぎると拡大した生産力を活かす時間も短くなり勝っているプレイヤーの楽しさが目減りするので、適度な長さが必要。 +- (性質B)投資行動がそのまま自動的に得点にならない + - 貪欲に投資すればするほど得をし続けるタイプのゲームは、序盤に拡大再生産が捗ったプレイヤーがそのまま最後まで簡単に勝ちやすく、後から逆転する余地が少ない傾向があり要件を満たさない(例:カタン)。 + - 投資と得点のバランスが難しいゲームほど、後半にも紛れが起きやすい傾向があると思われる。 +- (性質C)多様な得点手段があり、各プレイヤーはその一部に特化した戦略を取らざるを得ない + - 得点が比較しづらい状況になっていれば、凡人がパッと見で誰が勝っているかはっきり分からないので、実際の逆転の有無によらず終盤まで勝ちを目指す気持ちを維持しやすい。 + - 逆に得点手段が一元的だと、得点状況を把握しやすく途中で勝敗が見えてしまいやすい(例:カタン)。 + - 得点手段が多くても、勝っている人が簡単に総取りできてしまうようなシステムだと状況は分かりやすくなってしまうので、それがしづらいようにする必要もある。 +- (性質D)不利をひっくり返すためのギミックがある + - 逆転の目があれば、負けているプレイヤーも最後までゲームを続ける意欲を持続しやすいので。 + - 例:終盤に低頻度に大得点をする手段がある + - 麻雀の親番連荘とか役満とか + +## 具体例 + +自分が遊んだことのある拡大再生産ゲームを例として考える。 + +### カタンの場合 + +建設や改築が資源の生産力を上げると共にそのまま得点にもなる。純粋な得点行動にしかならないアクションは、最長交易路タイル獲得のためだけの生産に寄与しない街道建設や、最後の住居建設などのみで、貪欲に自明な生産行動を繰り返すだけで得点行動になる傾向が強い(性質Bを満たさない)。 +序盤に拡大が進むほど多くの賽の目で資源を産むことができるようになり確率にも左右されづらくなるため、中終盤に巻き返す方法はほぼ貿易によるトップの足止めに限られてくる。ところがその貿易を行う頻度が増えるとゲーム時間が長引いてしまい(=性質Aを満たさなくなる)、ゲーム体験の悪さがトレードオフにしかならない。 +得点手段は建設と最長交易路2点・最大騎士力2点、発展カード1点(非公開)に限られており、得点状況は容易に計算できてしまう(性質C・Dを満たさない)。 + +### テラミスティカの場合 + +住居2点や交易所3点の恩恵タイルが生産力アップをそのまま得点にするシステムで得点も大きいので`解決策B`は部分的には満たしていない。しかし、投資行動自体が得点になるケースはそれ以外では少なく、得点手段は多様でゲーム終了時に集計される点数もそれなりに大きいため、得点状況は比較的複雑といえる(性質Cを概ね満たす)。 +1ゲームは長いため、要件Aは満たさない。 + +### ツォルキンの場合 + +ワーカー数や資源自体も一応得点になることがあるが得点換算効率はあまり良くない場合が多く、技術トラック成長によって行動時の加点効率を上げたり、純粋に得点のためだけに行う記念碑建設などの点数が支配的な傾向がある(性質Bを満たす)。生産と得点のバランスに最後まで悩まなければならない点で中盤あたりからも紛れが多い。 +得点手段はテラミスティカと同じくらい種類が多く、異なる戦略の対抗になることが多い(性質Cを満たす)。 +大得点をする手段は主に記念碑で、集計がゲーム終盤に行われるが、足し上げることはそれほど難しくはないし + +### ドミニオンの場合 + +生産力アップのための行動自体は得点にならない場合が多く、得点行動が生産力を下げることが多い(VPトークンによる得点とかその他もろもろの回避策はあるが)。そのため、序盤の生産力アップが勝敗に直結するというよりは、得点行動のバランスが難しい点が面白いと思っている。 +加えて、ドミニオンは1ゲームが上の三つと比べてそれほど長くない点もゲームシステムに求められる緻密さを多少緩和できるとも言える(=何ゲームも繰り返せば1ゲームあたりのゲーム体験の悪さがあったとしても緩和されやすい)。 diff --git a/articles/numeric-input-interface.md b/articles/numeric-input-interface.md index 305d29ea4b..4f1cb949b1 100644 --- a/articles/numeric-input-interface.md +++ b/articles/numeric-input-interface.md @@ -70,32 +70,30 @@ numeric input の設計を考える上で、まず React の **controlled compon ![text-input](https://github.com/noshiro-pf/mono/blob/develop/articles/numeric-input-interface/text-input.png?raw=true) controlled component による実装では form の最新の値は state に常に反映されるため、その値を使った処理は単にその state 変数を使うだけですが、 uncontrolled component による実装では form の値は DOM に保持されており、必要なとき(上の例では submit を押したとき)に ref を介して最新の値を "pull" する必要があります。 -`` 要素の場合、 `value` プロパティの方を使って state を常に入力欄に反映し onChange で変更を即 state に反映する実装方式は controlled mode、 `defaultValue` プロパティの方を使って初期値のみ設定し、以後は ref を経由して DOM に保持されている値を取り出したり(pull)、更新したりする実装方式は uncontrolled mode と言えます。 +`` 要素の場合、 controlled mode とは `value` プロパティの方を使って state を常に入力欄に反映し onChange で変更を即 state に反映する実装方式、 uncontrolled mode とは `defaultValue` プロパティの方を使って初期値のみ設定し、以後は ref を経由して DOM に保持されている値を取り出したり(pull)、更新したりする実装方式となります。 実装例: https://playcode.io/2136118 -React 公式ドキュメントには以下のように書かれています。 +React 公式ドキュメントには、これら 2 パターンの実装方法について以下のように使い分け方が書かれています。 > ほとんどの場合では、フォームの実装には制御されたコンポーネント (controlled component) を使用することをお勧めしています。制御されたコンポーネントでは、フォームのデータは React コンポーネントが扱います。非制御コンポーネント (uncontrolled component) はその代替となるものであり、フォームデータを DOM 自身が扱います。 https://ja.legacy.reactjs.org/docs/uncontrolled-components.html -https://ja.react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components - https://goshacmd.com/controlled-vs-uncontrolled-inputs-react/ -React などを用いてフロントエンドを実装する一番のメリットは、従来のように画面状態を DOM に直接持たせるのではなく、JavaScript の変数として持ち、画面状態がその像になっている(`view = f(state)` を満たす)ように実装しやすい点にあります。 -コンポーネントを uncontrolled mode で実装してしまうと React のメリットを手放すことになってしまうので、 input 等のフォーム要素も controlled mode として実装するのが基本と言えます。 +React などを用いてフロントエンドを実装する一番のメリットは、従来のように画面状態を DOM に直接持たせるのではなく、JavaScript の変数として持ち、画面状態がその像になっている(`view = f(state)` を満たす)ように実装しやすい(=状態を JavaScript 側に一元管理しやすい)点にあります。 +input 要素も uncontrolled mode で実装してしまうと React のメリットを手放すことになってしまうので、基本的に controlled mode として実装するのが良いと言えます。 ## numeric input の場合の難しさ -ところが、 numeric input には、**数値型データをやり取りするインターフェースの controlled な component として実装することができない**、という厄介な特徴があります。これは、ユーザーが入力途中の文字列は数値として有効であるとは限らないため、状態を `number` 型の変数で保持し controlled な作りにしてしまうと、 数値に対応させられない文字列が即座に `NaN` に潰れてしまい入力を邪魔してしまう、という問題があるためです。 +ところが、 **numeric input は、数値型データをやり取りするインターフェースの controlled な component として実装することができない**、という厄介な特徴があります。これは、ユーザーが入力途中の文字列は数値として有効であるとは限らないため、状態を `number` 型の変数で保持し controlled な作りにしてしまうと、 数値に対応させられない文字列が即座に `NaN` に潰れてしまい入力を邪魔してしまう、という問題があるためです。 -どういうことか説明するために、先ほどの例を `type="number"` とした例を考えます。 +先ほどの例を `type="number"` とした例を考えます。 ```diff - const InputControlled = () => { -+ const BadNumericInputControlled = () => { ++ const NumericInputControlledBad = () => { - const [str, setStr] = React.useState(""); + const [num, setNum] = React.useState(0); @@ -121,12 +119,17 @@ React などを用いてフロントエンドを実装する一番のメリッ }; ``` -`` とするとその input はモダンブラウザでは["有効な浮動小数点数"](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number) (`0-9`, `-`, `.`, `e`, `E`, `+` からなる並び順に一定の制約のある文字列)のみを受け付けるようにはなりますが、入力途中の状態も認める必要があるため、 有効な数値文字列になっているとは限りません。 +`` とするとその input はモダンブラウザでは["有効な浮動小数点数"](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number) (`0-9`, `-`, `.`, `e`, `E`, `+` からなる並び順に一定の制約のある文字列)のみを受け付けるようになりますが、入力途中の状態も認める必要があるため、 有効な数値文字列になっているとは限りません。 -`BadNumericInputControlled` では、例えば `"-1"` や `"3.4e+1"` という値を入力しようとすると `"-"` や `"3.4e"` などの**入力途中の文字列を有効な数値に対応させることができず `NaN` に変換されてしまう**ので、 input の内容は `""` に潰されてしまい入力が阻害されてしまいます。 +`NumericInputControlledBad` の方では、例えば `"-1"` や `"3.4e+1"` という値を入力しようとすると `"-"` や `"3.4e"` などの**入力途中の文字列を有効な数値に対応させることができず `NaN` に変換されてしまう**ので、 numeric input の内容は `""` に潰されてしまい入力が阻害されてしまいます。 これが numeric input を数値型データをやり取りするインターフェースの controlled な component として実装することができない理由です。 +:::message +良くない実装例(`Numeric input controlled mode (Bad implementation example)` の部分) +https://playcode.io/2136118 +::: + ## CSS コンポーネントライブラリ Blueprint.js の見解 この問題に関して、CSS コンポーネントライブラリの 「[Blueprint.js](https://blueprintjs.com)」 (Material UI 等と同様のライブラリ)の[Numeric input コンポーネント](https://blueprintjs.com/docs/#core/components/numeric-input.uncontrolled-mode)のドキュメントには、 @@ -192,26 +195,38 @@ const App = () => { }; ``` -UI状態の管理をシンプルにしたくて controlled numeric input にしたのに、その目的のためにすべての numeric input に紐づく数値の state を `string` 型で持たなければならなくなるのでは、これはこれで状態管理が複雑になり本末転倒な感があります。やはり `string` で数値情報をやりとりするのは受け入れがたいと思われます。 +UI状態の管理をシンプルにしたくて controlled numeric input にしたのに、その目的のためにすべての numeric input に紐づく数値の state を `string` 型で持たなければならなくなるのでは、却って状態管理が複雑になり本末転倒な感があります。やはり `string` 型をインターフェースにするのは受け入れがたいでしょう。 -したがって、 numeric input は `number` 型データをやりとりするステートフルなコンポーネントとして実装する方が多くの状況では便利で周辺コードのメンテナビリティの観点でも優れると私も考えます。 +したがって、 numeric input は `number` 型データをやりとりする uncontrolled でステートフルなコンポーネントとして実装する方が多くの状況では便利で、周辺コードのメンテナビリティの観点でも優れると思われます。 ### ステートフルな numeric input コンポーネントの悩み -`number` 型をインターフェースにすることを優先し、NumericInput をステートフルなコンポーネントとして実装すると多くの単純なユースケースで便利ですが、特殊な使い方が必要になるときにはその内部に状態があることが邪魔になることも少なくないです。 +`number` 型をインターフェースにすることを優先し、NumericInput をステートフルなコンポーネントとして実装すると多くの単純なユースケースでは便利ですが、少し込み入った使い方が必要になるときにその内部に状態があることが邪魔になることも少なくないです。 例えば、スライダーのような要素と数値入力欄を同期させたいユースケースがあるとします。 ![slider-input](https://github.com/noshiro-pf/mono/blob/develop/articles/numeric-input-interface/numeric-input-with-slider.png?raw=true) -このとき、数値データの state は本来これらを束ねる親コンポーネントに一つだけ持てばよいはずですが、 numeric input が内部に state を持っていると状態の二重管理が発生し、親子間で状態を同期するための処理が発生します。ステートフルなコンポーネントを親子に組み合わせデータを同期させようとすると、下手な実装をするとすぐ親子間の無限ループが起こってしまうという悩みがあります[^infinite_loop]。 +このとき、数値データの state は本来これらを束ねる親コンポーネントに一つだけ持てばよいはずですが、 numeric input が内部に state を持っていると状態の二重管理が発生し、親子間で状態を同期するための処理が発生します。ステートフルなコンポーネントを親子に組み合わせデータを同期させようとすると、状態管理が複雑になってしまい、下手な実装をすると親子間の状態更新で無限ループが起こってしまう危険もあります[^infinite_loop]。 [^infinite_loop]: `number` 型データを `value` と `onChange` props でやりとりするステートフルなコンポーネントは、 `value` の変更を内部 state に反映するための副作用を `useEffect` 等で書く必要が生じます。すると、この `useEffect` の書き方が悪かったり、親コンポーネントの状態管理の仕方がまずかったりで、「親コンポーネントの状態更新 → 子の useEffect が発火 → onChange を通して親に変更を通知 → 親コンポーネントの状態が更新 …」という無限ループが起きてしまうことがあります(こういう無限ループが起きないように、 props に依存する `useEffect` を書くときは十分注意して実装する必要があります)。 -このようなケースでは numeric input は `string` インターフェースでステートレスな controlled コンポーネントとして配置した方が、状態管理がはるかにシンプルになるので嬉しいです。 -そもそも、このケースでは有限の数値の値しか取らないスライダーと、数値でない値まで取り得る numeric input の文字列状態を同期させる方法(スライダー側で無効になる値をどのようなデフォルト値にマップするかとそのタイミング)を定義するのは親コンポーネントの責務であるため、どのように parse 結果を数値に対応させるかは numeric input コンポーネント内部で定義すべきではないと言えます。 +:::message +良くない実装例(`Numeric input with slider (Bad implementation example)` の部分) +https://playcode.io/2136118 +::: + +このようなケースでは numeric input は `string` インターフェースでステートレスな controlled コンポーネントとして配置した方が、状態管理がはるかにシンプルになります。 +そもそも、このケースでは有限の数値の値しか取らないスライダーと、数値でない値まで取り得る numeric input の文字列状態を同期させる方法(スライダー側で無効になる値をどのようにマップするかとそのタイミング)を定義するのは親コンポーネントの責務であるため、input 文字列と parse 結果の数値との対応関係は numeric input コンポーネント内部で定義すべきではないと言えます。 + +:::message +良い実装例(`Numeric input with slider (Good implementation example)` の部分) +https://playcode.io/2136118 -React の実装例: https://playcode.io/2136118 +> スライダー側で無効になる値をどのようにマップするかとそのタイミング + +については、 input からフォーカスが外れたタイミング=`onBlur` で $[min, max]$ に収まる値に丸めて range に反映しています。 +::: 他には、 controlled numeric input の入出力である `number` 型データをそのまま持ち回るのではなく、入力文字列を state に反映する前の段階で @@ -224,14 +239,14 @@ React の実装例: https://playcode.io/2136118 こういうときに、 -- インターフェースを `number` 型にするための状態管理を行う numeric input (子)コンポーネント -- インターフェースを整数にするための状態管理を行う numeric input (親)コンポーネント +- [子コンポーネント]:インターフェースを `number` 型にするための状態管理を行う numeric input +- [親コンポーネント]:インターフェースを整数にするための状態管理を行う numeric input -を組み合わせるような実装になってしまうと、先ほどのスライダーの例と同様に無駄な `number` 型中間 state が挟まり、ステートフルなコンポーネントを密に組み合わせることとなりバグの元です。この場合も、「子」コンポーネントの方はステートレスなものを使い、「親」の方で直接整数と文字列の対応方法を定義し入出力も行う方が安全です。 +を組み合わせるような実装になってしまうと、先ほどのスライダーの例と同様に無駄な state が一つ増えてしまい、ステートフルなコンポーネントを密に組み合わせることになるので予期せぬ挙動が発生しやすくなります。この場合も、「子」コンポーネントの方はステートレスなものを使い、「親」の方で直接整数と文字列の対応方法を定義し入出力も行う方が安全です。 ## ではどうするか?(numeric input コンポーネント設計の筆者の結論) -前節までの議論を踏まえて、私は `NumericInput` コンポーネントとして以下の設計が良いだろうと考えています。所謂 Container/Presentational Component パターンと呼ばれるものに近い設計だと思います。 +前節までの議論を踏まえて、私は `NumericInput` コンポーネントとして以下の設計(所謂 Container/Presentational Component パターンと呼ばれるものに近い設計)が良いだろうと考えています。 - numeric input のスタイリングのみを担当するステートレスな presentational コンポーネント(`NumericInputView`)を作る。 - このコンポーネントのインターフェースは `string` の `value` と `onChange` 関数(と `disabled` などの各種ネイティヴ input 要素の属性)とする。 @@ -245,15 +260,17 @@ React の実装例: https://playcode.io/2136118 このような二段構えの実装設計には主に以下の二つのメリットがあります。 一つは、`NumericInputView`というスタイリングだけを担当する完全にステートレスなコンポーネントが提供されるという点です。 -Material UI のような UI コンポーネントライブラリを使っていると、その見た目だけは拝借したいが挙動は気に食わないということがよくあるのですが、ステートレスコンポーネント `NumericInputView` が提供されていると自前で状態管理するという選択肢があるのは大きなメリットです。 -Blueprint.js の `NumericInput` コンポーネントは `string` 型を使って controlled mode で細かい制御を行う、という脱出ハッチも用意しているのでかなり理想に近いですが、 同じコンポーネントに渡す props で uncontrolled/controlled mode を切り替えるような使い方であり、ステートレスに使いたい場合に無駄な状態管理が中で走っていて効率が悪いという点で若干不満はあります。 -やはり、世の中の UI ライブラリは多くのユースケースに対応できるステートフルなパーツを提供すると共に、ステートレスなパーツもそれ単体で提供するようにしてほしいと私は思います。 +[Material UI](https://mui.com/material-ui/) や [Blueprint.js](https://blueprintjs.com/) のような UI コンポーネントライブラリを使っていると、そのスタイリング(CSS)だけは採用したいがステートフルであるがために JavaScript の挙動がユースケースに合わない、ということがたまにあります。 `NumericInput` はまさにその一例で、もしこういうときにライブラリがステートレスコンポーネント `NumericInputView` も提供していれば、自前で状態管理するという選択肢が生まれて問題を解決しやすくなります。[^about-ui-library] + +[^about-ui-library]: 世の中の UI ライブラリは、多くのユースケースに対応できる便利でステートフルなコンポーネントも提供すると共に、自前の状態管理をするためのステートレスな同じ見た目のコンポーネントもそれ単体で提供するようになっていたら良いのにと思います。[^NumericInput-MaterialUI] [^NumericInput-Blueprintjs] + +[^NumericInput-MaterialUI]: Material UI の場合、 Numeric Input は [Base UI](https://mui.com/base-ui/react-number-input/) というところで提供されていますが、 `value` は `number` 型で受け取る API となっており、今回求めていたものではなさそうです。 + +[^NumericInput-Blueprintjs]: [Blueprint.js](https://blueprintjs.com/docs/#core/components/numeric-input) の `NumericInput` コンポーネントの場合、 `value` に `string` 型を渡すこともできる API となっており、こちらは controlled mode でより細かい制御を行うために意図的に用意されていてかなり理想に近いです(こういうところを見比べても、 Blueprint.js は Material UI より API が洗練されているように感じます)。ただ、同じ `NumericInput` コンポーネントに渡す props の型(`string` or `number`)で controlled/uncontrolled mode を切り替えるような使い方であり、ステートレスに使いたい場合にも無駄な状態管理が中で走っていて効率が悪いという点で若干不満はあります。 もう一つは、ただの `number` 型より狭いカスタム数値型(例えば [実装例 A](#実装例A) における`ScoreType`)に対応する numeric input を使った実装が綺麗になる点です。型の制約に合う値だけをグローバルな state に反映するためにユーザー入力結果の文字列を数値に変換する処理を numeric input のレイヤーで行うことができるので、 `NumericInput` を使う側の実装がシンプルになります 。 複数のカスタム数値型があってそれぞれに対応する numeric input を UI に配置したいが見た目は同じで良い、という場合、`NumericInputView` を使ってそれぞれの数値型ごとに `NumericInput` を個別に実装して使う、というやり方を上の設計は意識しています(`NumericInputView` という一つのスタイル実装を使いまわし `NumericInput` は複数実装するというイメージ)。 -`number` 型より狭い数値型としては、 union 型で定義された有限集合(例: 0以上10以下の整数 `0|1|2|3|4|5|6|7|8|9|10` など)や、 Branded Type[^1] (例: io-ts の `Int` 型)などが考えられます。いずれにせよ、`NumericInput`としては大きな差は無くほぼ同じ手順で実装することができます。 - - +`number` 型より狭い数値型としては、 union 型で定義された有限集合(例: 0以上10以下の整数 `0|1|2|3|4|5|6|7|8|9|10` など)や、 Branded Type[^1] (例: io-ts の `Int` 型)などが考えられます。いずれにせよ、`NumericInput`としては同様の手順で実装することができます。 [^1]: 参考: [TypeScript の Type Branding をより便利に活用する方法のまとめ](https://zenn.dev/noshiro_piko/articles/typescript-branded-type-int) @@ -261,30 +278,39 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ ### 実装例 A -- App.tsx +- score-type.ts - ```tsx - import * as React from 'react'; - import { ScoreType } from './score'; - import { ScoreNumericInput } from './score-input'; + ```ts + import { createNumberType } from './create-number-type'; - export const App = () => { - const [score, onScoreChange] = React.useState(0); + export type ScoreType = + | 0 + | 0.1 + | 0.2 + | 0.3 + | 0.4 + | 0.5 + | 0.6 + | 0.7 + | 0.8 + | 0.9 + | 1; - return ( -
- -
- ); - }; + export const ScoreType = createNumberType({ + min: 0, + max: 1, + digit: 1, + defaultValue: 0, + }); ``` - score-input.tsx ```tsx - import { useNumericInputState } from './numeric-input-state'; + import * as React from 'react'; + import { useNumericInputState } from './use-numeric-input-state'; import { NumericInputView } from './numeric-input-view'; - import { ScoreType } from './score'; + import { type ScoreType } from './score-type'; type Props = Readonly<{ score: ScoreType; @@ -294,11 +320,9 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ const { step, min, max } = ScoreType; - export const ScoreNumericInput = ({ - score, - disabled = false, - onScoreChange, - }: Props): JSX.Element => { + export const ScoreNumericInput = React.memo((props) => { + const { score, disabled = false, onScoreChange } = props; + const { valueAsStr, onValueAsStrChange, submit } = useNumericInputState({ valueFromProps: score, onValueChange: onScoreChange, @@ -314,9 +338,29 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ step={step} value={valueAsStr} onBlur={submit} - onChange={onValueAsStrChange} + onValueChange={onValueAsStrChange} /> ); + }); + ``` + +- score-input-example.tsx + + ```tsx + import * as React from 'react'; + import { type ScoreType } from './score-type'; + import { ScoreNumericInput } from './score-input'; + + export const ScoreInputExample = () => { + const [score, setScore] = React.useState(0); + + console.log({ score }); + + return ( +
+ +
+ ); }; ``` @@ -327,82 +371,41 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ type Props = Readonly<{ value: string; - disabled: boolean; - min: number; - max: number; - step: number; - onChange: (value: string) => void; - onBlur: () => void; + onValueChange: (next: string) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + onBlur?: () => void; }>; export const NumericInputView = React.memo((props) => { - const { value, disabled, max, min, step, onChange, onBlur } = props; + const { onValueChange, value, min, max, step, disabled, onBlur } = props; - const handleChange = React.useCallback( + const onChange = React.useCallback( (ev: React.ChangeEvent) => { - onChange(ev.target.value); + onValueChange(ev.target.value); }, - [onChange], + [onValueChange], ); return ( ); }); - - NumericInputView.displayName = 'NumericInputView'; - ``` - -- score.ts - - ```ts - import { clampAndRound } from './numeric-type-utils'; - - export type ScoreType = - | 0 - | 0.1 - | 0.2 - | 0.3 - | 0.4 - | 0.5 - | 0.6 - | 0.7 - | 0.8 - | 0.9 - | 1; - - export namespace ScoreType { - export const min = 0 satisfies ScoreType; - export const max = 1 satisfies ScoreType; - export const defaultValue = 0 satisfies ScoreType; - export const digit = 1; - export const step = 0.1; - - const clampAndRoundScore = clampAndRound({ - defaultValue, - digit, - max, - min, - step, - }); - - export const encode = (s: ScoreType): string => s.toString(); - - export const decode = (s: string): ScoreType => - clampAndRoundScore(Number.parseFloat(s)); - } ``` -- numeric-input-state.ts +- use-numeric-input-state.ts ```ts import * as React from 'react'; @@ -422,15 +425,19 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ onValueAsStrChange: (value: string) => void; submit: () => void; }> => { - const [valueAsStr, setValueAsStr] = React.useState(encode(valueFromProps)); + const [valueAsStr, setValueAsStr] = React.useState( + encode(valueFromProps), + ); React.useEffect(() => { setValueAsStr(encode(valueFromProps)); - }, [valueFromProps, setValueAsStr, encode]); + }, [valueFromProps, encode]); const submit = React.useCallback(() => { - onValueChange(decode(valueAsStr)); - }, [decode, onValueChange, valueAsStr]); + const decoded = decode(valueAsStr); + onValueChange(decoded); + setValueAsStr(encode(decoded)); + }, [decode, encode, onValueChange, valueAsStr]); return { onValueAsStrChange: setValueAsStr, @@ -448,7 +455,6 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ max: T; digit: number; defaultValue: T; - step?: number; }>; export const clampAndRound = @@ -461,8 +467,55 @@ Blueprint.js の `NumericInput` コンポーネントは `string` 型を使っ : p.max < x ? p.max : ((Math.round(x * 10 ** p.digit) / 10 ** p.digit) as T); + + export const createNumberType = ({ + min, + max, + digit, + defaultValue, + }: NumericTypeProperties): Readonly<{ + step: number; + encode: (s: T) => string; + decode: (s: string) => T; + }> & + NumericTypeProperties => { + const step = 10 ** -digit; + + const clampAndRoundScore = clampAndRound({ + defaultValue, + digit, + max, + min, + }); + + const encode = (s: T): string => s.toString(); + + const decode = (s: string): T => clampAndRoundScore(Number(s)); + + return { + min, + max, + digit, + defaultValue, + step, + encode, + decode, + }; + }; ``` -動くコード +動くコード: https://playcode.io/2208737 + +## npm package + +今回紹介した numeric input の実装のためのユーティリティを npm package として publish しました。 + +https://www.npmjs.com/package/@noshiro/numeric-input-utils + +提供しているものは [実装例 A](#実装例A) における以下の型・関数です。 -https://github.com/noshiro-pf/mono/blob/develop/experimental/numeric-input +- `useNumericInputState` +- 数値型作成のための補助ユーティリティ + - `createNumberType` + - `NumericTypeProperties` + - `clampAndRound` diff --git a/articles/slider-input.png b/articles/slider-input.png new file mode 100644 index 0000000000000000000000000000000000000000..ee0f624433eff2edfc9ae35d6b9b05cce147e38d GIT binary patch literal 2504 zcmcJReKZqnAIC?EktDB$^vc*BlMoAcXjrU5WV`b&x8!xzyv#kxTi7k{Q<5;-EDM=; z%2Z3;Tg8yr7O|ll)fi=X)^ncooadkCzvubmb6wZ(y3YBXb6w|rKj*r27U8U{xJMBH z04Te3$LbXv|ZR1a6hqz!(=_ zGyt%p?INw0Xnc`okC`Ds1o*E6_rkNiL#WF+1Vbq=i>SX_Y52-Q)o zK0I^O@LY)u4C)n=BLg+d8EXpph0Z=Mb67+E;K z0OCG|f-KITE*-BMeM~!tM83o`I%qWV^~F)rt4LGO2kt!V#=HEt5S9snLfVTWohJEB z!ge0hw@7~2x#&;jE+=snAaOvYLQIX9XqZZ>R>#BWp|3TEhK80mEEm2p1I{3kcNCSB z=-BUZ=?=-xZGPq1w9c}FhPijr(r4Mk)(Gw+{iPAi{0AcObSZ^M9OY*uTU)o+JshvI zMhjNQB5-T+ku)sZUmj*0Sjgv<7cik72jIP*=1rrYB&@wbU&hH+?+6QTZhzM#OmC7yg^7gJRDA?8Qy{*7I@6JB}Y(#VL+#> zTCG@*>UB(L5=oxt&@m5(=aK|q5)qzfQbwz~zjz4I7h?>61{~gc&~4r{oK>Kl9ikDD z9C@?F-jntXZ(3Iy`(&6;c<@rK^>^0mdkdMdmRo|I$^(4&G1Mub5rKDjRCv2L0)4Y$ zslmHs9ga@QE4AF5_NBK4mWMYes2X0-NP$>f7V;yKO1C20_KjO{__^Pa7v6Vb+u_ip zPwD2$8v2uRIkKv?FLiiLE?dndH)I%!Ya@9*HFpwm0Cr5ZV|f669io>FnmqD&ptxc# zZ~JXpu4{4xK6B5S_s6l0Yo8nZQx5kMBx3a`qc`utG@Sw?3j zbh<IcLfyy?^+ z4gX=QG`l`q<&?p#0q*+*1cv=ZqJF3=Jv5?u;^rSC&#u9mCmzHN89QQ1Bq47Hfj#Vu z>>cM)Pf0Jp_N`)#c^i)-iRULyXdLk)TCDJ@s=8*8%dk$yN;ml%%6wcUxUzTBu_)j? zguwv^_a+W;?COhijt;Fdw%PueLeQtwBbHm%>%I?4c)-K2oQtv@#VRdsnA-^6!YH&2knhLAnGu0K|5t`Ge9F zc=`B{XfzYtc5yr!o}QkL?7@DY1toKq8l`UVeHT@+O)8xRNWvwpzc`+G^-w)SxjZf6 ze+oyrkO_@Lc1(G*Dz3RNMt|zzKW%tK1<&D0%SGa^#P>&5G&2)My%}wu##PS{?u{zP<;v8(#AI@~X^L8nNy*A=s zjICnfW&0cnkSk1Dmm`zboKA(I`e&cUg$^%t{?M5Xw=bQx(Jx8AN zF$ewqJ{U$ve529fb9Mq)N^fkvF1tTxKuc7id#>mN@5zcCbL~U`C=Do_(la_m!oH5g z=dM`ugffmmj!*ln`6VOpn!{nzmtVfbuf{M|nyt9F4>M%rYF2IF>i5vnnR%RKusgVLw0|)o$7hDGC#3AZUaZp_A2$=zv|}$K z7L`>hkzmswmDi>#!Nfkpjh8IlD5K^}{jMep?pg2dd)rWx@9(O0ddXr9jayfXN{1VR zEZy|(aW9Xkx2Csc4?Ya`Oe$B*G7q_Q$rwB+7ExIll+I4)cZUSUFCMd_7)1&^tS2aB ztw^WSf7-)SJ=@IMrh-U{<2fIQw+hOIMe^%jTp<_`Nyd(uT+K;ig#1xYa|O=>&2&12 zUeApj4OyX%hb_)dPUZdcxVuf@FeV(x^CcvLuyR^YS?c(;w7Dy&U=6??ChkkC2gRdsmM@jno0=S++ KIMEyeQvU%7p3gS` literal 0 HcmV?d00001 diff --git a/articles/state-management-in-react.md b/articles/state-management-in-react.md index b352773b75..873b3ca612 100644 --- a/articles/state-management-in-react.md +++ b/articles/state-management-in-react.md @@ -7,3 +7,14 @@ published: false --- WIP + +- React コンポーネント内で状態管理するメリット + - UI 上の条件分岐で絞られた値を使った処理が書ける + - `projectId: string | undefined` という値の `undefined` のケースは loading 表示し、 `string` のケースでメイン画面の表示を行うようにしているときに、 `projectId: string` を用いて(= `undefined` のケースをいちいち除外せずに)メイン画面の実装が行える。 + - コンポーネントの破棄時に自動で state がリセットされる。 + - state にアクセスできる場所が state を定義したコンポーネントの子孫に基本的には絞られ、アクセス範囲が分かりやすくなる。 +- React コンポーネント内で状態管理するデメリット + - コールバック関数等の更新が render サイクルで行われ効率が悪い。 + - 子孫コンポーネントへのバケツリレーにより props が肥大化しやすい。また、バケツリレーの経由点となっている中間のコンポーネントで余計な render が発生する可能性がある。 + - useEffect での非同期処理実装の実行タイミングが読みづらくなる。コンポーネントライフサイクルに縛られるため、非同期処理の途中の任意のタイミングでコンポーネントが破棄されることを想定しなければならない。 + - コンポーネント state に依存する diff --git a/articles/take-full-advantage-of-typescript-eslint.md b/articles/take-full-advantage-of-typescript-eslint.md index 8e6b8f52a6..84fabb1986 100644 --- a/articles/take-full-advantage-of-typescript-eslint.md +++ b/articles/take-full-advantage-of-typescript-eslint.md @@ -258,7 +258,11 @@ if (obj.value) { { "@typescript-eslint/strict-boolean-expressions": [ "warn", - { "allowString": false, "allowNumber": false, "allowNullableObject": false } + { + "allowString": false, + "allowNumber": false, + "allowNullableObject": false + } ] } ``` diff --git a/articles/typescript-branded-type-int.md b/articles/typescript-branded-type-int.md index 76da93fcec..a6d1d9e2e2 100644 --- a/articles/typescript-branded-type-int.md +++ b/articles/typescript-branded-type-int.md @@ -51,11 +51,11 @@ Type branding とは、対象となる型(この例では `string`)に `{ ta ## Branded Type の様々な実装 -ちなみに Branded Type の具体的な実装はライブラリによっても結構まちまちのようです(2024/10/21 追記: io-ts の方の型定義コード例が)。 +ちなみに Branded Type の具体的な実装はライブラリによっても結構まちまちのようです(2024/12/31 追記: io-ts の方の型定義のコードが間違っていたので修正しました)。 [io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements)[^io-ts] -[^io-ts]: io-tsでは、`Brand` という方により unique symbol をキーに持つオブジェクト型にネストさせることで、カスタム実装した型定義や他のライブラリ製の branded type との衝突を防ぐ仕組みになっています。本記事の Branded Type 定義では分かりやすさのためこのような実装はせずに説明しました。 +[^io-ts]: io-tsでは、`Brand` という型により unique symbol をキーに持つオブジェクト型にネストさせることで、カスタム実装した型定義や他のライブラリ製の branded type との衝突を防ぐ仕組みになっています。本記事の Branded Type 定義では分かりやすさのためこのような実装はせずに説明しました。 ```ts declare const _brand: unique symbol; @@ -76,9 +76,9 @@ type Int = number & { __type__: 'Int' } & { __witness__: number }; どちらの方法も型を区別するという要件は満たせますが、 tag object の key 部に型 ID を置く前者のやり方の方が、以下のように `&` で意味のある交差型を作ることができる点で便利そうです(後者の方法では `never` に潰れてしまいます)。 ```ts -type Int = number & { Int: never }; -type Positive = number & { Positive: never }; -type PositiveInt = Positive & Int; // number & { Positive: never } & { Int: never } +type Int = number & { Int: unique symbol }; +type Positive = number & { Positive: unique symbol }; +type PositiveInt = Positive & Int; // number & { Positive: unique symbol } & { Int: unique symbol } ``` また、後者の tag の value 部に型 ID を書く方法は、 `__type__` などのタグ名を予約するというルールが増えてしまうのも少し気になります。 @@ -101,9 +101,11 @@ function numberToString(n: number, radix?: Int): string { numberToString(12345, r); ``` -これを多少改善するために、 Branded Type 定義と共にガード関数と生成関数をセットで用意する方法が考えられます。 `as` を使ったキャストは不正確になりやすいですが、以下のようにすると型名に合う値であることを保証しやすくなります。 +これを多少改善するために、 Branded Type 定義と共にガード関数と生成関数をセットで用意する方法が考えられます。 `as` を使ったキャストは unsafe ですが、以下のようにすると型名に合う値であることを保証しやすくなります。 ```ts +// types/int.ts + type Int = number & { Int: never }; function isInt(a: number): a is Int { @@ -117,6 +119,8 @@ function toInt(a: number): Int { return a as Int; } +// main.ts + const r: Int = toInt(0.1); // ここで早期にエラーで気づける function numberToString(n: number, radix?: Int): string { @@ -126,7 +130,7 @@ function numberToString(n: number, radix?: Int): string { numberToString(12345, r); ``` -[io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements) や [zod](https://github.com/colinhacks/zod#brand) のようなツールを使うと、型定義と同時にこのような型ガード関数を生成できるのでさらに便利になります。この `Int` 型程度であれば簡単ですが、複雑な型を定義するときには型ガード関数が型定義と整合していないチェックをしてしまっているミスも発生しやすくなるため、より安全にもなります。 +[io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements) や [zod](https://github.com/colinhacks/zod#brand) のようなツールを使うと、型定義と同時にこのような型ガード関数を生成できるのでさらに便利になります。この `Int` 型程度であれば簡単なのであまり問題になりませんが、複雑な型を定義するときには型ガード関数が型定義と整合していないチェックをしてしまうミスも発生しやすくなるため、型定義を型ガード関数から自動生成できるとより安全にもなります。 [zod](https://github.com/colinhacks/zod#brand) の使用例: @@ -148,7 +152,9 @@ export const isInt = (a: number): a is Int => Int.safeParse(a).success; export const toInt = (a: number): Int => Int.parse(a); ``` -[io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements) の使用例: +[io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements) の使用例:[^io-ts-int] + +[^io-ts-int]: io-ts には Int 型は標準で提供されているので本来は自前でこのように定義する必要はありません。 ```ts import * as E from 'fp-ts/Either'; @@ -175,7 +181,7 @@ export const toInt = (a: number): Int => { }; ``` -もちろん、これだけでは `toInt` や `isInt` などのユーティリティを使わず `as Int` と書いてしまうことを禁止できているわけではないので、ESLint によりチェックするとさらに良さそうです。 +もちろん、これだけでは `toInt` や `isInt` などのユーティリティを使わず `as Int` と書いてしまうことを禁止できているわけではないので、さらに ESLint によりチェックすると良さそうです。 以下のように設定することでこれをチェックできます。 @@ -395,7 +401,7 @@ if (isInt(x) && isNonNegativeNumber(x)) { ## [発展]Branded Number Type の Union 型を改善する -以上の実装で唯一難点があるのが、 union 型の結果に余計なプロパティが生えてしまうことです。例として以下の型を考えてみます。 +前節の実装で唯一難点なのが、 union 型の結果に余計なプロパティが生えてしまうことです。例として以下の型を考えてみます。 ```ts type MaybeNonZeroNumber = NegativeNumber | PositiveNumber; @@ -435,26 +441,27 @@ type PositiveNumber = number & { ```ts number & { readonly NaN: false; - readonly NonNegative: boolean; // <- true | false + readonly NonNegative: boolean; // = true | false readonly Zero: false; }; ``` という型になってしまいます。 -`Brand` 型の定義を工夫することで解決できれば理想的なのですが、元々 `number` 型を「割る」ときにプロパティを新たに「足す」ということをしているので union を取ったときに対消滅させるような仕組みにするのは単純な方法では上手くいかず、他に良い方法が思い付かなかったので、 union 型から `boolean` になってしまったキーを取り除くユーティリティだけ用意してみることにしました。 +`Brand` 型の定義を工夫することで解決できれば理想的なのですが、元々 `number` 型を「割る」ときにプロパティを新たに「足す」ということをしているので、普通に union 型を作ったときに対消滅させるような仕組みにするのは単純な方法では上手くいかなさそうなので、作った branded type の union 型から `boolean` になってしまったキーを取り除いて正規化するユーティリティだけ用意してみることにしました。 ```ts NormalizeBrandUnion; /* -number & { += number & { readonly NaN: false; readonly Zero: false; -}; +} +となってほしい */ ``` -TypeScript の型レベルプログラミングテクニックが少し必要で本記事では説明を省きますが([TypeScript の型初級](https://qiita.com/uhyo/items/da21e2b3c10c8a03952f#conditional-type%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8Bunion-distribution) などの記事が参考になりそうです)、以下の実装で所望の `NormalizeBrandUnion` を得ることができます。 +TypeScript の型レベルプログラミングテクニックが少し必要で本記事では説明を省きますが([TypeScript の型初級](https://qiita.com/uhyo/items/da21e2b3c10c8a03952f#conditional-type%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8Bunion-distribution) などの記事が参考になります)、以下の実装で所望の `NormalizeBrandUnion` を得ることができます。 ```ts type TypeEq = [A] extends [B] ? ([B] extends [A] ? true : false) : false;