ReactでdetailsをoutSideClickで閉じるやつ

自作のアニメ視聴管理webサービス watch-duty-manager にこのようなメニューUIがある。ハンバーガーを押すとメニューが開く。これはHTMLのdetails/summaryタグで実現している。

こういうメニューはメニューの外側をクリックすると閉じるのが一般的だ。しかしdetailsタグは標準でそのような挙動をしない(まあメニューにだけ使われるものではないからね)。summary(ハンバーガー部分)をもう一度押すことで閉じることができる。外側クリックで閉じる方法を適当にググってみると

  • detailsが開いているときはcssでsummaryを画面全体を覆うように巨大化させる
  • JavaScriptのクリックイベントを全て拾ってなんとかする

という2つの方針が見つかった。1はなかなか賢いと思うものの、真面目に作り込もうと思うとなかなかバギーな部分もありそうで(特にレイヤー順とか)やめて2をやることにした。

みんな大好き react-use に useClickAway というカスタムフックがある。refで参照されたHTML要素の外側がクリックされたときに指定されたコールバックを実行する。実装としては愚直に2をやっているだけ。

// https://github.com/streamich/react-use/blob/ad33f76dfff7ddb041a9ef74b80656a94affaa80/src/useClickAway.ts#L16-L22

const handler = (event) => {
  const { current: el } = ref;
  el && !el.contains(event.target) && savedCallback.current(event);
};
for (const eventName of events) {
  on(document, eventName, handler);
}

これを使って

function Component() {
  const ref = useRef<HTMLDetailsElement>(null);
  useClickAway(ref, () => {
    if (ref.current && ref.current.open) {
      ref.current.removeAttribute("open");
    }
  })
  return (
    <details ref={ref}>
      <summary>summary</summary>
      details
    </details>
  )
}

てな感じでやれば一応動くものは作れる。作れるのだが、この作りだとdocument内のどこをクリックしても、画面内のこのコンポーネントの数だけ ref.current && ref.current.open の評価が走る。実際のユースケースでは100個くらいの表示はあり得る。まあ実際のところ100個あろうと大したコストではなく、使用感に影響を与える16msには届かないのだが、個人開発だからそういう現実的な損得はおいといて、もっと効率的な作りを追求することにした。

この作りの何が無駄かと言えば同時に開くdetailsは1個しかないのに画面内のdetails全てに対して同じ判定関数を与えていることだ。そこでデフォルトではなにもしない関数を渡しておいて、detailsが開いたときだけuseClickAwayに渡す関数を差し替えることにした。

const noOpCallback = () => {};

const useCloseDetailsOnClickAway = () => {
  const ref = useRef<HTMLDetailsElement>(null);
  const closeCallback = useCallback(() => {
    if (ref.current) {
      ref.current.removeAttribute("open");
    }
  }, [ref.current]);
  const [onClickAway, setOnClickAway] = useState({ f: noOpCallback });
  const onToggle = useCallback<React.ReactEventHandler<HTMLDetailsElement>>(
    (_) => {
      if (ref.current === null) {
        return;
      }
      if (ref.current.open) {
        setOnClickAway({ f: closeCallback });
      } else {
        setOnClickAway({ f: noOpCallback });
      }
    },
    [],
  );
  useClickAway(ref, onClickAway.f);
  return { ref, onToggle };
};

function Component() {
  const { ref, onToggle } = useCloseDetailsOnClickAway();
  return (
    <details ref={ref} onToggle={onToggle}>
      <summary>summary</summary>
      details
    </details>
  )
}

その結果できたカスタムフックがこれだ。detailsのtoggleイベントを監視して、開いたものだけuseClickAwayのコールバック関数をcloseCallbackに差し替えている。

ちなみに数字上の速度差はなかったです。たぶんReactやV8がなんとかしてるんでしょ。

 

20250105 作らされたいもの/RimWorld終盤の資産管理と人生訓

作らされたいもの

作りたいものねぇ〜とか言っていたら昔作ったwebアプリケーションをまだ使ってくれている友人から機能の要望が来て、若干の修正と膨大なリファクタリングが発生していて楽しい。しかし連休の終盤に楽しくて夜ふかししているのは頭が悪い。

昔作ったものだから結局コンフォートゾーンに逆戻りしているということではあるんだけど…まあ何もしないよりはマシかな。TypeScript+prismaでデータベースを複雑にいじくるところで(mysqlだとcreateManyAndReturnが使えません)、fp-tsで型をガチガチに固めているので絶対間違ったプログラムを書けないのだが、だからと言って正しいプログラムが書けるというわけでもなく苦労している。fp-tsの型エラーは原因箇所と遠く離れた場所に出るので何を間違ったのか探すのにコツがいる。好きな思想のライブラリではあるんだけどやっぱり言語機能の支援なしにこういうことをやろうとするとどこかに無理が生じるのよね…。

本日

今日からジムが開いていたので行って全身シバいてきた。少し衰えていたが想定内だ。NHKラジオニュースではUSスチールの買収差し止めが大きく扱われていた。大変だ。昼は久々に餃子の王将に行ってみたが、日高屋よりは美味しいだろうと期待しすぎた結果思ったほどじゃないな…となった。かぼちゃを買って帰宅。

RimWorld終盤の資産管理と人生訓

RimWorldは佳境である。いよいよ宇宙船の研究が一通り完了し、後は地道にウランやらプラスチールやら先進コンポーネントやらをかき集める段階に入った。ここの進め方がなかなか難しい。コロニー経営は軌道に乗っていて金も食料も有り余っているのだが、希少資源はそうそう見つからないので金余りの状態になりやすく、そのせいでゲームシステムによって敵の襲撃が強力になってしまう。かと言って金を捨てるといざ希少資源が見つかったときに買えない。つまりゲームシステムにバレない場所に資金を移動させる必要があるのだ。

一つの方法としてはキャラバンに乗せて適当な人員にコロニー外を徘徊させるというのがある。物理的な資産隠しだ。しかしこれはあまりにもゲームを騙してる感が強くポリシーに反するのでダメ。他の方法としては近隣の他勢力に配っちゃうというのがある。もちろんその資金は消滅するのだが、その分友好度が上昇し、いざというときに助けに来てくれたりキャラバンを派遣してくれたりする。

人を働かせて金を集めるシステムを構築したら次はその金を隠したり配ったりというのは嫌なリアリティがあるが、溜め込むくらいなら人に配ってしまえというのはなかなかポジティブな人生訓ではないだろうか。資産を持つことはそれ自体がリスクである。価値は損耗・変動するし、奪われる可能性もある(現代日本の銀行だって1000万円までしか保証してくれないぞ)。それに対して、ロマンティックに言えば友情、システマティックに言えば互恵性の規範に投資するという道もあるぞということをこのゲームは提示しているのだ(厳密に考えると金銭投げつけは友情なのか?という疑問は新たに浮かんでくるが…)。

ちなみに住人の一人であるElk(39歳独身男性・社交能力1)は性欲が強すぎて人妻を含む周囲の女性に声をかけまくり、当然のごとく振られ続けた結果メンタルを悪化させ暴れだしたので逮捕・収監と相成りました。そのうち復帰はさせようと思うけどもう女性と顔を合わせないように深夜シフト固定かなあ。これはネガティブな人生訓。

Next.jsのReact EssentialsのServer Componentsの説明をオレオレ翻訳しながら読む

これの話

https://nextjs.org/docs/getting-started/react-essentials#server-components

正確さではなく(それならAIでいいよね)、自分の理解に引き寄せて翻訳してみる。

Server Components

Server and Client Components があるとサーバーとクライアントの両側にまたがるアプリケーションを作れます。クライアントサイドのリッチなインタラクティビティと伝統的なサーバーレンダリングのパフォーマンスを組み合わせることができます。

Thinking in Server Components

ReactはUI設計の考え方を変えました。同じようにReact Server Componentsは、サーバーとクライアントの両方を活かしたハイブリッドアプリケーションを作るための新しい考え方です。

Reactはこれまで全てをクライアントサイドでレンダリングしていました(SPAというやつです)。しかし、Server Componentsがあると目的に応じてどこでレンダリングするかを決められます。

たとえば、アプリケーションのpageを考えます。

ページをコンポーネントに分割すると、実は大多数のコンポーネントはインタラクティブではないです。つまりサーバーサイドでServer Componentとしてレンダリングできます。そしてその中にインタラクティブなクライアントコンポーネントを点在させるのです。これはNext.jsのサーバーファーストなアプローチと相性がいいです。

Why Server Components?

Server Componentsのメリットは何?という疑問が浮かぶでしょう。

Server Componentsはサーバーのインフラを活用しやすいです。たとえば、巨大な依存パッケージをクライアントに送信する必要がありません。こうなるとReactはPHPやRoRのよう(テンプレートエンジンのように?)に扱えます。

Server Componentsは初期ページ読み込みが早いです。バンドルサイズが小さくなります。根幹部分のクライアント側ランタイムはキャッシュ可能かつサイズの予測が可能で、アプリケーションが成長しても増えません。追加されるJavaScriptは、Client Componentsが使われたときだけ増えます(この辺あんまりわかってない)。

Next.jsでrouteが読み込まれたとき、初期HTMLがサーバーでレンダリングされます。このHTMLはブラウザで段階的に成長し、クライアントがアプリケーションを引き継ぎ、インタラクティビティが付加されます。このためのランタイムの読み込みは非同期的です。

Server Componentsに楽に移行できるように、App Router内のコンポーネントは全てデフォルトでServer Componentsにします。special filesやcolocated componentsも同様です。だからあなたは何もしなくてもServer Componentsを採用して優れたパフォーマンスを得られます。ここに use client directiveを使うことでClient Componentsをオプトインできます。

Client Components

Client Componentsを使うとクライアント側でのインタラクティビティを付加できます。これはNext.jsではサーバーでのpre-renderingとクライアント側でのhydrationで実現しています。Client ComponentsはこれまでのPage Routerと同じ動きです。

The "use client" directive

use client directiveはServer / Client Componentsの境界線を宣言するものです。

'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

"use client" はサーバーとクライアントのコードの間に配置します。ファイルの一番上、importsよりも上に書きます。"use client" が宣言されると、そのファイルがimportしている全てのモジュール(子コンポーネントなど)はクライアントバンドルの一部と認識されます。

デフォルトはServer Componentsなので、 "use client" 宣言がない限り全てはServer Component moduleに含まれます。

豆知識

  • Server Component moduleに含まれるComponentはサーバーでのみレンダリングされることが保証されます
  • Client Componentはクライアントでレンダリングされるものですが、Next.jsは事前にサーバーでレンダリングすることもあります
  • "use client" はファイルの一番上で宣言しなければなりません
  • "use client" は全てのファイルで宣言する必要はなく、Server Componentsとの境界でだけ宣言すれば十分です(そのファイルがimportしているファイルもクライアントバンドルになるので、"use client" が宣言されたファイルはentry pointとみなすことができます)

おわりに

Server Componentsが何なのか知りたくて調べていたんだけど、なんか概念的な記事しかなくてよくわからなくて、この記事もそうだった。完。でも概念はちょっとわかった。

TypeScriptの引数を分割代入したときの ‘A’ is assignable to the constraint of type ‘T’, but ‘T’ could be instantiated with a different subtype of constraint ‘B’ みたいなエラー

T型の引数を受け取ってそのまま返す関数だが、返り値の型をTにすると型エラーになる。

const f1 = <T extends { user: { id: number } }>({ user, ...rest }: T): T => {
  return {
    user,
    ...rest,
  }
}
Type '{ id: number; } & Omit<T, "id">' is not assignable to type 'T'.
  '{ id: number; } & Omit<T, "id">' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ id: number; }'.(2322)

このエラーメッセージが難しい。 T extends { user: { id: number } } に注目する。extendsだからTはたとえば

type T1 = {
  user: {
    id: number;
    name: string;
  }
}

かもしれない(狭い型)。そうだとしても、関数内でのuserの型は { id: number } だ。nameがあることにはならない。TypeScriptならわかるかと思ったがわからないらしい。

Tに何を渡されようと返り値のuserの型は { id: number } (広い型)なので、それを T["user"] (狭い型)に渡しても要求を満たせないことがある。だから型エラーになる。

という話はここに書いてあるのだが
https://github.com/microsoft/TypeScript/issues/33579#issuecomment-534789400

このように書くと引数の型を維持できる。

const f2 = <T extends { user: { id: number } }>({ user, ...rest }: T): Omit<T, "user"> & { user: T["user"] } => {
  return {
    user,
    ...rest,
  }
}

const b = f2({user:{id: 1, name: "chao", premium: false}})
const p = b.user.premium // アクセス可能

TypeScriptくんは書き方によって推論ができたりできなかったりする。かわいい。

最近考えていること@202206

読書

『ザ・ゴール』

スクラムでベロシティを安定化するにはどうしたらよいかで紹介されていたので8割方読んだ。8割方というのは、途中生産管理の話から思考法っぽい話に移ったところで興味を失って止まっているからだ。

この本では問題を抱えた工場の工場長が、学生時代の恩師(作者がモデルのようだ)から断片的なヒントを得ながら、それまでとは全く違う思想の生産管理を取り入れて成功するというストーリーが物語仕立てで描かれる(無職やめ太郎氏のようなものだ)。端的に言えば全員を休みなく働かせるのが最高効率というわけではなく、むしろボトルネックに着目しろという話だったように思う。「ように思う」というのは、教科書ではなく物語だから論理的な筋立てはあまりよくわかっていないからだ。

興味深い読み物ではあったが、単純にソフトウェアエンジニアリングに応用できるかは疑問だ。ソフトウェアエンジニアリングには固定化した生産ラインはないし、在庫コストもないからだ。しかしフロー効率とリソース効率とか、従属事象・統計的変動の概念はなんとなく掴めたので、もうちょっとかっちりした制約理論の教科書を読んでみたいと思った。

『プロを目指す人のためのTypeScript入門』

言わずと知れた有名人uhyo氏の著作。3割くらい読んだ。

僕は既にプロなので9割くらいはもう知ってる知識だ。しかし端々に挟まれる詳細な仕様の知識とか歴史的経緯、さらにuhyo氏の思想などが勉強になる。知っていると思い込んでいるものをもう一度学び直すという意味で価値のある読書だなと思う。思うのだが、やっぱり大体は知ってる話なので退屈になってしまってなかなか読み進められない。

『HTML解体新書』

仕様が広大ゆえに使いこなすのは難しいHTMLの本。全然読めてない。

『データ構造とアルゴリズム(五十嵐健夫)』

連結リストやスタック、木、ハッシュなどから始まり、ソートやグラフや文字列検索なども扱うらしい。これも3割くらい。

C++で実装しながら読み進めている。2-3木は辛かった。コアになるアルゴリズムはシンプルなのだが場合分けがドエラい数になる。平均計算量に関してはある程度計算で求める必要があるが、理解できないほどではなかった(自分で発想しろと言われても無理だが…)。

労働

あまり多くは語れないが、ここ半年くらいはずっと悩んでいる。4年目にもなるとこれまで通りの仕事をしていても学びがなくなってくる。だから更にスコープを広げて何かチャレンジしたいなと思っているけど、なかなかうまくできない。仕事に学びを求めるのが間違いなのかもしれない。

だからまあ、最近本をたくさん買ってみたり個人開発頑張ってみたりというのは、何か突破口が見つからないかなあということですね。

個人開発

最近はもっぱら https://anime.chao.tokyo の開発を進めている。Animetickからしょぼいカレンダー(手入力のアニメ放送予定API)への依存を切って、大量視聴を管理するための可視化などを足してみたいなという狙い。

使用技術はRemix+PrismaでEC2に雑に(DBもEC2で動いてるw)立てている。バックエンドの処理をパイプラインに見立ててfp-tsで書いているのが自分的こだわり。

飽きたら次にやってみたいのはweb componentsとかかなあ。Reactを捨てて生のHTMLと最低限のJSで何ができるのかというところを勉強してみたい。あるいはなんかOSSとかも。GoやRustも触っておきたい。学ぶべきことはいくらでもある。その気になればTypeScript一本でなんでも書けてしまうので、意図的にコンフォートゾーンの外に出るようにはしたい。

料理

以前から美味しいものを食べるなら外食で、自炊は楽で身体に悪くないものというポリシーでやっている。最近は以下のセットで固まっている。

  • ご飯
  • 味噌汁
  • 自作サラダチキン
  • ブロッコリーの惣菜(冷凍食品)
  • スーパーで買ってきたサラダ(ごぼう・コールスロー・切り干し大根のローテーション)
  • 納豆
  • ヨーグルト

サラダチキンを切らしたときは適当に惣菜買ってきたり。ここから50年くらいずっと同じものを食べ続ける可能性すらあるので、自炊は変に偏らないようには気をつけたい(何食っても塩分過多で怒るのであすけんは嫌いです)。

健康

3月末に右足首を捻挫して結構長く歩行で痛んだり疲れやすかったりした。ようやく治りつつある。

アニメ視聴

まあ、そこそこ見てます。

Dota2

ちょっとやる気なくなってます。4月頃は調子よくて3880まで上がったけどしばらくやらなくなって今3430。

自分の持ちキャラのメタ変動の話をすると、Dazzleの7.31の変化が気に入らない。タイミングの概念を捨てて単にCD上がるたびにスキル撃つのを推奨するようなメカニクスになっていて、こんなの人間がプレイする必要ないじゃんと思ってしまう。

ということでプロの間で大流行していたPugnaばかり使っていた。癖はあるもののスキルセットが強力。サポートでも積極的にタワーを折れる1番、SavingにもDisableにもなる2番、設置するだけで大きなダメージを叩き出せるNether Ward、そしてダメージにも大回復にもなるUlt。Shardは弱いと思うけど…(相手にPLかNagaがいたら買うかも)。

ただPugnaも7.31dでかなりお仕置きを受けてしまったのでやめる。またDazzleに戻ることも検討しているが、単に数字上のbuffがあったというだけでメカニクスは改善していないので悩ましい。あるいはEnchantressもいいかもしれない。これもプロで猛威を奮っていたので7.31cでEnchantにレベルキャップがついてしまったが(なかったほうがおかしいだろ!)、それでもタンクやラットができるサポートというのは貴重だ。

Stunがないサポートは嫌がられるのでHoodwinkを使っていた時期もあったが肌に合わず全然勝てなかったのでやめてしまった。

2/18~2/24 在宅勤務/過剰な健康への不安

在宅勤務

すでに発表されている通り弊社は在宅勤務期間中だ。僕はソフトウェアエンジニアの一般社員なので仕事に占めるコミュニケーションの割合が低く、在宅勤務でもそこまで不便を感じていない。エンジニア職以外の事情はよくわからないし、エンジニアでもリーダー級以上になるとミーティングが多くあるのでなかなか大変なところがあるのではと思う。

あえて不便なところを挙げると、オフィスが使えない分自宅の環境に左右されるところか。自宅のPCデスクはかなりリソースを投じて強化してきたので問題ないどころか会社以上に快適。だがネット回線は時間帯によって不安定でイライラする。自宅の周りに飲食店がないので食事に変化がなくなるし(自炊のレパートリーが少ない)、運動不足にもなる。通勤が不要なぶん1日あたり80分くらい時間が浮いてるので、その時間を使って散歩でもすればいいのか。でも花粉のシーズンだからなあ。

過剰な健康への不安

先週の虫歯の処置以降、なんでもない歯の感覚がどれも虫歯の兆候なのではと思えてしまって不安だ。歯医者では処置した歯以外は問題ないと言われているが、それもあまり信用できない気分。一般論として医者は信用すべきなんだが。

もともと僕は健康を損ないたくない、特に永続的にダメージが残るような病気にはなりたくないという心配が強すぎる。年齢的に考えればこれから健康は失っていく一方だというのに、バカバカしい。

ちゃおネイキッドプロジェクトの進展

自動デプロイ

Spotify機能のためにredisを立ててdocker-composeで連携するようにした。これに伴いデプロイの手順が変わったので.drone.ymlを調整した。docker runに複雑なオプションを載せまくるよりもdocker-compose.ymlに書いてしまう方が楽だし1コンテナでもdocker-composeを使うべきなのかもしれない。

CSSレイアウトのお勉強

Spotifyで聴いている曲を表示できるようになったので、画像・タイトル・アーティストをいい感じにレイアウトしたくてCSSのお勉強をした。MDNのドキュメントが大変参考になった。というかCSS系の情報はググってもノイズが多すぎる。

勉強のためにstyled-componentsでCSSをTSの中で記述してみた。ファイルの中身がカオスになる感覚は否めないが、コンポーネントとスタイルの結びつきが強くなるのは使いどころによっては便利なのだろう。特にwebpackを弄らずとも導入できるのはよい。

bundleサイズ削減

bundle analyzerで見てみるとmomentのタイムゾーンとかlocaleのファイルがすごい容量食っててビビったのでググって削減した。効果が数字で見えるのは楽しいのだが、webpack.config.jsがどんどん複雑になっていくのはつらい。

今後の野望

  • 監視・ロギングの強化
  • 様々な自動投稿機能がついたTwitterタイムラインのようなサービスにしていきたい

SpotifyAPI実装記

2/11(火)

自宅にこもってSpotifyNowPlayingをホームページに表示する機能を作っていた。

Spotifyのaccess_tokenをredisで保持することにしたのだが、そうすると本番環境にもredisを用意せねばならない。docker-composeで連携させることになるだろうが、デプロイフローをまるごと変えなければならないので大変だ。

クライアントとサーバーのアプリケーションをどういうリポジトリ構成で管理するかも難しい問題だ。クライアントはReact、サーバーはexpressなので両方ともTypeScriptであり、型の共有などのメリットは活用したいが、両者を近づけていくとビルド周りの設定が煩雑になりそうだ。

既にサーバー側のビルドがなぜかクライアント側の型エラーで落ちる現象が起きている。おそらくtsconfigを共有しているせいでクライアント側のコードまでincludeしてしまっているからだろう。

OAuth認証も難しい。僕のSpotifyアカウントでリソースへのアクセスを許可する必要があり、これはブラウザからしかできない。よってその工程までは手作業で行ってからaccess_codeをconfに記入し、アプリケーションを起動することになる。

適当にパソコンをポチポチしていた

2/1(土)

していたら土曜日が終わった。

OpenToonzの1.4.0が出たのでLinuxでビルドしてみた。ドキュメント通りで特に問題なし。

動画サイトの再生エリアのスクショをワンクリックで撮れるようにしたくて調べていたんだけど、ざっくりMDNを読んだ感じセキュリティ上の理由でクライアント側だけではどうにもならないっぽい。

その他、寝たり起きたり鍋作ったりDota2したりしてたら1日が終わってた。僕は休日は無計画に浪費する方が好きだ。あまり褒められたことではないが…。

カレーうどん界の動向

1/25(土)

同僚と外出。昼に千吉のカレーうどんを食べた。カレーうどんの専門店が存在するとは思っていなかった。

Dota2のプレイ履歴が見られるOpenDotaというサービスにちょっとしたバグがあり、OSSだったのでPRを出しマージされた。intの0が入りうる箇所で !hoge という書き方でvalidationをしているので0がinvalid扱いされていた。生のJSは辛い。

Dota2は調子がいい。7.23の環境にようやく体が慣れてきた。8連勝中。今は大規模な大会も行われていてアツい。

TypeScript Interfaces メモ

1/9(木)

http://www.typescriptlang.org/docs/handbook/interfaces.html

Introduction

  • TSはduck typingでありstructural subtypingである

Our First Interface

  • TSは要求されているプロパティがあるかだけをチェックする

Optional Properties

  • プロパティ名の後ろに?をつけるとoptionalになる

Readonly properties

  • プロパティ名の前にreadonlyをつけると書き換え不可になる
  • ReadonlyArray<T> というやつもあるぞ

Excess Property Checks

  • 要求されているプロパティがあるかだけをチェックする とoptionalを組み合わせると、optional propertyのプロパティ名のtypoが型エラーにならなくなる
  • でもtypoはバグとして検出したい…検出したくない?
  • なのでTSはプロパティ名を手書きする(リテラル)ときは特別に excess property checking をする
    • Object literal may only specify known properties, but 'hoge' does not exist in type 'Fuga'.
  • excess property checking を回避する方法
    • as
    • interfaceの方に [propName: string]: any; を足しておく
    • 一度変数に入れる