<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ja">
  <title>Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発</title>
  
  <subtitle>しまなみ海道を実際に歩き・自転車・ツーリングで巡りながら、ルートやスポット情報を検証し、Web技術でサイトとして構築していく開発ログ</subtitle>
  
  <link href="https://mojitonews.hateblo.jp/"/>
  <updated>2026-05-02T13:52:46+09:00</updated>
  <author>
    <name>morningglorycloud0203</name>
  </author>
  <generator uri="https://blog.hatena.ne.jp/" version="f55d41c1f1e9d553c20fd41f441dc0">Hatena::Blog</generator>
  <id>hatenablog://blog/10328537792363370792</id>

  
    
    
    <entry>
        <title> `useEffect` の第2引数（依存配列）を空にしていい場面・してはいけない場面——しまなみガイドの実装から整理した</title>
        <link href="https://mojitonews.hateblo.jp/entry/useeffect-deps-array-patterns"/>
        <id>hatenablog://entry/17179246901383223026</id>
        <published>2026-05-02T13:52:46+09:00</published>
        <updated>2026-05-02T13:52:46+09:00</updated>        <summary type="html">useEffect の第2引数（依存配列）を空にしていい場面・してはいけない場面を、しまなみ海道ガイドサイトの実装コードをもとに4パターンで整理した記事です。stale closure 問題や ESLint の exhaustive-deps ルールについても解説しています。</summary>
        <content type="html">&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;しまなみ海道の観光ガイドサイトを個人で開発しています。カスタムフックを書くたびに &lt;code&gt;useEffect&lt;/code&gt; の第2引数（依存配列）をどう書くか迷いました。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;useEffect(() =&amp;gt; {
  // 何か処理
}, [])          // ← これは正しいのか？&lt;/pre&gt;


&lt;p&gt;「とりあえず &lt;code&gt;[]&lt;/code&gt; にしとけばいい」と思っていた時期があります。実際には &lt;code&gt;[]&lt;/code&gt; が正しい場面と、&lt;code&gt;[]&lt;/code&gt; にしてはいけない場面があります。&lt;/p&gt;

&lt;p&gt;このサイトで書いたカスタムフックを素材に、4パターンに整理しました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;まず依存配列とは何か&quot;&gt;まず：依存配列とは何か&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt; の第2引数は「この値が変わったときに再実行する」というリストです。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;useEffect(() =&amp;gt; {
  // 処理
}, [a, b])  // a または b が変わったときに再実行&lt;/pre&gt;


&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[]&lt;/code&gt;（空）&lt;/strong&gt; → マウント時に1回だけ実行。以降は再実行しない&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;[a, b]&lt;/code&gt;&lt;/strong&gt; → &lt;code&gt;a&lt;/code&gt; か &lt;code&gt;b&lt;/code&gt; が変わるたびに再実行&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;省略（第2引数なし）&lt;/strong&gt; → 毎レンダリングのたびに実行&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;パターン1-でいい場面初回だけ取得すればいい静的データ&quot;&gt;パターン1：&lt;code&gt;[]&lt;/code&gt; でいい場面——「初回だけ取得すればいい」静的データ&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/map/useHotelsData.ts
useEffect(() =&amp;gt; {
  let cancelled = false

  ;(async () =&amp;gt; {
    const res = await fetch(&amp;#39;/data/hotels.json&amp;#39;, { cache: &amp;#39;no-store&amp;#39; })
    if (!cancelled) setHotels(await res.json())
  })().catch(() =&amp;gt; {})

  return () =&amp;gt; { cancelled = true }
}, [])  // ← [] で正しい&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useHotelsData&lt;/code&gt; はホテルの GeoJSON ファイルをフェッチするフックです。フェッチ対象の URL は固定で、props や state に依存しません。「マウント時に1回取得すれば十分」なケースは &lt;code&gt;[]&lt;/code&gt; が正解です。&lt;/p&gt;

&lt;p&gt;同じパターンは &lt;code&gt;useBusTimetable&lt;/code&gt;（バス時刻表）でも使っています。どちらも「URL が変わることはない・ページを開いたときに1回取得すれば十分」というデータです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;[]&lt;/code&gt; を使っていい条件：&lt;/strong&gt;
- effect 内で参照している値がコンポーネントの外（定数・モジュールスコープ）にある
- effect 内で参照している値が props や state ではない
- 「何かが変わったら再実行したい」という要件がない&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;パターン2依存値-が必要な場面URLが変わったら取り直す&quot;&gt;パターン2：&lt;code&gt;[依存値]&lt;/code&gt; が必要な場面——「URLが変わったら取り直す」&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/map/useMapStyle.ts
export function useMapStyle(styleUrl: string) {
  const [styleSpec, setStyleSpec] = useState&amp;lt;MapboxStyle | null&amp;gt;(null)

  useEffect(() =&amp;gt; {
    const ac = new AbortController()
    setStyleSpec(null)
    ;(async () =&amp;gt; {
      try {
        const spec = await fetchAndLocalizeStyle(styleUrl, ac.signal)
        if (!ac.signal.aborted) setStyleSpec(spec)
      } catch { /* noop */ }
    })()
    return () =&amp;gt; ac.abort()
  }, [styleUrl])  // ← styleUrl が変わるたびに再実行

  return { styleSpec }
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useMapStyle&lt;/code&gt; は地図のスタイルURL（衛星モード・通常モードなど）を受け取ってスタイル定義を取得するフックです。ユーザーがレイヤーを切り替えると &lt;code&gt;styleUrl&lt;/code&gt; が変わるため、&lt;code&gt;[styleUrl]&lt;/code&gt; にしています。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[]&lt;/code&gt; にしていたら、スタイルを切り替えても最初のURLのスタイルを使い続けます。&lt;/p&gt;

&lt;p&gt;また &lt;code&gt;return () =&amp;gt; ac.abort()&lt;/code&gt; でクリーンアップしています。&lt;code&gt;styleUrl&lt;/code&gt; が変わって effect が再実行されるとき、前回の fetch を中断するためです。&lt;strong&gt;依存配列に値を入れたら、クリーンアップも一緒に書く&lt;/strong&gt;のがセットです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;パターン3query-が必要な場面設定が変わったらリスナーを張り直す&quot;&gt;パターン3：&lt;code&gt;[query]&lt;/code&gt; が必要な場面——「設定が変わったらリスナーを張り直す」&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/useIsMobile.ts
export function useMediaQuery(query: string) {
  const [matches, setMatches] = useState(false)

  useEffect(() =&amp;gt; {
    if (typeof window === &amp;#39;undefined&amp;#39;) return
    const mql = window.matchMedia(query)
    const onChange = () =&amp;gt; setMatches(mql.matches)
    onChange()
    mql.addEventListener(&amp;#39;change&amp;#39;, onChange)
    return () =&amp;gt; mql.removeEventListener(&amp;#39;change&amp;#39;, onChange)
  }, [query])  // ← query が変わるたびにリスナーを張り直す

  return matches
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useMediaQuery&lt;/code&gt; はメディアクエリ文字列（&lt;code&gt;&quot;(max-width: 768px)&quot;&lt;/code&gt; など）を受け取ってマッチ状態を返すフックです。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;query&lt;/code&gt; が変わったとき、&lt;code&gt;[]&lt;/code&gt; にしていると前の &lt;code&gt;query&lt;/code&gt; に対するリスナーが残り続けます。&lt;code&gt;[query]&lt;/code&gt; にすることで、&lt;code&gt;query&lt;/code&gt; が変わると前のリスナーを外して（&lt;code&gt;removeEventListener&lt;/code&gt;）新しい &lt;code&gt;query&lt;/code&gt; のリスナーを張り直します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;パターン4複数の依存値渡された値が変わるたびに再セットアップ&quot;&gt;パターン4：複数の依存値——「渡された値が変わるたびに再セットアップ」&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/map/useHotelMarkers.ts（抜粋）
useEffect(() =&amp;gt; {
  if (!map) return

  // syncOnce・各ハンドラはすべて effect 内部で定義（依存配列への追加不要）
  const syncOnce = (): void =&amp;gt; { /* マーカーの差分更新処理 */ }
  const onMoveEnd = (): void =&amp;gt; { syncOnce() }
  const onIdle = (): void =&amp;gt; { syncOnce() }

  map.on(&amp;#39;moveend&amp;#39;, onMoveEnd)
  map.on(&amp;#39;idle&amp;#39;, onIdle)

  return () =&amp;gt; {
    map.off(&amp;#39;moveend&amp;#39;, onMoveEnd)
    map.off(&amp;#39;idle&amp;#39;, onIdle)
    // マーカーを全削除
  }
}, [map, sourceId, idProp, thumbProp, nameProp, onPinClick])&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useHotelMarkers&lt;/code&gt; はホテルのマーカーを地図に表示するフックです。&lt;code&gt;map&lt;/code&gt;（Mapboxのインスタンス）・&lt;code&gt;sourceId&lt;/code&gt;（データソースID）・各種設定を受け取ります。これらのどれかが変わったら、リスナーを外してマーカーを削除し、新しい設定で再セットアップが必要です。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onMoveEnd&lt;/code&gt; / &lt;code&gt;onIdle&lt;/code&gt; は effect の&lt;strong&gt;内部で定義&lt;/strong&gt;しているため依存配列には含めません。依存配列に書くのは「&lt;strong&gt;effect の外から来た値&lt;/strong&gt;」だけです。effect 内部で作った変数・関数は毎回新しく作られるので、依存配列に入れる意味がありません。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;なぜ--にしてはいけない場面があるのかstale-closure-問題&quot;&gt;なぜ &lt;code&gt;[]&lt;/code&gt; にしてはいけない場面があるのか：stale closure 問題&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;[]&lt;/code&gt; を誤って使うと「stale closure（古い値を参照し続ける）」というバグが起きます。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// ❌ よくある間違い
function SearchBox({ query }: { query: string }) {
  const [result, setResult] = useState(null)

  useEffect(() =&amp;gt; {
    fetch(`/api/search?q=${query}`)  // query を参照しているのに
      .then(r =&amp;gt; r.json())
      .then(setResult)
  }, [])  // ← [] にしている → 最初の query 値を永遠に使い続ける
}&lt;/pre&gt;


&lt;p&gt;この例では &lt;code&gt;query&lt;/code&gt; が変わっても effect が再実行されないため、初回マウント時の &lt;code&gt;query&lt;/code&gt; 値でフェッチし続けます。&lt;/p&gt;

&lt;p&gt;正しくは：&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// ✅ 依存配列に query を入れる
useEffect(() =&amp;gt; {
  fetch(`/api/search?q=${query}`)
    .then(r =&amp;gt; r.json())
    .then(setResult)
}, [query])  // query が変わるたびにフェッチする&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;ESLint-の-react-hooksexhaustive-deps-を使う&quot;&gt;ESLint の &lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; を使う&lt;/h2&gt;

&lt;p&gt;どの値を依存配列に入れるべきか、人間が毎回判断するのは難しいです。ESLint の &lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; ルールを有効にすると、「依存配列に入れるべき値が漏れている」ときに警告してくれます。&lt;/p&gt;

&lt;p&gt;Next.js 15 はデフォルトで flat config（&lt;code&gt;eslint.config.mjs&lt;/code&gt;）を採用しており、&lt;code&gt;next/core-web-vitals&lt;/code&gt; を継承するだけで &lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; が&lt;strong&gt;すでに有効&lt;/strong&gt;になっています。&lt;/p&gt;

&lt;pre class=&quot;code js&quot; data-lang=&quot;js&quot; data-unlink&gt;// eslint.config.mjs（Next.js 15 のデフォルト）
import { FlatCompat } from &amp;#39;@eslint/eslintrc&amp;#39;

const compat = new FlatCompat({ baseDirectory: __dirname })

export default [
  ...compat.extends(&amp;#39;next/core-web-vitals&amp;#39;, &amp;#39;next/typescript&amp;#39;),
]
// ↑ next/core-web-vitals に react-hooks/exhaustive-deps が含まれる&lt;/pre&gt;


&lt;p&gt;追加設定なしで警告が出るので、このルールが出ているときは &lt;code&gt;// eslint-disable-line&lt;/code&gt; で黙らせる前に「本当に &lt;code&gt;[]&lt;/code&gt; でいいのか」を一度考える習慣にしています。&lt;/p&gt;

&lt;p&gt;「&lt;code&gt;[]&lt;/code&gt; でいい理由」が説明できるなら &lt;code&gt;[]&lt;/code&gt; で問題ありません。説明できないなら、依存配列に値を追加するのが正解です。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4パターンの整理&quot;&gt;4パターンの整理&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; パターン &lt;/th&gt;
&lt;th&gt; 依存配列 &lt;/th&gt;
&lt;th&gt; 使う場面 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; 静的データのフェッチ &lt;/td&gt;
&lt;td&gt; &lt;code&gt;[]&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; URL固定・マウント時1回で十分（&lt;code&gt;useHotelsData&lt;/code&gt;） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; props が変わったら再フェッチ &lt;/td&gt;
&lt;td&gt; &lt;code&gt;[url]&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; URLが外から渡される（&lt;code&gt;useMapStyle&lt;/code&gt;） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 外部リスナーの張り直し &lt;/td&gt;
&lt;td&gt; &lt;code&gt;[query]&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; 設定が変わったらリスナーを付け替え（&lt;code&gt;useMediaQuery&lt;/code&gt;） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 複数設定の再セットアップ &lt;/td&gt;
&lt;td&gt; &lt;code&gt;[map, sourceId, ...]&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; 渡された全設定に追従（&lt;code&gt;useHotelMarkers&lt;/code&gt;） &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;依存配列の選び方の判断基準は1つです。&lt;strong&gt;「effect 内で外部から来た値を参照しているか」&lt;/strong&gt;。参照していたら依存配列に入れる。参照していなければ &lt;code&gt;[]&lt;/code&gt; でいい。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;実際に動いているサイトはこちらです。今回紹介したフックはすべてこのマップ機能の中で動いています。
&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;&amp;#x5730;&amp;#x56F3;&amp;#x3067;&amp;#x63A2;&amp;#x3059;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053; &amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fcustom-hooks-useismobile-usegeolocate-usecloseall&quot; title=&quot;カスタムフックで「どこで何が動くか」を整理——useIsMobile / useGeolocate / useCloseAll の設計 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/custom-hooks-useismobile-usegeolocate-usecloseall&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fuse-callback-when-to-use&quot; title=&quot;useCallback が必要な場面・不要な場面——「とりあえず入れとく」はパフォーマンスを悪化させる - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/use-callback-when-to-use&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <link rel="enclosure" href="https://ogimage.blog.st-hatena.com/10328537792363370792/17179246901383223026/1777697566" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title> `Promise.all` で複数APIを並列fetchしつつ、1件失敗しても残りを使う設計——しまなみガイドのバス時刻表実装</title>
        <link href="https://mojitonews.hateblo.jp/entry/promise-all-partial-success-bus-timetable"/>
        <id>hatenablog://entry/17179246901382150696</id>
        <published>2026-04-29T23:04:15+09:00</published>
        <updated>2026-04-29T23:04:15+09:00</updated>        <summary type="html">複数のバス事業者の時刻表JSONを並列で取得する際、1件が404でも残りのデータは表示したい——という要件を Promise.all の内側 try/catch で null を返す設計で解決した記録です。Promise.allSettled との比較、このパターンを使うべきでない場面、cancelled フラグによるクリーンアップ、force-cache の使い分けまで、しまなみ海道観光ガイドサイトの実装を題材に整理しました</summary>
        <content type="html">&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;しまなみ海道の観光ガイドサイトを個人で開発しています。マップ上にバス停を表示し、タップすると時刻表を見られる機能を実装したとき、&lt;code&gt;Promise.all&lt;/code&gt; の使い方でひとつ判断が必要になりました。&lt;/p&gt;

&lt;p&gt;このサイトはしまなみ海道エリアをカバーするため、複数のバス事業者の時刻表データを扱います。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;中国バス&lt;/li&gt;
&lt;li&gt;尾道バス&lt;/li&gt;
&lt;li&gt;井笠バス&lt;/li&gt;
&lt;li&gt;鞆鉄バス&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;4事業者それぞれの JSON ファイルを読み込んで、ひとつにマージして使います。このとき「4件を並列で取得したいが、1件が404でも残り3件は表示したい」という要件がありました。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;左：バス停レイヤーOFF、右：バス停レイヤーON。4事業者のデータを並列フェッチし、取得できた分だけマージして表示している。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260429/20260429230036.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30AC;&amp;#x30A4;&amp;#x30C9;&amp;#x30B5;&amp;#x30A4;&amp;#x30C8;&amp;#x306E;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#x753B;&amp;#x9762;&amp;#x3002;&amp;#x5DE6;&amp;#x5074;&amp;#x306F;&amp;#x30D0;&amp;#x30B9;&amp;#x505C;&amp;#x3092;&amp;#x975E;&amp;#x8868;&amp;#x793A;&amp;#x306B;&amp;#x3057;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&amp;#x3001;&amp;#x53F3;&amp;#x5074;&amp;#x306F;&amp;#x30D0;&amp;#x30B9;&amp;#x505C;&amp;#x30D4;&amp;#x30F3;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&quot; width=&quot;764&quot; height=&quot;667&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;左：バス停レイヤーOFF、右：バス停レイヤーON。4事業者のデータを並列フェッチし、取得できた分だけマージして表示している。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;素直に &lt;code&gt;Promise.all&lt;/code&gt; を書くと、1件でも失敗した瞬間に全体がエラーになります。この記事はその問題をどう解決したかの記録です。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;Promiseall-の1件失敗で全滅問題&quot;&gt;&lt;code&gt;Promise.all&lt;/code&gt; の「1件失敗で全滅」問題&lt;/h2&gt;

&lt;p&gt;まず基本動作の確認です。&lt;code&gt;Promise.all&lt;/code&gt; は渡したすべての Promise が成功したときだけ解決します。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const results = await Promise.all([
  fetch(&amp;#39;/data/chugokubus/stop-times.json&amp;#39;).then(r =&amp;gt; r.json()),
  fetch(&amp;#39;/data/onomichibus/stop-times.json&amp;#39;).then(r =&amp;gt; r.json()),
  fetch(&amp;#39;/data/ikasabus/stop-times.json&amp;#39;).then(r =&amp;gt; r.json()),
  fetch(&amp;#39;/data/tomotetsubus/stop-times.json&amp;#39;).then(r =&amp;gt; r.json()),
])&lt;/pre&gt;


&lt;p&gt;4事業者のうち1事業者のファイルがまだ存在しなかった（404）場合、&lt;code&gt;Promise.all&lt;/code&gt; 全体が reject されます。残り3事業者のデータも捨てられます。&lt;/p&gt;

&lt;p&gt;今回はデータ追加を順次進めている途中で、まだファイルが存在しない事業者があります。「404のものは警告だけ出してスキップ、存在するものだけマージ」という動作が必要でした。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;解決策内側で-catch-して-null-を返す&quot;&gt;解決策：内側で catch して &lt;code&gt;null&lt;/code&gt; を返す&lt;/h2&gt;

&lt;p&gt;解決策はシンプルです。&lt;strong&gt;各 fetch を個別の try/catch で囲み、失敗したら &lt;code&gt;null&lt;/code&gt; を返す&lt;/strong&gt;。&lt;code&gt;Promise.all&lt;/code&gt; には「絶対に reject しない Promise」だけを渡します。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const results = await Promise.all(
  STOP_TIMES_SOURCES.map(async (src) =&amp;gt; {
    try {
      const res = await fetch(src.url, { cache: &amp;#39;force-cache&amp;#39; })

      if (res.status === 404) {
        console.warn(`[useBusTimetable] not found: ${src.label}`)
        return null  // ← 失敗ではなく null を返す
      }

      if (!res.ok) {
        console.warn(`[useBusTimetable] HTTP ${res.status}: ${src.label}`)
        return null
      }

      const json = await res.json() as BusStopTimetableByStop | null
      if (!json || typeof json !== &amp;#39;object&amp;#39;) {
        console.warn(`[useBusTimetable] invalid JSON: ${src.label}`)
        return null
      }

      return json  // ← 成功したものだけ返る

    } catch (e) {
      console.warn(`[useBusTimetable] error: ${src.label}`, e)
      return null  // ← ネットワークエラーなどもここで吸収
    }
  })
)
// results の型: (BusStopTimetableByStop | null)[]
// Promise.all 自体は絶対に reject しない&lt;/pre&gt;


&lt;p&gt;このパターンの肝は「&lt;code&gt;Promise.all&lt;/code&gt; の外側に reject を漏らさない」ことです。各 async 関数が成功なら &lt;code&gt;json&lt;/code&gt;、失敗なら &lt;code&gt;null&lt;/code&gt; を返すことで、&lt;code&gt;results&lt;/code&gt; は必ず &lt;code&gt;(T | null)[]&lt;/code&gt; になります。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;null-を除いてマージする&quot;&gt;null を除いてマージする&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;results&lt;/code&gt; には成功した事業者のデータと &lt;code&gt;null&lt;/code&gt; が混在しています。&lt;code&gt;null&lt;/code&gt; を飛ばしながらマージします。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const merged: BusStopTimetableByStop = {}

for (const json of results) {
  if (!json) continue  // null はスキップ
  for (const [stopId, record] of Object.entries(json)) {
    merged[stopId] = record
  }
}

const hasAnyData = Object.keys(merged).length &amp;gt; 0

if (hasAnyData) {
  setBusTimetables(merged)
  setError(null)
} else {
  // 全事業者が失敗したときだけエラー表示
  setBusTimetables(null)
  setError(&amp;#39;バス時刻表データの読み込みに失敗しました&amp;#39;)
}&lt;/pre&gt;


&lt;p&gt;「全事業者が失敗した」ときだけ &lt;code&gt;error&lt;/code&gt; をセットし、1件でも成功していればユーザーにはエラーを見せません。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;PromiseallSettled-との違い&quot;&gt;&lt;code&gt;Promise.allSettled&lt;/code&gt; との違い&lt;/h2&gt;

&lt;p&gt;同じことを &lt;code&gt;Promise.allSettled&lt;/code&gt; でも書けます。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const results = await Promise.allSettled(
  STOP_TIMES_SOURCES.map(src =&amp;gt; fetch(src.url).then(r =&amp;gt; r.json()))
)

for (const result of results) {
  if (result.status === &amp;#39;fulfilled&amp;#39;) {
    // result.value を使う
  } else {
    // result.reason がエラー
  }
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;Promise.allSettled&lt;/code&gt; は ES2020 で追加されたメソッドで、全件が settle（成功または失敗）するまで待ち、&lt;code&gt;{ status: &#39;fulfilled&#39; | &#39;rejected&#39;, value/reason }&lt;/code&gt; の配列を返します。&lt;/p&gt;

&lt;p&gt;今回 &lt;code&gt;Promise.allSettled&lt;/code&gt; を使わなかった理由は2つです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1つ目：ステータスコード単位の処理を細かく書きたかった。&lt;/strong&gt;
&lt;code&gt;Promise.allSettled&lt;/code&gt; の &lt;code&gt;rejected&lt;/code&gt; はネットワークエラーやパースエラーをひとまとめにします。404 だけ &lt;code&gt;warn&lt;/code&gt; で通過させて他のエラーコードは別処理、という分岐を書くには内側 catch のほうが素直でした。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2つ目：型が少し手間になる。&lt;/strong&gt;
&lt;code&gt;PromiseSettledResult&amp;lt;T&amp;gt;&lt;/code&gt; を受け取る場合、&lt;code&gt;fulfilled&lt;/code&gt; かどうかをナローイングしてから &lt;code&gt;value&lt;/code&gt; にアクセスする必要があります。内側 catch で &lt;code&gt;T | null&lt;/code&gt; に統一しておくほうが後続処理がシンプルになりました。&lt;/p&gt;

&lt;p&gt;どちらが正解というわけではありません。「fetch の成否だけを見る場合」なら &lt;code&gt;Promise.allSettled&lt;/code&gt; のほうが意図が明確です。「ステータスコードごとに細かく分岐したい場合」は内側 catch パターンのほうが書きやすいと感じました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;クリーンアップcancelled-フラグ&quot;&gt;クリーンアップ：&lt;code&gt;cancelled&lt;/code&gt; フラグ&lt;/h2&gt;

&lt;p&gt;フックのクリーンアップも必要です。コンポーネントがアンマウントされた後に &lt;code&gt;setState&lt;/code&gt; を呼ぶと React が警告を出します（React 18 以降は警告なしですが、後続処理が走り続けるのは無駄です）。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;useEffect(() =&amp;gt; {
  let cancelled = false

  ;(async () =&amp;gt; {
    try {
      const results = await Promise.all(/* ... */)

      if (cancelled) return  // アンマウント済みなら setState しない

      // マージ・setState の処理
    } catch (e) {
      // fetch エラーは内側 try/catch で null を返すので、
      // ここに来るのは設計バグや setState 周りの想定外例外
      console.error(&amp;#39;[useBusTimetable] unexpected error:&amp;#39;, e)
      if (!cancelled) {
        setBusTimetables(null)
        setError(&amp;#39;バス時刻表データの読み込みに失敗しました&amp;#39;)
      }
    }
  })().catch(() =&amp;gt; {})
  // outer try/catch が先にエラーを拾うため、
  // 末尾の .catch(() =&amp;gt; {}) は「万が一 outer try/catch すら抜けた場合」の最後の安全網

  return () =&amp;gt; {
    cancelled = true  // クリーンアップ時にフラグを立てる
  }
}, [])&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;AbortController&lt;/code&gt; を使ってフェッチ自体をキャンセルする方法もあります。今回は静的 JSON ファイルのフェッチで、キャンセルの恩恵が小さいため &lt;code&gt;cancelled&lt;/code&gt; フラグだけにしています。重い API コールや長時間のストリームには &lt;code&gt;AbortController&lt;/code&gt; が適しています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;cache-force-cache-で2回目以降を高速化&quot;&gt;&lt;code&gt;cache: &#39;force-cache&#39;&lt;/code&gt; で2回目以降を高速化&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const res = await fetch(src.url, { cache: &amp;#39;force-cache&amp;#39; })&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;force-cache&lt;/code&gt; はブラウザのHTTPキャッシュを最大限に利用するオプションです。一度ダウンロードした JSON はキャッシュから返るため、マップを開き直しても再ネットワーク通信が走りません。&lt;/p&gt;

&lt;p&gt;バス時刻表は日次更新がなく、デプロイ時にのみ変わるファイルなので &lt;code&gt;force-cache&lt;/code&gt; が適切です。リアルタイム性が必要なデータには &lt;code&gt;no-store&lt;/code&gt; や &lt;code&gt;no-cache&lt;/code&gt; を使います。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;全体像&quot;&gt;全体像&lt;/h2&gt;

&lt;p&gt;最終的なフックの構造をまとめるとこうなります。&lt;/p&gt;

&lt;pre class=&quot;code&quot; data-lang=&quot;&quot; data-unlink&gt;useEffect
  └── async IIFE
        └── Promise.all([
              fetch A → try/catch → T | null,
              fetch B → try/catch → T | null,
              fetch C → try/catch → T | null,
              fetch D → try/catch → T | null,
            ])
            ↓ (T | null)[] が必ず返る
            null を除いてマージ
            ↓
            1件以上成功 → setBusTimetables(merged)
            全件失敗   → setError(&amp;#34;失敗しました&amp;#34;)
  return () =&amp;gt; { cancelled = true }&lt;/pre&gt;


&lt;p&gt;呼び出し側はシンプルです：&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const { busTimetables, error } = useBusTimetable()&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;busTimetables&lt;/code&gt; が &lt;code&gt;null&lt;/code&gt; でなければマップのバス停タップ時に時刻表を表示し、&lt;code&gt;null&lt;/code&gt; のときはバス停を非表示にします。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;実際に動いているものはこちらで確認できます。マップ右上のレイヤー切替でバス停を有効にするとバス停ピンが表示されます。
&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;&amp;#x5730;&amp;#x56F3;&amp;#x3067;&amp;#x63A2;&amp;#x3059;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053; &amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-hooks-map-site-useref-usecallback-abortcontroller&quot; title=&quot;useRef・useCallback・AbortController——しまなみ海道観光サイトに組み込んだ地図機能で&amp;quot;stateに入れない・再生成させない・止める&amp;quot;を実践した話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-hooks-map-site-useref-usecallback-abortcontroller&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fasync-await-then-chain-usage-map-site&quot; title=&quot;async/await と .then() チェーン——しまなみ海道観光サイトで両方使い分けた理由 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/async-await-then-chain-usage-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="非同期処理" label="非同期処理" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/ebd94c18dfbdb06693d62e8433387fe27d899a2e/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260429%2F20260429230036.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>ライブラリなしでスワイプ対応 BottomSheet を React に実装した——CSSトランジションが「一発で動かない理由」と double rAF trick</title>
        <link href="https://mojitonews.hateblo.jp/entry/bottomsheet-from-scratch-double-raf"/>
        <id>hatenablog://entry/17179246901381030853</id>
        <published>2026-04-27T06:41:53+09:00</published>
        <updated>2026-04-27T06:41:53+09:00</updated>        <summary type="html">Radix UI や vaul を使わず、React + TypeScript で BottomSheet をゼロから実装しました。CSSトランジションが動かない原因と double rAF による解決、スワイプ速度判定、inert 属性、フォーカス復帰、createPortal まで、実装の中で学んだことをまとめます。</summary>
        <content type="html">&lt;h2 id=&quot;はじめになぜゼロから書いたのか&quot;&gt;はじめに：なぜゼロから書いたのか&lt;/h2&gt;

&lt;p&gt;しまなみ海道の観光ガイドサイトを個人で開発しています。マップ上のスポットをタップすると詳細が表示されるモバイルUIを作るため、BottomSheet が必要になりました。&lt;/p&gt;

&lt;p&gt;最初は Radix UI の &lt;code&gt;&amp;lt;Dialog&amp;gt;&lt;/code&gt; や &lt;code&gt;vaul&lt;/code&gt;（Drawer ライブラリ）を試しました。動きはします。ただ、使いながら「なぜこう動くのか」がわからないまま実装が進んでいく感覚に違和感がありました。&lt;/p&gt;

&lt;p&gt;ライブラリを使えば1時間で終わる話を、&lt;strong&gt;わざわざ自分で書いた理由はそこです。ゼロから書いて中身を理解したかったのです。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;結果として、CSSトランジションの落とし穴・スワイプ速度計算・アクセシビリティのお作法など、普段触れない領域を一通り掘り下げることになりました。この記事はその記録です。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;完成品の機能&quot;&gt;完成品の機能&lt;/h2&gt;

&lt;p&gt;実装したコンポーネントの機能一覧です：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;スワイプ（上→下）で閉じる（距離 or 速度の2段判定）&lt;/li&gt;
&lt;li&gt;マウスドラッグでも閉じる（デスクトップ対応）&lt;/li&gt;
&lt;li&gt;背景タップで閉じる&lt;/li&gt;
&lt;li&gt;ESC キーで閉じる&lt;/li&gt;
&lt;li&gt;開閉アニメーション（イージング・時間カスタマイズ可）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;inert&lt;/code&gt; 属性によるアクセシビリティ&lt;/li&gt;
&lt;li&gt;フォーカス復帰（閉じたら開く前にフォーカスしていた要素に戻る）&lt;/li&gt;
&lt;li&gt;命令的 close（&lt;code&gt;ref.requestClose()&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createPortal&lt;/code&gt; で &lt;code&gt;document.body&lt;/code&gt; 直下にレンダリング&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;BottomSheet 本体の実装に外部ライブラリは使っていません（ダークモード判定にプロジェクト全体で使っている &lt;code&gt;next-themes&lt;/code&gt; を参照しているのみです）。&lt;/p&gt;

&lt;p&gt;実際に動いているものはこちらで確認できます。スポットをタップするとシートが開きます。
&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;&amp;#x5730;&amp;#x56F3;&amp;#x3067;&amp;#x63A2;&amp;#x3059;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053; &amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;今回ゼロから実装したBottomSheet。スポットをタップするとシートが下から滑り上がります&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260427/20260427064014.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30AC;&amp;#x30A4;&amp;#x30C9;&amp;#x30B5;&amp;#x30A4;&amp;#x30C8;&amp;#x306E;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#x753B;&amp;#x9762;&amp;#x3002;&amp;#x5DE6;&amp;#x304C;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x3092;&amp;#x9078;&amp;#x629E;&amp;#x3057;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&amp;#x3001;&amp;#x53F3;&amp;#x304C;BottomSheet&amp;#x304C;&amp;#x958B;&amp;#x3044;&amp;#x3066;&amp;#x8015;&amp;#x4E09;&amp;#x5BFA;&amp;#x306E;&amp;#x8A73;&amp;#x7D30;&amp;#x60C5;&amp;#x5831;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&quot; width=&quot;1200&quot; height=&quot;630&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;今回ゼロから実装したBottomSheet。スポットをタップするとシートが下から滑り上がります&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;設計の出発点mounted-と-show-を分離する&quot;&gt;設計の出発点：&lt;code&gt;mounted&lt;/code&gt; と &lt;code&gt;show&lt;/code&gt; を分離する&lt;/h2&gt;

&lt;p&gt;アニメーションつきで「消える」コンポーネントを作るとき、素直に考えるとこうなります：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;open&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setOpen&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)
return &lt;span class=&quot;synType&quot;&gt;open&lt;/span&gt; ? &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;Panel &lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;/&amp;gt;&lt;/span&gt; : &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;これだと &lt;code&gt;open=false&lt;/code&gt; にした瞬間 DOM からパネルが消えます。&lt;strong&gt;閉じるアニメーションは永遠に見えません。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;解決策は 2つの状態を分けることです：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;mounted&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setMounted&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)  &lt;span class=&quot;synComment&quot;&gt;// DOMに存在するか&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;show&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setShow&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;       = useState(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)  &lt;span class=&quot;synComment&quot;&gt;// CSSで見えているか&lt;/span&gt;
&lt;/pre&gt;


&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 状態 &lt;/th&gt;
&lt;th&gt; mounted &lt;/th&gt;
&lt;th&gt; show &lt;/th&gt;
&lt;th&gt; 画面に見える &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; 閉じている &lt;/td&gt;
&lt;td&gt; false &lt;/td&gt;
&lt;td&gt; false &lt;/td&gt;
&lt;td&gt; 非表示（DOMなし） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 開くアニメーション中 &lt;/td&gt;
&lt;td&gt; true &lt;/td&gt;
&lt;td&gt; true &lt;/td&gt;
&lt;td&gt; 表示 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 閉じるアニメーション中 &lt;/td&gt;
&lt;td&gt; true &lt;/td&gt;
&lt;td&gt; false &lt;/td&gt;
&lt;td&gt; ← translateY(100%)に向けてアニメ中 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; アニメ終了後 &lt;/td&gt;
&lt;td&gt; false &lt;/td&gt;
&lt;td&gt; false &lt;/td&gt;
&lt;td&gt; 非表示（DOMなし） &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&lt;code&gt;show=false&lt;/code&gt; にして閉じるアニメーションが終わった後（&lt;code&gt;transitionend&lt;/code&gt; イベントで検知）、はじめて &lt;code&gt;mounted=false&lt;/code&gt; にして DOM から取り除きます。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; onPanelTransitionEnd = ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!showRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    finalizeClose()  &lt;span class=&quot;synComment&quot;&gt;// ここで mounted=false にする&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;本題なぜdouble-rAFが必要なのか&quot;&gt;本題：なぜ「double rAF」が必要なのか&lt;/h2&gt;

&lt;p&gt;ここがこの実装で一番ハマった部分であり、記事の核心です。CSS トランジションを使ったことがある方なら、一度は同じ罠を踏んでいるはずです。&lt;/p&gt;

&lt;p&gt;「シートを開く」処理を単純に書くとこうなります：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ❌ アニメーションしない&lt;/span&gt;
setMounted(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)
setShow(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;React がレンダリングしても、CSSトランジションは&lt;strong&gt;起動しません。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translateY(100%)&lt;/code&gt; → &lt;code&gt;translateY(0)&lt;/code&gt; に変化させたいのですが、ブラウザから見ると「最初から &lt;code&gt;translateY(0)&lt;/code&gt; な要素が突然 DOM に現れた」という扱いになります。スタート地点が存在しないのでアニメーションが発火しません。&lt;/p&gt;

&lt;h3 id=&quot;1つの-requestAnimationFrame-では足りない&quot;&gt;1つの requestAnimationFrame では足りない&lt;/h3&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ❌ これも動かないことがある&lt;/span&gt;
setMounted(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)
setShow(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)  &lt;span class=&quot;synComment&quot;&gt;// translateY(100%) でDOMに挿入&lt;/span&gt;
requestAnimationFrame(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  setShow(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;) &lt;span class=&quot;synComment&quot;&gt;// translateY(0) に変化 → アニメ開始…のはずが動かないことがある&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;React のステートバッチ処理の影響で、&lt;code&gt;setMounted(true)&lt;/code&gt; と &lt;code&gt;setShow(false)&lt;/code&gt; が同一レンダリングにまとめられ、ブラウザが「&lt;code&gt;translateY(100%)&lt;/code&gt; の状態をペイントしたフレーム」が実際には挟まっていないことがあります。&lt;/p&gt;

&lt;h3 id=&quot;double-rAF--強制フラッシュが解決する&quot;&gt;double rAF + 強制フラッシュが解決する&lt;/h3&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;setMounted(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)
setShow(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)            &lt;span class=&quot;synComment&quot;&gt;// [1] translateY(100%) でDOM生成&lt;/span&gt;
setEnableTransition(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;) &lt;span class=&quot;synComment&quot;&gt;// トランジション無効で挿入&lt;/span&gt;

requestAnimationFrame(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;                      &lt;span class=&quot;synComment&quot;&gt;// [2] 1フレーム後&lt;/span&gt;
  panelRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.getBoundingClientRect()        &lt;span class=&quot;synComment&quot;&gt;// ← レイアウト強制フラッシュ&lt;/span&gt;
  requestAnimationFrame(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;                    &lt;span class=&quot;synComment&quot;&gt;// [3] さらに1フレーム後&lt;/span&gt;
    setEnableTransition(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)                      &lt;span class=&quot;synComment&quot;&gt;// トランジション有効化&lt;/span&gt;
    setShow(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;)          &lt;span class=&quot;synComment&quot;&gt;// translateY(0) に変化 → アニメ確実に起動&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;getBoundingClientRect()&lt;/code&gt; を呼ぶことでブラウザに「&lt;strong&gt;レイアウト計算をいま確定しろ&lt;/strong&gt;」と命令します（強制リフロー）。これにより &lt;code&gt;translateY(100%)&lt;/code&gt; の状態が確実にペイントされ、次のフレームで &lt;code&gt;translateY(0)&lt;/code&gt; に変化したときに「スタート地点から変化している」とブラウザが認識してトランジションが起動します。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;enableTransition&lt;/code&gt; フラグを別で持っているのは、DOM 挿入の瞬間にトランジションが走って一瞬乱れるのを防ぐためです。ドラッグ中も同様に &lt;code&gt;transitionProperty: &quot;none&quot;&lt;/code&gt; にして、指の動きにパネルが即座に追従するようにしています。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;style=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;{
  &lt;span class=&quot;synStatement&quot;&gt;transform&lt;/span&gt;: show ? &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;translateY(0)&amp;quot;&lt;/span&gt; : &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;translateY(100%)&amp;quot;&lt;/span&gt;,
  &lt;span class=&quot;synStatement&quot;&gt;transitionProperty&lt;/span&gt;:
    enableTransition &amp;amp;&amp;amp; !draggingRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; ? &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;transform&amp;quot;&lt;/span&gt; : &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;none&amp;quot;&lt;/span&gt;,
  &lt;span class=&quot;synStatement&quot;&gt;transitionDuration&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;${&lt;/span&gt;duration&lt;span class=&quot;synStatement&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;ms`&lt;/span&gt;,
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;}
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;スワイプ判定距離だけでは足りない&quot;&gt;スワイプ判定：距離だけでは足りない&lt;/h2&gt;

&lt;p&gt;スワイプで閉じる判定を「何px以上下にドラッグしたか」だけにすると、ゆっくりちょっとだけ引っ張って離したときに閉じません。iOSのシートと同じ操作感にするには、&lt;strong&gt;速度（velocity）も見る&lt;/strong&gt;必要があります。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; thresholdPx = &lt;span class=&quot;synConstant&quot;&gt;120&lt;/span&gt;        &lt;span class=&quot;synComment&quot;&gt;// 距離の閾値&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; velocityThreshold = &lt;span class=&quot;synConstant&quot;&gt;0.6&lt;/span&gt;  &lt;span class=&quot;synComment&quot;&gt;// px/ms の速度閾値&lt;/span&gt;

&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; endDrag = ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; dt = &lt;span class=&quot;synType&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;max&lt;/span&gt;(&lt;span class=&quot;synConstant&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;synType&quot;&gt;performance&lt;/span&gt;.now() - lastTSRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;) &lt;span class=&quot;synComment&quot;&gt;// 経過時間(ms)&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; dy = &lt;span class=&quot;synType&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;max&lt;/span&gt;(&lt;span class=&quot;synConstant&quot;&gt;0&lt;/span&gt;, lastYRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; - dragStartYRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;)
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; v = dy / dt  &lt;span class=&quot;synComment&quot;&gt;// 速度 (px/ms)&lt;/span&gt;

  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (dy &amp;gt; thresholdPx || v &amp;gt; velocityThreshold) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    requestClose()  &lt;span class=&quot;synComment&quot;&gt;// 距離 OR 速度 のどちらかを満たしたら閉じる&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    setDragY(&lt;span class=&quot;synConstant&quot;&gt;0&lt;/span&gt;)     &lt;span class=&quot;synComment&quot;&gt;// 閾値未満なら元の位置に戻す&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;performance.now()&lt;/code&gt; を使うのは &lt;code&gt;Date.now()&lt;/code&gt; より精度が高いためです（1ms 未満の解像度）。&lt;/p&gt;

&lt;p&gt;「速度を見る」ことで「素早くフリック → 少ししか動いていなくても閉じる」という自然な操作感が生まれます。&lt;/p&gt;

&lt;p&gt;マウスドラッグも同じロジックで対応しています。&lt;code&gt;window&lt;/code&gt; にリスナーを付けているのは、ドラッグ中にカーソルがパネル外に出ても追従させるためです：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;onMouseDown=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;(&lt;span class=&quot;synStatement&quot;&gt;e&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;synStatement&quot;&gt;if &lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;e.button !&lt;/span&gt;==&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;0&lt;/span&gt;) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;
&lt;span class=&quot;synStatement&quot;&gt;  beginDrag&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;e.clientY&lt;/span&gt;)
  &lt;span class=&quot;synStatement&quot;&gt;const onMove &lt;/span&gt;= (&lt;span class=&quot;synStatement&quot;&gt;ev&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;MouseEvent&lt;/span&gt;) =&amp;gt; &lt;span class=&quot;synStatement&quot;&gt;moveDrag&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;ev.clientY&lt;/span&gt;)
  &lt;span class=&quot;synStatement&quot;&gt;const onUp &lt;/span&gt;= () =&amp;gt; {
    &lt;span class=&quot;synStatement&quot;&gt;endDrag&lt;/span&gt;()
    &lt;span class=&quot;synStatement&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;removeEventListener&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;&amp;quot;mousemove&amp;quot;&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; onMove&lt;/span&gt;)
    &lt;span class=&quot;synStatement&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;removeEventListener&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;&amp;quot;mouseup&amp;quot;&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; onUp&lt;/span&gt;)
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;addEventListener&lt;/span&gt;(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;mousemove&amp;quot;&lt;/span&gt;, onMove)
  &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;addEventListener&lt;/span&gt;(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;mouseup&amp;quot;&lt;/span&gt;, onUp)
}}
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;inert-属性でアクセシビリティを担保する&quot;&gt;&lt;code&gt;inert&lt;/code&gt; 属性でアクセシビリティを担保する&lt;/h2&gt;

&lt;p&gt;「閉じるアニメーション中のシート」はDOMに残っています。この間にスクリーンリーダーやキーボードからアクセスされると困ります。&lt;/p&gt;

&lt;p&gt;HTML の &lt;code&gt;inert&lt;/code&gt; 属性を使うと、その要素とすべての子孫を一括で「操作不可・フォーカス不可・読み上げ不可」にできます：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;useEffect(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; overlayEl = overlayRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!overlayEl) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;

  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!show) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    overlayEl.setAttribute(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;inert&amp;quot;&lt;/span&gt;, &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;)  &lt;span class=&quot;synComment&quot;&gt;// 閉じ中は非活性&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    overlayEl.removeAttribute(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;inert&amp;quot;&lt;/span&gt;)   &lt;span class=&quot;synComment&quot;&gt;// 開いているときは活性&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;show&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;以前は &lt;code&gt;aria-hidden&lt;/code&gt; + 全子孫に &lt;code&gt;tabindex=&quot;-1&quot;&lt;/code&gt; を手動で設定する必要がありましたが、&lt;code&gt;inert&lt;/code&gt; 1つで同等のことができます。2023年に Firefox が対応したことで、主要ブラウザがすべて揃いました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;フォーカス復帰&quot;&gt;フォーカス復帰&lt;/h2&gt;

&lt;p&gt;WAI-ARIA のダイアログパターンでは、モーダルを閉じたときに&lt;strong&gt;開く前にフォーカスしていた要素にフォーカスを戻す&lt;/strong&gt;ことが求められています。&lt;/p&gt;

&lt;p&gt;実装は単純で、開くタイミングで &lt;code&gt;document.activeElement&lt;/code&gt; を保存しておき、閉じたときに &lt;code&gt;focus()&lt;/code&gt; を呼ぶだけです：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// 開くとき：フォーカス元を保存&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; prev = &lt;span class=&quot;synType&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;activeElement&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;HTMLElement&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;
&lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (prev &amp;amp;&amp;amp; !panelRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.&lt;span class=&quot;synStatement&quot;&gt;contains&lt;/span&gt;(prev)) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  restoreFocusRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = prev
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;synComment&quot;&gt;// 閉じたとき（finalizeClose 内）：フォーカスを戻す&lt;/span&gt;
&lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (restoreFocusRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synSpecial&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; restoreFocusRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;.&lt;span class=&quot;synType&quot;&gt;focus&lt;/span&gt;() &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{}&lt;/span&gt;
  restoreFocusRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;開くときは &lt;code&gt;[data-autofocus]&lt;/code&gt; 属性がある要素か、フォーカス可能な最初の要素に自動フォーカスします：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; target =
  panelRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.&lt;span class=&quot;synStatement&quot;&gt;querySelector&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HTMLElement&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;[data-autofocus]&amp;quot;&lt;/span&gt;) ||
  panelRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.&lt;span class=&quot;synStatement&quot;&gt;querySelector&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HTMLElement&lt;/span&gt;&amp;gt;(
    &lt;span class=&quot;synConstant&quot;&gt;&#39;button, [href], input, select, textarea, [tabindex]:not([tabindex=&amp;quot;-1&amp;quot;])&#39;&lt;/span&gt;
  )
target?.&lt;span class=&quot;synType&quot;&gt;focus&lt;/span&gt;()
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;forwardRef--useImperativeHandle-で命令的-close&quot;&gt;&lt;code&gt;forwardRef&lt;/code&gt; + &lt;code&gt;useImperativeHandle&lt;/code&gt; で命令的 close&lt;/h2&gt;

&lt;p&gt;このシートは &lt;code&gt;open&lt;/code&gt; props で開閉を制御しますが、「外部から close を呼びたい」ケースがあります（例：フォーム送信完了後に閉じるなど）。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useImperativeHandle&lt;/code&gt; で &lt;code&gt;requestClose&lt;/code&gt; を外部に公開します：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;type &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;SheetHandle &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;requestClose&lt;/span&gt;: () &lt;span class=&quot;synIdentifier&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; Sheet = forwardRef&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;SheetHandle&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;SheetProps&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;Sheet&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;props&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; ref&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; requestClose = useCallback(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    setShow(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;)
    closeTimerRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synType&quot;&gt;setTimeout&lt;/span&gt;(finalizeClose, closeMs + &lt;span class=&quot;synConstant&quot;&gt;80&lt;/span&gt;)
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;closeMs, finalizeClose&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;)

  useImperativeHandle(ref, ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; (&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;requestClose &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;), &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;requestClose&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;)
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;使う側：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; sheetRef = useRef&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;SheetHandle&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;)

&lt;span class=&quot;synComment&quot;&gt;// 任意のタイミングで&lt;/span&gt;
sheetRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.requestClose()

return &amp;lt;Sheet ref=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;sheetRef&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;open&lt;/span&gt;=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; onClose=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;onClose&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; /&amp;gt;
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;createPortal-でスタッキングコンテキストを脱出する&quot;&gt;&lt;code&gt;createPortal&lt;/code&gt; でスタッキングコンテキストを脱出する&lt;/h2&gt;

&lt;p&gt;シートは &lt;code&gt;position: fixed&lt;/code&gt; で画面全体に重ねる必要がありますが、コンポーネントツリーの深い場所に置かれると、親要素の &lt;code&gt;transform&lt;/code&gt; や &lt;code&gt;overflow: hidden&lt;/code&gt; によってクリッピングされることがあります。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;createPortal&lt;/code&gt; で &lt;code&gt;document.body&lt;/code&gt; 直下にレンダリングすることでこれを回避します：&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; overlayRootRef = useRef&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HTMLElement&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;)
useEffect(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  overlayRootRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = &lt;span class=&quot;synType&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;body&lt;/span&gt;  &lt;span class=&quot;synComment&quot;&gt;// SSR後に取得&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;)

&lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!mounted || !overlayRootRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;) return &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;

return createPortal(overlay, overlayRootRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;)
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt; 内で取得しているのは SSR（サーバーサイドレンダリング）時に &lt;code&gt;document&lt;/code&gt; が存在しないためです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;まとめ&quot;&gt;まとめ&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 問題 &lt;/th&gt;
&lt;th&gt; 解決策 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; CSSトランジションが動かない &lt;/td&gt;
&lt;td&gt; double rAF + &lt;code&gt;getBoundingClientRect()&lt;/code&gt; 強制フラッシュ &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 閉じアニメーションが見えない &lt;/td&gt;
&lt;td&gt; &lt;code&gt;mounted&lt;/code&gt; / &lt;code&gt;show&lt;/code&gt; を2状態で管理し &lt;code&gt;transitionend&lt;/code&gt; 後にDOMを消す &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 速度フリックで閉じない &lt;/td&gt;
&lt;td&gt; &lt;code&gt;velocity = dy / dt&lt;/code&gt; の2段判定 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; アニメ中にフォーカスが当たる &lt;/td&gt;
&lt;td&gt; &lt;code&gt;inert&lt;/code&gt; 属性で一括非活性化 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 閉じた後にフォーカスが迷子 &lt;/td&gt;
&lt;td&gt; &lt;code&gt;activeElement&lt;/code&gt; を保存して復帰 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 親の &lt;code&gt;transform&lt;/code&gt; に干渉される &lt;/td&gt;
&lt;td&gt; &lt;code&gt;createPortal&lt;/code&gt; で &lt;code&gt;document.body&lt;/code&gt; に脱出 &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;ライブラリを使えば確かに速く実装できます。ただ、ゼロから書くと「なぜ動くのか」がわかります。特に double rAF は、知っておくと他のアニメーション実装でも繰り返し使える知識です。&lt;/p&gt;

&lt;p&gt;実際に動いているサイトはこちらです。マップのスポットをタップするとシートが開きます。
&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;&amp;#x5730;&amp;#x56F3;&amp;#x3067;&amp;#x63A2;&amp;#x3059;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053; &amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#xFF5C;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fcustom-hooks-useismobile-usegeolocate-usecloseall&quot; title=&quot;カスタムフックで「どこで何が動くか」を整理——useIsMobile / useGeolocate / useCloseAll の設計 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/custom-hooks-useismobile-usegeolocate-usecloseall&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fnextjs-layout-tsx-page-tsx-difference&quot; title=&quot;layout.tsx と page.tsx の違いがわからなかった——本サイトを作って理解できたこと - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/nextjs-layout-tsx-page-tsx-difference&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fuse-callback-when-to-use&quot; title=&quot;useCallback が必要な場面・不要な場面——「とりあえず入れとく」はパフォーマンスを悪化させる - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/use-callback-when-to-use&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="React" label="React" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/c906367298aff5182541e54c2c78d378e5328262/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260427%2F20260427064014.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>useCallback が必要な場面・不要な場面——「とりあえず入れとく」はパフォーマンスを悪化させる</title>
        <link href="https://mojitonews.hateblo.jp/entry/use-callback-when-to-use"/>
        <id>hatenablog://entry/17179246901379922214</id>
        <published>2026-04-24T07:30:42+09:00</published>
        <updated>2026-04-24T07:30:42+09:00</updated>        <summary type="html">useCallbackは「とりあえず入れとく」が通用しないフックです。不要な場面では可読性の低下とわずかなパフォーマンス悪化を招きます。React.memoとの組み合わせ・useEffectの依存配列・依存チェーンの3パターンを実コードで整理しました。</summary>
        <content type="html">&lt;p&gt;しまなみ海道の観光サイト勉強も兼ねて作り始めたとき、カスタムフックを書くたびに &lt;code&gt;useCallback&lt;/code&gt; を巻いていました。&lt;/p&gt;

&lt;p&gt;「関数の再生成を防ぐんでしょ？　入れておいて損はないよね」——そう思っていました。&lt;/p&gt;

&lt;p&gt;実際には、不要な &lt;code&gt;useCallback&lt;/code&gt; はコードを読みにくくするだけでなく、パフォーマンスをわずかに悪化させることもあります。どこで使うべきで、どこでは要らないのかを整理しました。&lt;/p&gt;

&lt;h2 id=&quot;useCallback-がやっていること&quot;&gt;useCallback がやっていること&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const handleClick = useCallback(() =&amp;gt; {
  doSomething();
}, []);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useCallback&lt;/code&gt; はメモ化された関数を返します。依存配列の中身が変わらない限り、前回レンダリング時と同じ関数参照を返し続けます。&lt;/p&gt;

&lt;p&gt;逆に言うと、それだけです。関数の中身を「高速化」するわけではありません。&lt;/p&gt;

&lt;h2 id=&quot;入れなくていい場面&quot;&gt;入れなくていい場面&lt;/h2&gt;

&lt;h3 id=&quot;1-ネイティブ-DOM-要素への-onClick&quot;&gt;1. ネイティブ DOM 要素への onClick&lt;/h3&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ❌ useCallback は不要&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; handleClick = useCallback(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  setIsOpen(&lt;span class=&quot;synConstant&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;);

return &amp;lt;button onClick=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;handleClick&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;&amp;gt;開く&amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;button&lt;/span&gt;&amp;gt;;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; などのネイティブ要素は &lt;code&gt;React.memo&lt;/code&gt; で囲まれていません。関数参照が毎回変わっても再レンダリングは起きません。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ✅ これで十分&lt;/span&gt;
return &amp;lt;button onClick=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;() =&amp;gt; &lt;span class=&quot;synStatement&quot;&gt;setIsOpen&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;true&lt;/span&gt;)&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;&amp;gt;開く&amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;button&lt;/span&gt;&amp;gt;;
&lt;/pre&gt;


&lt;h3 id=&quot;2-コンポーネント内だけで使う関数&quot;&gt;2. コンポーネント内だけで使う関数&lt;/h3&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ❌ 不要&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; formatLabel = useCallback((&lt;span class=&quot;synPreProc&quot;&gt;name&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;string&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;（スポット）`&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;);
&lt;/pre&gt;


&lt;p&gt;他のコンポーネントに渡さない、&lt;code&gt;useEffect&lt;/code&gt; の依存にもならない関数は、メモ化しても意味がありません。&lt;/p&gt;

&lt;h2 id=&quot;入れる意味がある場面&quot;&gt;入れる意味がある場面&lt;/h2&gt;

&lt;h3 id=&quot;1-Reactmemo-で囲んだ子コンポーネントへ渡すとき&quot;&gt;1. React.memo で囲んだ子コンポーネントへ渡すとき&lt;/h3&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; Child = React.memo((&lt;span class=&quot;synPreProc&quot;&gt;{ onClose }&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;onClose&lt;/span&gt;: () &lt;span class=&quot;synIdentifier&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;button &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;onClick&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;onClose&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;閉じる&lt;span class=&quot;synComment&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

&lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;Parent&lt;/span&gt;() &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synComment&quot;&gt;// ❌ useCallback なし → 毎レンダリングで新しい関数参照 → memo が意味をなさない&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; handleClose = ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; setOpen(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);

  &lt;span class=&quot;synComment&quot;&gt;// ✅ useCallback あり → 参照が安定 → Child が不要に再レンダリングしない&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; handleClose = useCallback(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; setOpen(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;), &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;);

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;Child &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;onClose&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;handleClose&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;/&amp;gt;&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;React.memo&lt;/code&gt; は props の参照で前回と比較します。関数を毎回新しく作ると、中身が同じでも「変わった」と判断されて再レンダリングが走ります。&lt;/p&gt;

&lt;h3 id=&quot;2-別の-useCallback-の依存配列に入れるとき&quot;&gt;2. 別の useCallback の依存配列に入れるとき&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;useCallback&lt;/code&gt; の依存配列に入るケースは &lt;code&gt;useEffect&lt;/code&gt; だけとは限りません。別の &lt;code&gt;useCallback&lt;/code&gt; から呼ばれる場合も同じ理由で参照の安定が必要になります。&lt;/p&gt;

&lt;p&gt;このサイトの &lt;code&gt;useHotelMapClickHandler.ts&lt;/code&gt; はその実例です。フック自体は &lt;code&gt;handleHotelClick&lt;/code&gt; を返り値として返し、呼び出し側の &lt;code&gt;PowerMap.tsx&lt;/code&gt; で別の &lt;code&gt;useCallback&lt;/code&gt; の依存配列に入っています。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// PowerMap.tsx&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; handleClick = useCallback(
  (&lt;span class=&quot;synPreProc&quot;&gt;e&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    handleHotelClick(e); &lt;span class=&quot;synComment&quot;&gt;// ← useHotelMapClickHandler が返した関数&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
  &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;activeLayer, closeHotelPopup, handleHotelClick, mapRef&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;, &lt;span class=&quot;synComment&quot;&gt;// ← 依存配列に入っている&lt;/span&gt;
);
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;handleHotelClick&lt;/code&gt; の参照が毎レンダリングで変わると、&lt;code&gt;handleClick&lt;/code&gt; も毎回再生成されます。&lt;code&gt;handleClick&lt;/code&gt; がさらに別の依存配列に入っていれば、その連鎖が続きます。参照を安定させることで、この再生成の連鎖を断ち切れます。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;React.memo との組み合わせ（上）と useCallback の依存配列チェーン（下）——どちらも関数参照を安定させる必要がある場面です。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260424/20260424072519.png&quot; alt=&quot;useCallback&amp;#x3092;React.memo&amp;#x3068;&amp;#x7D44;&amp;#x307F;&amp;#x5408;&amp;#x308F;&amp;#x305B;&amp;#x305F;&amp;#x4F8B;&amp;#x3068;&amp;#x3001;PowerMap.tsx&amp;#x306E;&amp;#x4F9D;&amp;#x5B58;&amp;#x914D;&amp;#x5217;&amp;#x30C1;&amp;#x30A7;&amp;#x30FC;&amp;#x30F3;&amp;#x306E;&amp;#x30B3;&amp;#x30FC;&amp;#x30C9;&amp;#x4F8B;&quot; width=&quot;920&quot; height=&quot;571&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;React.memo との組み合わせ（上）と useCallback の依存配列チェーン（下）——どちらも関数参照を安定させる必要がある場面です。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;h3 id=&quot;3-useEffect-の依存チェーンが連なるとき&quot;&gt;3. useEffect の依存チェーンが連なるとき&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Sheet.tsx&lt;/code&gt;（ボトムシート）では &lt;code&gt;finalizeClose&lt;/code&gt; と &lt;code&gt;requestClose&lt;/code&gt; を &lt;code&gt;useCallback&lt;/code&gt; で定義しています。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; finalizeClose = useCallback(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (showRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;;
  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (closeTimerRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; !== &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synType&quot;&gt;clearTimeout&lt;/span&gt;(closeTimerRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;);
    closeTimerRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;synComment&quot;&gt;// ...&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;onClose&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);
&lt;/pre&gt;


&lt;p&gt;構造はこうなっています。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;finalizeClose&lt;/code&gt; → &lt;code&gt;useEffect&lt;/code&gt; の依存配列に入っている&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestClose&lt;/code&gt; → &lt;code&gt;finalizeClose&lt;/code&gt; を内部で呼び、さらに別の &lt;code&gt;useEffect&lt;/code&gt; の依存配列に入っている&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestClose&lt;/code&gt; → &lt;code&gt;useImperativeHandle&lt;/code&gt; にも渡している&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;code&gt;useImperativeHandle&lt;/code&gt; で公開しているのはその延長であって、主な理由は &lt;code&gt;useEffect&lt;/code&gt; の依存チェーンの安定化です。&lt;code&gt;finalizeClose&lt;/code&gt; の参照が毎回変わると、それを依存に持つ &lt;code&gt;requestClose&lt;/code&gt; も再生成され、さらにその &lt;code&gt;useEffect&lt;/code&gt; が毎回再実行される連鎖が起きます。&lt;/p&gt;

&lt;h2 id=&quot;useCallback-自体にコストがある&quot;&gt;useCallback 自体にコストがある&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useCallback&lt;/code&gt; はタダではありません。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;依存配列の値を毎レンダリングで比較する処理が走る&lt;/li&gt;
&lt;li&gt;前回の関数参照をメモリに保持し続ける&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;関数の生成コスト（ほぼゼロに近い）より、メモ化のオーバーヘッドが上回るケースもあります。「入れておけば安全」ではなく、入れる理由がある場面でだけ使うのが正しい判断です。&lt;/p&gt;

&lt;h2 id=&quot;判断フローチャート&quot;&gt;判断フローチャート&lt;/h2&gt;

&lt;pre class=&quot;code&quot; data-lang=&quot;&quot; data-unlink&gt;この関数、useCallback 巻く？
│
├─ React.memo を使った子コンポーネントに渡す？
│   YES → 巻く
│
├─ useEffect の依存配列に入っている？
│   YES → 巻く
│
├─ 別の useCallback や useEffect の依存配列に入る？
│   YES → 巻く（依存チェーンの連鎖を断ち切る）
│
└─ 上記以外
    NO → 巻かない&lt;/pre&gt;


&lt;h2 id=&quot;まとめ&quot;&gt;まとめ&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 場面 &lt;/th&gt;
&lt;th&gt; useCallback &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;&amp;lt;button onClick={fn}&amp;gt;&lt;/code&gt; などネイティブ要素 &lt;/td&gt;
&lt;td&gt; 不要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; コンポーネント内だけで使う関数 &lt;/td&gt;
&lt;td&gt; 不要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;React.memo&lt;/code&gt; の子コンポーネントへ渡す &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useEffect&lt;/code&gt; の依存配列に入る &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 別の &lt;code&gt;useCallback&lt;/code&gt; の依存配列に入る &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useEffect&lt;/code&gt; 依存チェーンの末端で &lt;code&gt;useImperativeHandle&lt;/code&gt; にも渡す &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;「とりあえず入れとく」のをやめてから、カスタムフックのコードが読みやすくなりました。依存配列に &lt;code&gt;useCallback&lt;/code&gt; が並んでいたら、「この関数は子や Effect から参照される重要な関数だ」という意味になります。ノイズがなくなると、コードが語れる情報量が増えます。&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Ftanstack-query-migration-custom-cache&quot; title=&quot;自前キャッシュをTanStack Queryに移行した——176行のコードが82行に - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/tanstack-query-migration-custom-cache&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fcustom-hooks-useismobile-usegeolocate-usecloseall&quot; title=&quot;カスタムフックで「どこで何が動くか」を整理——useIsMobile / useGeolocate / useCloseAll の設計 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/custom-hooks-useismobile-usegeolocate-usecloseall&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/57f62f55084c4d2a353222beffd548833f3f9270/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260424%2F20260424072519.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>layout.tsx と page.tsx の違いがわからなかった——本サイトを作って理解できたこと</title>
        <link href="https://mojitonews.hateblo.jp/entry/nextjs-layout-tsx-page-tsx-difference"/>
        <id>hatenablog://entry/17179246901379261663</id>
        <published>2026-04-22T09:41:11+09:00</published>
        <updated>2026-04-22T09:41:11+09:00</updated>        <summary type="html">Next.js App Router の layout.tsx と page.tsx の違いを本サイトの実コードで整理しました。どちらに何を書くか迷ったときの判断基準、metadata・generateStaticParams の使い分けまでまとめています。</summary>
        <content type="html">&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;Next.js の App Router を使い始めてすぐ、ファイル構成で悩みました。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt; と &lt;code&gt;layout.tsx&lt;/code&gt; をどちらに何を書くのか、最初はまったくわかりませんでした。「とりあえず全部 &lt;code&gt;page.tsx&lt;/code&gt; に書けばいいんじゃないか」と思ったこともあります。&lt;/p&gt;

&lt;p&gt;本サイト（しまなみ海道の観光ガイドサイト）を作りながら理解できたことをまとめます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1分でわかる結論&quot;&gt;1分でわかる結論&lt;/h2&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;layout.tsx は1つだけ。page.tsx はページの数だけあります。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260422/20260422093940.png&quot; alt=&quot;Next.js App Router&amp;#x306E;&amp;#x30D5;&amp;#x30A1;&amp;#x30A4;&amp;#x30EB;&amp;#x69CB;&amp;#x6210;&amp;#x56F3;&amp;#x3002;layout.tsx&amp;#x304C;1&amp;#x3064;&amp;#x3060;&amp;#x3051;&amp;#x5168;&amp;#x30DA;&amp;#x30FC;&amp;#x30B8;&amp;#x5171;&amp;#x901A;&amp;#x3067;&amp;#x5B58;&amp;#x5728;&amp;#x3057;&amp;#x3001;page.tsx&amp;#x306F;&amp;#x30C8;&amp;#x30C3;&amp;#x30D7;&amp;#x30FB;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x4E00;&amp;#x89A7;&amp;#x30FB;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x8A73;&amp;#x7D30;&amp;#x30FB;&amp;#x5730;&amp;#x56F3;&amp;#x30FB;&amp;#x30B5;&amp;#x30A4;&amp;#x30C8;&amp;#x6982;&amp;#x8981;&amp;#x3068;&amp;#x30DA;&amp;#x30FC;&amp;#x30B8;&amp;#x6570;&amp;#x5206;&amp;#x3042;&amp;#x308B;&amp;#x69CB;&amp;#x6210;&quot; width=&quot;991&quot; height=&quot;764&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;layout.tsx は1つだけ。page.tsx はページの数だけあります。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; ファイル &lt;/th&gt;
&lt;th&gt; 役割 &lt;/th&gt;
&lt;th&gt; 毎ページ表示されるか &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;layout.tsx&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; 複数ページをまたいで共通して表示する枠組み &lt;/td&gt;
&lt;td&gt; される（再レンダリングされない） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;page.tsx&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; そのURLにアクセスしたときだけ表示される内容 &lt;/td&gt;
&lt;td&gt; そのページのみ &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;「&lt;code&gt;layout.tsx&lt;/code&gt; は額縁、&lt;code&gt;page.tsx&lt;/code&gt; は中身の絵」というイメージが一番近いです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;ファイル構成を見てみる&quot;&gt;ファイル構成を見てみる&lt;/h2&gt;

&lt;p&gt;本サイトの &lt;code&gt;src/app/&lt;/code&gt; ディレクトリはこんな構成です。&lt;/p&gt;

&lt;pre class=&quot;code&quot; data-lang=&quot;&quot; data-unlink&gt;src/app/
  layout.tsx              ← すべてのページ共通のレイアウト
  page.tsx                ← トップページ（/）
  spots/
    page.tsx              ← スポット一覧（/spots）
    [spotId]/
      page.tsx            ← スポット詳細（/spots/猫の細道 など）
  hotels/
    page.tsx              ← ホテル一覧（/hotels）
    [hotelNo]/
      page.tsx            ← ホテル詳細（/hotels/12345 など）
  map/
    page.tsx              ← 地図ページ（/map）
  about/
    page.tsx              ← サイト概要（/about）&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;layout.tsx&lt;/code&gt; は1つだけです。&lt;code&gt;page.tsx&lt;/code&gt; はページの数だけあります。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;layouttsx全ページに共通する枠&quot;&gt;layout.tsx——全ページに共通する「枠」&lt;/h2&gt;

&lt;p&gt;本サイトの &lt;code&gt;src/app/layout.tsx&lt;/code&gt; の中身（簡略版）はこうなっています。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/app/layout.tsx&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;RootLayout&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;synPreProc&quot;&gt;  children&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;synPreProc&quot;&gt;}&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;children&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;React.ReactNode&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; (
    &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;html&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;lang&lt;/span&gt;=&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;ja&amp;quot;&lt;/span&gt;&amp;gt;
      &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;body&lt;/span&gt;&amp;gt;
        &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;ClientProviders&lt;/span&gt;&amp;gt;
          &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;children&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;  &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;/* ここに各ページの内容が入る */&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
        &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;ClientProviders&lt;/span&gt;&amp;gt;
      &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;body&lt;/span&gt;&amp;gt;
    &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;html&lt;/span&gt;&amp;gt;
  );
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;children&lt;/code&gt; の部分に各ページの &lt;code&gt;page.tsx&lt;/code&gt; の内容が差し込まれます。&lt;/p&gt;

&lt;p&gt;実際の &lt;code&gt;layout.tsx&lt;/code&gt; でやっていることは4つです。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;フォント設定（Noto Sans JP・Noto Serif JP を Google Fonts から読み込む）&lt;/li&gt;
&lt;li&gt;Google Analytics タグの設置（すべてのページで計測したいため）&lt;/li&gt;
&lt;li&gt;Google AdSense タグの設置（同上）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ClientProviders&lt;/code&gt; でアプリ全体を包む（Zustand・TanStack Query・テーマの初期化）&lt;/li&gt;
&lt;/ol&gt;


&lt;p&gt;これらはどのページでも必要なので &lt;code&gt;layout.tsx&lt;/code&gt; に書きます。「トップページだけ」「スポット詳細だけ」に必要なものは &lt;code&gt;layout.tsx&lt;/code&gt; には書きません。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;pagetsxそのURLの内容&quot;&gt;page.tsx——そのURLの「内容」&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt; はURLに対応する内容だけを書きます。&lt;/p&gt;

&lt;p&gt;トップページ（&lt;code&gt;src/app/page.tsx&lt;/code&gt;）は注目スポット一覧・エリアナビゲーション・サイクリングガイドバナーを表示します。これは &lt;code&gt;/&lt;/code&gt; にアクセスしたときだけ見せたいものなので &lt;code&gt;page.tsx&lt;/code&gt; に書きます。&lt;/p&gt;

&lt;p&gt;スポット詳細ページ（&lt;code&gt;src/app/spots/[spotId]/page.tsx&lt;/code&gt;）はこのような構造です。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/app/spots/[spotId]/page.tsx&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;SpotDetailPage&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{ params }&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;PageProps&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synPreProc&quot;&gt;spotId&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; = &lt;span class=&quot;synStatement&quot;&gt;await&lt;/span&gt; params;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; spot = getSpotById(spotId);

  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!spot) notFound();

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; (
    &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;main&lt;/span&gt;&amp;gt;
      &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;Breadcrumb&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;items&lt;/span&gt;=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;[...]&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; /&amp;gt;
      &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;SpotDetailView&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;spot&lt;/span&gt;=&lt;span class=&quot;synIdentifier&quot;&gt;{spot}&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;articleContent&lt;/span&gt;=&lt;span class=&quot;synIdentifier&quot;&gt;{articleContent}&lt;/span&gt; /&amp;gt;
    &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;main&lt;/span&gt;&amp;gt;
  );
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;params.spotId&lt;/code&gt; に &lt;code&gt;neko-no-hosomichi&lt;/code&gt; が入れば猫の細道のページ、&lt;code&gt;ohyamazumi-jinjya&lt;/code&gt; が入れば大山祇神社のページが表示されます。URLが変わるたびに &lt;code&gt;page.tsx&lt;/code&gt; だけが切り替わります。&lt;code&gt;layout.tsx&lt;/code&gt; はそのまま残ります。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;metadataもどちらに書くかで意味が変わる&quot;&gt;metadataもどちらに書くかで意味が変わる&lt;/h2&gt;

&lt;p&gt;Next.js では &lt;code&gt;metadata&lt;/code&gt; オブジェクトをエクスポートするとページタイトルや説明文が設定できます。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;layout.tsx&lt;/code&gt; に書く &lt;code&gt;metadata&lt;/code&gt; はサイト全体のデフォルト値になります。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/app/layout.tsx&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; metadata: &lt;span class=&quot;synIdentifier&quot;&gt;Metadata&lt;/span&gt; = &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;しまなみ観光マップ&amp;quot;&lt;/span&gt;,         &lt;span class=&quot;synComment&quot;&gt;// デフォルトタイトル&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;template&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;%s｜しまなみ観光マップ&amp;quot;&lt;/span&gt;,    &lt;span class=&quot;synComment&quot;&gt;// 各ページのタイトルにサフィックスを付ける&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
  &lt;span class=&quot;synStatement&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;尾道・今治・しまなみ海道エリアの...&amp;quot;&lt;/span&gt;,
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;template: &quot;%s｜しまなみ観光マップ&quot;&lt;/code&gt; の &lt;code&gt;%s&lt;/code&gt; は各 &lt;code&gt;page.tsx&lt;/code&gt; が設定したタイトルに置き換わります。スポット詳細ページが &lt;code&gt;title: &quot;猫の細道&quot;&lt;/code&gt; と設定すると、ブラウザのタブには「猫の細道｜しまなみ観光マップ」と表示されます。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt; に書く &lt;code&gt;metadata&lt;/code&gt; はそのページ専用の値です。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/app/spots/[spotId]/page.tsx&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;generateMetadata&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{ params }&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;PageProps&lt;/span&gt;): &lt;span class=&quot;synIdentifier&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;Metadata&lt;/span&gt;&amp;gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; spot = getSpotById((&lt;span class=&quot;synStatement&quot;&gt;await&lt;/span&gt; params).spotId);
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;title&lt;/span&gt;: spot.seoTitle ?? &lt;span class=&quot;synConstant&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;${&lt;/span&gt;spot.&lt;span class=&quot;synStatement&quot;&gt;name}&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;｜観光スポット`&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;description&lt;/span&gt;: spot.shortDescription,
    &lt;span class=&quot;synComment&quot;&gt;// ...&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;スポットによって内容が変わるので &lt;code&gt;generateMetadata&lt;/code&gt;（関数形式）を使っています。静的なページは &lt;code&gt;export const metadata = { ... }&lt;/code&gt; で書けます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;generateStaticParams動的ページを静的に生成する&quot;&gt;generateStaticParams——動的ページを静的に生成する&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;[spotId]&lt;/code&gt; のように角括弧で囲んだディレクトリは動的ルートです。URLが無数に作れる反面、ビルド時にどのURLが存在するかをNext.jsに教えてあげる必要があります。それが &lt;code&gt;generateStaticParams&lt;/code&gt; です。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/app/spots/[spotId]/page.tsx&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;generateStaticParams&lt;/span&gt;() &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; spots = getAllSpots();
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; spots.&lt;span class=&quot;synStatement&quot;&gt;map&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;spot&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; (&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;spotId&lt;/span&gt;: spot.&lt;span class=&quot;synStatement&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;));
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;getAllSpots()&lt;/code&gt; で全スポットを取得し、それぞれの &lt;code&gt;id&lt;/code&gt;（&lt;code&gt;neko-no-hosomichi&lt;/code&gt; など）を返します。Next.jsはビルド時にこのリストをもとに全スポット分のHTMLを事前生成します。&lt;/p&gt;

&lt;p&gt;これは &lt;code&gt;page.tsx&lt;/code&gt; にしか書かない（書けない）ものです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;整理すると&quot;&gt;整理すると&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;layout.tsx&lt;/code&gt; に書くもの&lt;/strong&gt;
- &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; タグ
- フォント設定
- 全ページ共通のスクリプト（Analytics・AdSense）
- 全ページ共通のプロバイダー（Context・QueryClient・テーマ）
- サイト全体のデフォルト &lt;code&gt;metadata&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;page.tsx&lt;/code&gt; に書くもの&lt;/strong&gt;
- そのURLだけが表示するUI
- そのページ専用の &lt;code&gt;metadata&lt;/code&gt;
- 動的ルートの &lt;code&gt;generateStaticParams&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;最初は「どちらに書くか」が判断できませんでしたが、「このコードは全ページで必要か、このページだけか」を考えるだけで自然に分かれていきました。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;本サイト（Next.js + TypeScript + MDX）を作りながら学んでいます。&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fnextjs-personal-site-learning&quot; title=&quot;手を動かして初めてわかった　Next.js + TypeScript で個人サイトを作りながら学ぶという選択 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/nextjs-personal-site-learning&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Ffs-error-nextjs-server-client-component&quot; title=&quot;`fs` を使ったらエラーが出た——Next.js の Server / Client Component を理解するまで - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/fs-error-nextjs-server-client-component&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/0777c308f788478e8e71b24f881f1d151fd8597c/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260422%2F20260422093940.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>カスタムフックで「どこで何が動くか」を整理——useIsMobile / useGeolocate / useCloseAll の設計</title>
        <link href="https://mojitonews.hateblo.jp/entry/custom-hooks-useismobile-usegeolocate-usecloseall"/>
        <id>hatenablog://entry/17179246901378939276</id>
        <published>2026-04-21T10:51:56+09:00</published>
        <updated>2026-04-21T10:51:56+09:00</updated>        <summary type="html">本サイトの地図画面開発で生まれた3つのカスタムフックを実コードで整理しました。ブラウザAPIをラップする useIsMobile、コールバックとstateをセットにする useGeolocate、複数storeの横断操作をまとめる useCloseAll——目的が違えばフックの形も変わります。</summary>
        <content type="html">&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;左がデスクトップ、右がモバイルの表示です。ナビゲーションの構成やボタンの配置が変わります。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260421/20260421104652.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#x306E;&amp;#x30C7;&amp;#x30B9;&amp;#x30AF;&amp;#x30C8;&amp;#x30C3;&amp;#x30D7;&amp;#x8868;&amp;#x793A;&amp;#xFF08;&amp;#x5DE6;&amp;#xFF09;&amp;#x3068;&amp;#x30E2;&amp;#x30D0;&amp;#x30A4;&amp;#x30EB;&amp;#x8868;&amp;#x793A;&amp;#xFF08;&amp;#x53F3;&amp;#xFF09;&amp;#x3002;useIsMobile&amp;#x3067;&amp;#x30EC;&amp;#x30A4;&amp;#x30A2;&amp;#x30A6;&amp;#x30C8;&amp;#x3092;&amp;#x5207;&amp;#x308A;&amp;#x66FF;&amp;#x3048;&amp;#x3066;&amp;#x3044;&amp;#x308B;&quot; width=&quot;1200&quot; height=&quot;560&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;左がデスクトップ、右がモバイルの表示です。ナビゲーションの構成やボタンの配置が変わります。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;React を書いていると、コンポーネントの中にロジックが増えてきます。&lt;code&gt;useEffect&lt;/code&gt; が2つ、&lt;code&gt;useState&lt;/code&gt; が3つ、コールバックが4つ……気づいたら何をしているファイルなのかわからなくなっていました。&lt;/p&gt;

&lt;p&gt;カスタムフックはこの問題を解決する手段です。「どこで何が動くか」をフックという単位に切り出すことで、コンポーネントは「何を表示するか」だけに集中できます。&lt;/p&gt;

&lt;p&gt;ただ、カスタムフックを「なぜ作るか」は一つではありませんでした。本サイトを作りながら、目的が全然違う3種類のカスタムフックが生まれました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;useIsMobileブラウザAPIをラップするパターン&quot;&gt;useIsMobile——ブラウザAPIをラップするパターン&lt;/h2&gt;

&lt;h3 id=&quot;なぜ作ったか&quot;&gt;なぜ作ったか&lt;/h3&gt;

&lt;p&gt;地図画面はモバイルとデスクトップで表示が大きく変わります。パネルの開き方、ドロワーの有無、ボタンの配置——これらをJavaScriptで制御する必要がありました。&lt;/p&gt;

&lt;p&gt;CSSの &lt;code&gt;@media&lt;/code&gt; だけでは足りない場面があります。「モバイルかどうか」をコンポーネントの中で &lt;code&gt;if&lt;/code&gt; で分岐したいとき、&lt;code&gt;window.matchMedia&lt;/code&gt; を直接書くと毎回SSR対応・イベントリスナーの登録・クリーンアップが必要になります。これをまとめたのが &lt;code&gt;useIsMobile&lt;/code&gt; です。&lt;/p&gt;

&lt;h3 id=&quot;コード&quot;&gt;コード&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/hooks/useIsMobile.ts&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useEffect, useState &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;react&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useMediaQuery&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;query&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;string&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;matches&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setMatches&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);

  useEffect(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (&lt;span class=&quot;synIdentifier&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt; === &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;undefined&amp;quot;&lt;/span&gt;) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;synComment&quot;&gt;// SSR対策&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; mql = &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synType&quot;&gt;matchMedia&lt;/span&gt;(query);
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; onChange = ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; setMatches(mql.matches);
    onChange(); &lt;span class=&quot;synComment&quot;&gt;// 初回判定&lt;/span&gt;
    mql.&lt;span class=&quot;synStatement&quot;&gt;addEventListener&lt;/span&gt;?.(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;change&amp;quot;&lt;/span&gt;, onChange);
    &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; mql.&lt;span class=&quot;synStatement&quot;&gt;removeEventListener&lt;/span&gt;?.(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;change&amp;quot;&lt;/span&gt;, onChange);
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;query&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; matches;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;synComment&quot;&gt;/** モバイル判定。デフォルト: max-width 768px */&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useIsMobile&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;maxWidth &lt;/span&gt;=&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;768&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; useMediaQuery(&lt;span class=&quot;synConstant&quot;&gt;`(max-width: &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;${&lt;/span&gt;maxWidth&lt;span class=&quot;synStatement&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;px)`&lt;/span&gt;);
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;h3 id=&quot;ポイント&quot;&gt;ポイント&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;useMediaQuery&lt;/code&gt; と &lt;code&gt;useIsMobile&lt;/code&gt; を2段構えにした理由&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useMediaQuery&lt;/code&gt; は汎用的な「メディアクエリをstateにする」フックです。&lt;code&gt;useIsMobile&lt;/code&gt; はそれに「768px以下をモバイルとみなす」というこのプロジェクトのルールを乗せた薄いラッパーです。将来「タブレット判定（1024px以下）も欲しい」となったとき、&lt;code&gt;useMediaQuery&lt;/code&gt; を再利用すれば済みます。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;typeof window === &quot;undefined&quot;&lt;/code&gt; のチェック&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js はサーバーサイドでコンポーネントを実行するため、&lt;code&gt;window&lt;/code&gt; が存在しない環境で &lt;code&gt;window.matchMedia&lt;/code&gt; を呼ぶとエラーになります。&lt;code&gt;useEffect&lt;/code&gt; の中でチェックすることで、サーバーでは何もせずスキップします。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;初期値が &lt;code&gt;false&lt;/code&gt; の理由&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useState(false)&lt;/code&gt; で始まるのは「最初はモバイルでないとみなす」という選択です。&lt;code&gt;useEffect&lt;/code&gt; は初回レンダリングの後に走るため、サーバーとクライアントで初期値が一致している必要があります。&lt;code&gt;window&lt;/code&gt; が存在しないサーバーでは「モバイルかどうか不明」なので &lt;code&gt;false&lt;/code&gt; が安全な初期値です。&lt;/p&gt;

&lt;h3 id=&quot;使い方&quot;&gt;使い方&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// Header.tsx・RightPane.tsx での実際の使い方&lt;/span&gt;
&lt;span class=&quot;synComment&quot;&gt;// ブレークポイントは引数で渡せる。1025px以下をモバイルとみなす&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; isMobile = useIsMobile(&lt;span class=&quot;synConstant&quot;&gt;1025&lt;/span&gt;);

return isMobile ? &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;MobileLayout&lt;/span&gt; /&amp;gt; : &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;DesktopLayout&lt;/span&gt; /&amp;gt;;
&lt;/pre&gt;


&lt;p&gt;コンポーネントの中はこれだけです。&lt;code&gt;window.matchMedia&lt;/code&gt; のことは何も知らなくていいです。ブレークポイントは引数で渡せるので、場面に応じて変えられます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;useGeolocateコールバックとstateをセットにするパターン&quot;&gt;useGeolocate——コールバックとstateをセットにするパターン&lt;/h2&gt;

&lt;h3 id=&quot;なぜ作ったか-1&quot;&gt;なぜ作ったか&lt;/h3&gt;

&lt;p&gt;本サイトの地図では、ユーザーが「現在地ボタン」を押すと自分の位置を地図上にピンで表示します。この機能はMapboxの &lt;code&gt;GeolocateControl&lt;/code&gt; というコントロールを使っています。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GeolocateControl&lt;/code&gt; は「位置情報が取れたとき」と「エラーが起きたとき」のコールバックを受け取ります。このコールバックの中でstateを更新する必要があります。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;位置情報取得成功 → &lt;code&gt;myPos&lt;/code&gt;・&lt;code&gt;myAccuracy&lt;/code&gt; を更新 → 地図をその位置にフォーカス&lt;/li&gt;
&lt;li&gt;位置情報取得失敗 → &lt;code&gt;errMsg&lt;/code&gt; を更新 → エラーを表示&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;この「コールバック＋それに対応するstate」がセットになったのが &lt;code&gt;useGeolocate&lt;/code&gt; です。&lt;/p&gt;

&lt;h3 id=&quot;コード-1&quot;&gt;コード&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/hooks/useGeolocate.ts&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useCallback, useState &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;react&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useGeolocate&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;opts?&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;onFocus&lt;/span&gt;?: (&lt;span class=&quot;synSpecial&quot;&gt;loc&lt;/span&gt;:&lt;span class=&quot;synSpecial&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;lng&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;lat&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt; accuracy&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;?&lt;/span&gt;:&lt;span class=&quot;synSpecial&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;myPos&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setMyPos&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;lng&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;lat&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;&amp;gt;();
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;myAccuracy&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setMyAccuracy&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;undefined&lt;/span&gt;&amp;gt;();
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;errMsg&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setErrMsg&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synType&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);

  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; onError = useCallback((&lt;span class=&quot;synPreProc&quot;&gt;e&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;GeolocationPositionError&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    setErrMsg(e.message ?? &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;現在地を取得できませんでした&amp;quot;&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;);

  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; onGeolocate = useCallback(
    (&lt;span class=&quot;synPreProc&quot;&gt;pos&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;GeolocationPosition&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synPreProc&quot;&gt;latitude&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;longitude&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;accuracy&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; = pos.coords;
      setErrMsg(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
      &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; loc = &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;lng&lt;/span&gt;: longitude, &lt;span class=&quot;synStatement&quot;&gt;lat&lt;/span&gt;: latitude &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;
      setMyPos(loc);
      setMyAccuracy(accuracy);
      opts?.onFocus?.(loc, accuracy); &lt;span class=&quot;synComment&quot;&gt;// 地図をその位置にフォーカスする（外から渡す）&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;opts&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;
  );

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;myPos&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;myAccuracy&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;errMsg&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;setErrMsg&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;onError&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;onGeolocate &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;h3 id=&quot;ポイント-1&quot;&gt;ポイント&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;navigator.geolocation&lt;/code&gt; を直接呼ばない&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;このフックは &lt;code&gt;navigator.geolocation.getCurrentPosition()&lt;/code&gt; を自分では呼びません。Mapboxの &lt;code&gt;GeolocateControl&lt;/code&gt; が位置情報の取得を担い、結果をコールバックとして渡してきます。このフックは「結果を受け取ったらどう処理するか」だけを持っています。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;useCallback&lt;/code&gt; を使う理由&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onGeolocate&lt;/code&gt; と &lt;code&gt;onError&lt;/code&gt; を &lt;code&gt;useCallback&lt;/code&gt; で包んでいるのは、これらをMapboxのイベントリスナーに渡すためです。関数が毎回再生成されると、リスナーの付け替えが無駄に走ります。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;opts?.onFocus&lt;/code&gt;——地図フォーカスは外から渡す&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;位置情報が取れたとき、地図をその座標にアニメーションで移動させたいです。しかし地図のインスタンス（Mapboxの &lt;code&gt;map&lt;/code&gt; オブジェクト）はこのフックの外にあります。「地図を動かす処理」を &lt;code&gt;onFocus&lt;/code&gt; として外から受け取ることで、フック自体は地図のことを知らなくて済みます。&lt;/p&gt;

&lt;h3 id=&quot;使い方-1&quot;&gt;使い方&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; mapRef = useRef&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;mapboxgl.Map&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);

&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synPreProc&quot;&gt;myPos&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;myAccuracy&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;errMsg&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;onError&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;onGeolocate&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; = useGeolocate(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;onFocus&lt;/span&gt;: (&lt;span class=&quot;synPreProc&quot;&gt;loc&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    mapRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt;?.flyTo(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;center&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;loc.lng, loc.lat&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;zoom&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;15&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

&lt;span class=&quot;synComment&quot;&gt;// react-map-gl の GeolocateControl に props として渡す（PowerMap.tsx）&lt;/span&gt;
&amp;lt;GeolocateControl
  onError=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;onError&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
  onGeolocate=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;onGeolocate&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
/&amp;gt;
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;useCloseAll複数storeの横断操作をまとめるパターン&quot;&gt;useCloseAll——複数storeの横断操作をまとめるパターン&lt;/h2&gt;

&lt;h3 id=&quot;なぜ作ったか-2&quot;&gt;なぜ作ったか&lt;/h3&gt;

&lt;p&gt;本サイトの地図画面には4種類のレイヤーがあります。ホテル・グルメ・観光スポット・バス停です。それぞれ選択状態をZustandのstoreで管理しています。&lt;/p&gt;

&lt;p&gt;地図の背景をタップしたとき、開いているパネルをすべて閉じる必要があります。最初はコンポーネントの中に直接書いていました。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// 最初はコンポーネントの中にこれを直接書いていた&lt;/span&gt;
setSelectedHotelNo(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
setSelectedSpot(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
setSelectedRestaurant(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
setSelectedBusStop(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
&lt;span class=&quot;synComment&quot;&gt;// さらにMapboxのpopupも閉じる...&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;「閉じる」という操作が4箇所以上に散らばっていて、1つ追加するたびに全部の呼び出し元を直さなければなりませんでした。これを1つにまとめたのが &lt;code&gt;useCloseAll&lt;/code&gt; です。&lt;/p&gt;

&lt;h3 id=&quot;コード-2&quot;&gt;コード&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// src/hooks/useCloseAll.ts&lt;/span&gt;
&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;use client&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useCallback &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;react&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useHotelStore &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@/store/useHotelStore&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useSpotStore &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@/store/useSpotStore&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useRestaurantStore &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@/store/useRestaurantStore&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useBusStore &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@/store/useBusStore&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useCloseAll&lt;/span&gt;() &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; useCallback(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synComment&quot;&gt;// ホテルを完全リセット&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; hs = useHotelStore.getState();
    hs.closeDetail?.();
    hs.clearSelectedHotel?.();
    hs.setSelectedHotelNo?.(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
    useHotelStore.setState(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;selectedHotelNo&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;isPaneOpen&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;isSheetOpen&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

    &lt;span class=&quot;synComment&quot;&gt;// 観光スポットを完全クリア&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; ss = useSpotStore.getState();
    ss.clearSelectedSpot?.();
    ss.closeSpotPane?.();
    useSpotStore.setState?.(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;selectedSpot&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;, &lt;span class=&quot;synStatement&quot;&gt;isPaneOpen&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

    &lt;span class=&quot;synComment&quot;&gt;// グルメを完全クリア&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; rs = useRestaurantStore.getState();
    rs.clearSelectedRestaurant?.();
    useRestaurantStore.setState?.(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;selectedRestaurant&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

    &lt;span class=&quot;synComment&quot;&gt;// バス停を完全クリア&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; bs = useBusStore.getState();
    bs.clearSelectedBusStop?.();
    useBusStore.setState?.(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;selectedBusStop&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

    &lt;span class=&quot;synComment&quot;&gt;// MapboxのローカルPopupも閉じる（storeの外にあるため）&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (&lt;span class=&quot;synIdentifier&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt; !== &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;undefined&amp;quot;&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;synType&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;dispatchEvent&lt;/span&gt;(&lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; Event(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;map:close-all-popups&amp;quot;&lt;/span&gt;));
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[]&lt;/span&gt;);
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;※実際のコードは store 側の API が複数形式に対応するため型まわりが複雑ですが、やっていることはこれがすべてです。&lt;/p&gt;

&lt;h3 id=&quot;ポイント-2&quot;&gt;ポイント&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;store.getState()&lt;/code&gt; でReactの外からstateを読む&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Zustandは &lt;code&gt;useHotelStore.getState()&lt;/code&gt; でReactのレンダリングサイクルの外からstateを読み書きできます。&lt;code&gt;useCallback&lt;/code&gt; の中でstoreの値を操作するときに使います。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;storeのメソッド + &lt;code&gt;setState&lt;/code&gt; の二重呼び出し&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hs.closeDetail?.()&lt;/code&gt; と &lt;code&gt;useHotelStore.setState(...)&lt;/code&gt; を両方呼んでいます。「storeが提供しているメソッドを優先して呼び、さらに直接stateも上書きする」という念押しです。storeのAPIが途中で変わったときも、どちらかは効くようにするための防御的なコードです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;window.dispatchEvent&lt;/code&gt; でMapboxのPopupを閉じる&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mapboxの地図上に表示しているpopupは、Reactのstoreの外で管理されています。storeをいくら更新してもpopupは消えません。&lt;code&gt;window.dispatchEvent(new Event(&quot;map:close-all-popups&quot;))&lt;/code&gt; でカスタムイベントを発火し、地図コンポーネント側で &lt;code&gt;addEventListener(&quot;map:close-all-popups&quot;, ...)&lt;/code&gt; でキャッチして閉じるようにしています。&lt;/p&gt;

&lt;h3 id=&quot;使い方-2&quot;&gt;使い方&lt;/h3&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; closeAll = useCloseAll();

&lt;span class=&quot;synComment&quot;&gt;// ロゴをクリックしてホームに戻るとき（Header.tsx）&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; handleLogoClick = ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  closeAll();
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;

&lt;span class=&quot;synComment&quot;&gt;// 閉じるボタンを押したとき（RightPane.tsx・RestaurantDetailView.tsx）&lt;/span&gt;
&amp;lt;button onClick=&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;closeAll&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;&amp;gt;閉じる&amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;button&lt;/span&gt;&amp;gt;
&lt;/pre&gt;


&lt;p&gt;どこから呼んでも &lt;code&gt;closeAll&lt;/code&gt; 一発で全部閉じます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3つを並べてわかったこと&quot;&gt;3つを並べてわかったこと&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; フック &lt;/th&gt;
&lt;th&gt; 切り出した理由 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useIsMobile&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; ブラウザAPIの扱いを毎回書きたくなかった &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useGeolocate&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; コールバックとstateがセットで動くので一緒にしたかった &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useCloseAll&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; 複数の場所に散らばった操作を1箇所にまとめたかった &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;「カスタムフックを作る理由」は一つではありませんでした。ブラウザAPIのラップ、関連するコールバックとstateの束、横断的な操作のまとめ——目的が違えば、フックの形も変わります。&lt;/p&gt;

&lt;p&gt;共通しているのは「コンポーネントに書き続けると辛くなった」という経験から生まれていることです。最初から設計したわけではなく、コンポーネントが重くなってから切り出しました。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;本サイト（Next.js + TypeScript + MDX）を作りながら学んでいます。&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fasync-await-then-chain-usage-map-site&quot; title=&quot;async/await と .then() チェーン——しまなみ海道観光サイトで両方使い分けた理由 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/async-await-then-chain-usage-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fjquery-react-declarative-ui&quot; title=&quot;jQueryとReactの対比で学ぶ宣言的UI——サイト内の絞り込み機能で気づいた「状態を渡すだけ」の正体 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/jquery-react-declarative-ui&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Ftanstack-query-migration-custom-cache&quot; title=&quot;自前キャッシュをTanStack Queryに移行した——176行のコードが82行に - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/tanstack-query-migration-custom-cache&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="React" label="React" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/56848dc78a600d9d31d99a4fb077fe6bf5b06cae/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260421%2F20260421104652.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
    <entry>
        <title>自前キャッシュをTanStack Queryに移行した——176行のコードが82行に</title>
        <link href="https://mojitonews.hateblo.jp/entry/tanstack-query-migration-custom-cache"/>
        <id>hatenablog://entry/17179246901378922484</id>
        <published>2026-04-21T09:56:29+09:00</published>
        <updated>2026-04-21T10:05:26+09:00</updated>        <summary type="html">useHotelDetail.tsで自前実装していたMapキャッシュ・inFlight管理・latestNoRefによる競合制御をTanStack Queryに移行しました。176行あったコードが82行になり、「どうやるか」を全部ライブラリに任せられるようになった過程を実際のコードで整理します。</summary>
        <content type="html">&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260421/20260421095137.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#x306E;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x8A73;&amp;#x7D30;&amp;#x8868;&amp;#x793A;&amp;#x3002;&amp;#x5DE6;&amp;#x304C;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x3001;&amp;#x53F3;&amp;#x304C;&amp;#x30DC;&amp;#x30C8;&amp;#x30E0;&amp;#x30B7;&amp;#x30FC;&amp;#x30C8;&amp;#x3067;&amp;#x8A73;&amp;#x7D30;&amp;#x60C5;&amp;#x5831;&amp;#x3092;&amp;#x8868;&amp;#x793A;&amp;#x3057;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x72B6;&amp;#x614B;&quot; width=&quot;751&quot; height=&quot;665&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;本サイトの地図画面には、ホテルのマーカーをタップすると詳細情報をAPIから取得して表示する機能があります。&lt;/p&gt;

&lt;p&gt;この機能、最初は自分でキャッシュを実装していました。同じホテルを2回タップしたときにAPIを叩かないように、&lt;code&gt;Map&lt;/code&gt; オブジェクトを使ってメモリに保存していたのです。&lt;/p&gt;

&lt;p&gt;動いてはいました。でも、コードを見るたびに「これ、本当に正しく動いているのか？」という不安がありました。今回 TanStack Query（React Query）に移行したら、そのコードが丸ごと消えました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;移行前のコード自前で全部やっていた&quot;&gt;移行前のコード——自前で全部やっていた&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/hooks/hotels/useHotelDetail.ts&lt;/code&gt; の中身です。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// メモリキャッシュ（同じホテル再閲覧の高速化）&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; memoryCache = &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt;&amp;gt;();
&lt;span class=&quot;synComment&quot;&gt;// 同時リクエストの集約&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; inFlight = &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt;&amp;gt;&amp;gt;();
&lt;/pre&gt;


&lt;p&gt;ファイルのトップレベルに2つの &lt;code&gt;Map&lt;/code&gt; が定義されていました。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;memoryCache&lt;/code&gt; は「一度取得したホテルの詳細データを保存しておく箱」です。同じホテルをタップしたとき、まずここを見てデータがあればAPIを叩かずに返します。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inFlight&lt;/code&gt; は「今まさに進行中のリクエスト」を保存する箱です。同じホテルに対して2つのリクエストが同時に走るのを防ぐための仕組みで、「すでにリクエスト中なら、その Promise を共有する」という処理をしていました。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useHotelDetail&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;hotelNo&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;detail&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setDetail&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;error&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setError&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;Error&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;loading&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setLoading&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synType&quot;&gt;boolean&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);

  &lt;span class=&quot;synComment&quot;&gt;// 競合回避用に最新 hotelNo を保持&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; latestNoRef = useRef&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
  useEffect(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    latestNoRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; = hotelNo;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;hotelNo&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);

  useEffect(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synComment&quot;&gt;// キャッシュ命中なら即返す&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; cached = memoryCache.&lt;span class=&quot;synStatement&quot;&gt;get&lt;/span&gt;(normalizedNo);
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (cached) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      setDetail(cached);
      setLoading(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);
      &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;;
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; controller = &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;AbortController&lt;/span&gt;();

    &lt;span class=&quot;synComment&quot;&gt;// 進行中の同一リクエストがあればそれを共有&lt;/span&gt;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; existing = inFlight.&lt;span class=&quot;synStatement&quot;&gt;get&lt;/span&gt;(normalizedNo);
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; p = existing ?? fetchDetail(normalizedNo, signal);
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!existing) inFlight.&lt;span class=&quot;synStatement&quot;&gt;set&lt;/span&gt;(normalizedNo, p);

    p.&lt;span class=&quot;synStatement&quot;&gt;then&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;data&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (latestNoRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; !== normalizedNo) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;synComment&quot;&gt;// 競合チェック&lt;/span&gt;
      memoryCache.&lt;span class=&quot;synStatement&quot;&gt;set&lt;/span&gt;(normalizedNo, data);
      setDetail(data);
      setLoading(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
    .&lt;span class=&quot;synStatement&quot;&gt;catch&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;e&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (signal.aborted) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;;
      &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (latestNoRef.&lt;span class=&quot;synStatement&quot;&gt;current&lt;/span&gt; !== normalizedNo) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;;
      setError(e &lt;span class=&quot;synIdentifier&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Error&lt;/span&gt; ? e : &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;synType&quot;&gt;String&lt;/span&gt;(e)));
      setLoading(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;)
    .&lt;span class=&quot;synStatement&quot;&gt;finally&lt;/span&gt;(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!existing) inFlight.&lt;span class=&quot;synStatement&quot;&gt;delete&lt;/span&gt;(normalizedNo);
    &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

    &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; controller.&lt;span class=&quot;synStatement&quot;&gt;abort&lt;/span&gt;();
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;normalizedNo&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;やっていることを整理すると：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;キャッシュ（&lt;code&gt;memoryCache&lt;/code&gt;）にデータがあれば即返す&lt;/li&gt;
&lt;li&gt;なければ &lt;code&gt;inFlight&lt;/code&gt; を確認して、進行中なら Promise を共有、なければ新しくfetch&lt;/li&gt;
&lt;li&gt;完了したら &lt;code&gt;memoryCache&lt;/code&gt; に保存&lt;/li&gt;
&lt;li&gt;&lt;code&gt;latestNoRef&lt;/code&gt; でホテルを切り替えたときの競合（古いデータが後から届く問題）を防ぐ&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AbortController&lt;/code&gt; でアンマウント時のリクエストをキャンセル&lt;/li&gt;
&lt;/ol&gt;


&lt;p&gt;動いてはいましたが、コードが複雑で「この &lt;code&gt;latestNoRef&lt;/code&gt; のチェック、本当に全パスで正しいか？」という不安が常にありました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;インストール&quot;&gt;インストール&lt;/h2&gt;

&lt;pre class=&quot;code bash&quot; data-lang=&quot;bash&quot; data-unlink&gt;npm install @tanstack/react-query&lt;/pre&gt;


&lt;p&gt;これだけです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;Step-1QueryClientProvider-をルートに追加&quot;&gt;Step 1：QueryClientProvider をルートに追加&lt;/h2&gt;

&lt;p&gt;TanStack Query はアプリ全体で &lt;code&gt;QueryClient&lt;/code&gt; を共有します。このプロジェクトでは &lt;code&gt;src/components/ClientProviders.tsx&lt;/code&gt; にすべての Provider が集まっているので、ここに追加しました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;変更前&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; ThemeProvider &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;next-themes&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;ClientProviders&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{ children }&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; (
    &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;ThemeProvider&lt;/span&gt; ...&amp;gt;
      &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;children&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
    &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;ThemeProvider&lt;/span&gt;&amp;gt;
  );
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;変更後&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useState &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;react&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; QueryClient, QueryClientProvider &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@tanstack/react-query&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; ThemeProvider &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;next-themes&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;ClientProviders&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{ children }&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;queryClient&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState(
    ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; QueryClient(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;synStatement&quot;&gt;defaultOptions&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;synStatement&quot;&gt;queries&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;synStatement&quot;&gt;staleTime&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;1000&lt;/span&gt; * &lt;span class=&quot;synStatement&quot;&gt;60 &lt;/span&gt;* &lt;span class=&quot;synStatement&quot;&gt;5&lt;/span&gt;, &lt;span class=&quot;synComment&quot;&gt;// 5分&lt;/span&gt;
            &lt;span class=&quot;synStatement&quot;&gt;retry&lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;1&lt;/span&gt;,
          &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
        &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;,
      &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;),
  );

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; (
    &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;QueryClientProvider&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;client&lt;/span&gt;=&lt;span class=&quot;synIdentifier&quot;&gt;{queryClient}&lt;/span&gt;&amp;gt;
      &amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;ThemeProvider&lt;/span&gt; ...&amp;gt;
        &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;children&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
      &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;ThemeProvider&lt;/span&gt;&amp;gt;
    &amp;lt;/&lt;span class=&quot;synIdentifier&quot;&gt;QueryClientProvider&lt;/span&gt;&amp;gt;
  );
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;なぜ &lt;code&gt;useState&lt;/code&gt; で生成するのか&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QueryClient&lt;/code&gt; をコンポーネントの外で &lt;code&gt;const queryClient = new QueryClient()&lt;/code&gt; と書くと、モジュールが読み込まれたタイミングで1度だけ生成されます。&lt;code&gt;useState(() =&amp;gt; new QueryClient())&lt;/code&gt; にしておくと「コンポーネントが初回レンダリングされたときに1度だけ生成する」ことが保証されます。SSRで複数インスタンスが生まれる問題を防ぐための書き方です。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;staleTime: 1000 * 60 * 5&lt;/code&gt; とは&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;「5分間はキャッシュを新鮮とみなし、再fetchしない」という設定です。ホテルの詳細情報は数分で変わるものではないので、5分はちょうどよい値でした。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;Step-2useHotelDetailts-を-useQuery-に書き換え&quot;&gt;Step 2：useHotelDetail.ts を useQuery に書き換え&lt;/h2&gt;

&lt;p&gt;ここがメインの変更です。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;変更前&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// ファイルトップレベルの自前キャッシュ&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; memoryCache = &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt;&amp;gt;();
&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; inFlight = &lt;span class=&quot;synIdentifier&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt;&amp;gt;&amp;gt;();

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useHotelDetail&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;hotelNo&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;detail&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setDetail&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;error&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setError&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;Error&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;loading&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setLoading&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState&amp;lt;&lt;span class=&quot;synType&quot;&gt;boolean&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;false&lt;/span&gt;);
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; latestNoRef = useRef&amp;lt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;);

  &lt;span class=&quot;synComment&quot;&gt;// ...キャッシュ確認・inFlight管理・競合チェック・AbortControllerの手動管理&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;変更後&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useMemo &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;react&amp;quot;&lt;/span&gt;;
&lt;span class=&quot;synSpecial&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; useQuery, useQueryClient &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;@tanstack/react-query&amp;quot;&lt;/span&gt;;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;useHotelDetail&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;hotelNo&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; queryClient = useQueryClient();

  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; normalizedNo = useMemo(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (hotelNo == &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;;
    &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; n = &lt;span class=&quot;synType&quot;&gt;Number&lt;/span&gt;(hotelNo);
    &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;isFinite&lt;/span&gt;(n) ? n : &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;hotelNo&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);

  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;synPreProc&quot;&gt;detail&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;isLoading&lt;/span&gt;: &lt;span class=&quot;synPreProc&quot;&gt;loading&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;error&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;refetch&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; = useQuery(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;queryKey&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;hotelDetail&amp;quot;&lt;/span&gt;, normalizedNo&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;queryFn&lt;/span&gt;: (&lt;span class=&quot;synPreProc&quot;&gt;{ signal }&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; fetchDetail(normalizedNo!, signal),
    &lt;span class=&quot;synStatement&quot;&gt;enabled&lt;/span&gt;: normalizedNo != &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;,
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);

  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; refresh = &lt;span class=&quot;synStatement&quot;&gt;async&lt;/span&gt; ()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (normalizedNo == &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt;;
    &lt;span class=&quot;synStatement&quot;&gt;await&lt;/span&gt; queryClient.invalidateQueries(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;queryKey&lt;/span&gt;: &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;hotelDetail&amp;quot;&lt;/span&gt;, normalizedNo&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);
    &lt;span class=&quot;synStatement&quot;&gt;await&lt;/span&gt; refetch();
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;

  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;detail&lt;/span&gt;: detail ?? &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;loading&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;error&lt;/span&gt;: error &lt;span class=&quot;synIdentifier&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;Error &lt;/span&gt;? &lt;span class=&quot;synStatement&quot;&gt;error &lt;/span&gt;: &lt;span class=&quot;synConstant&quot;&gt;null&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;refresh&lt;/span&gt;,
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;;
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;削除されたコードと、その代わりに何が担っているかの対応です。&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 削除したコード &lt;/th&gt;
&lt;th&gt; TanStack Query が代わりに担うこと &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;memoryCache = new Map()&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; &lt;code&gt;queryKey&lt;/code&gt; ごとに自動でキャッシュ。&lt;code&gt;staleTime&lt;/code&gt; 内なら再fetchしない &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;inFlight = new Map()&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; 同じ &lt;code&gt;queryKey&lt;/code&gt; への同時リクエストを自動で1本に集約 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;latestNoRef&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; &lt;code&gt;queryKey&lt;/code&gt; が変わると自動で前のクエリを無効化 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;AbortController&lt;/code&gt; の手動管理 &lt;/td&gt;
&lt;td&gt; &lt;code&gt;queryFn&lt;/code&gt; の引数 &lt;code&gt;signal&lt;/code&gt; に自動で渡される &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&lt;code&gt;fetchDetail&lt;/code&gt; 関数自体も少し変わっています。移行前は第3引数 &lt;code&gt;opts&lt;/code&gt; で &lt;code&gt;cache: &quot;no-store&quot;&lt;/code&gt; を切り替える仕組みがありました。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// 移行前&lt;/span&gt;
&lt;span class=&quot;synStatement&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;fetchDetail&lt;/span&gt;(
&lt;span class=&quot;synPreProc&quot;&gt;  hotelNo&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;synPreProc&quot;&gt;  signal?&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;AbortSignal&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;synPreProc&quot;&gt;  opts?&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;FetchDetailOptions&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;// ← refresh() 時に no-store を渡すための引数&lt;/span&gt;
): &lt;span class=&quot;synIdentifier&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;synIdentifier&quot;&gt;HotelDetail&lt;/span&gt;&amp;gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;synStatement&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;synType&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;synConstant&quot;&gt;`...`&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;cache&lt;/span&gt;: opts?.noStore ? &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;no-store&amp;quot;&lt;/span&gt; : &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;default&amp;quot;&lt;/span&gt;,
    &lt;span class=&quot;synStatement&quot;&gt;signal&lt;/span&gt;,
  &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;移行後は &lt;code&gt;refresh()&lt;/code&gt; が &lt;code&gt;invalidateQueries&lt;/code&gt; でキャッシュを無効化するようになったため、&lt;code&gt;no-store&lt;/code&gt; を渡す必要がなくなり、&lt;code&gt;opts&lt;/code&gt; ごと削除できました。&lt;code&gt;signal&lt;/code&gt; の受け取り方も &lt;code&gt;queryFn&lt;/code&gt; の引数から受け取る形に変わっています。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// 変更後（queryFn の引数から signal を受け取る）&lt;/span&gt;
queryFn: (&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;signal &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;) =&amp;gt; fetchDetail(normalizedNo!, signal),
&lt;/pre&gt;


&lt;p&gt;また、&lt;code&gt;useQuery&lt;/code&gt; が返す値の名前は &lt;code&gt;isLoading&lt;/code&gt; ですが、既存のコンポーネントは &lt;code&gt;loading&lt;/code&gt; という名前で受け取っています。分割代入時に読み替えることで、呼び出し側のコンポーネントは一切変更不要でした。&lt;/p&gt;

&lt;pre class=&quot;code lang-typescript&quot; data-lang=&quot;typescript&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;synPreProc&quot;&gt;detail&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;isLoading&lt;/span&gt;: &lt;span class=&quot;synPreProc&quot;&gt;loading&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;error&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;refetch&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt; = useQuery(&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;...&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;);
&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;動作確認&quot;&gt;動作確認&lt;/h2&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;X-Vercel-Cache: STALE はキャッシュはあるが期限切れと判断され、再fetchが走った状態です。次のタップでは HIT になりキャッシュから即返ります&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260421/20260421095255.png&quot; alt=&quot;DevTools&amp;#x306E;Network&amp;#x30BF;&amp;#x30D6;&amp;#x3002;hotel-detail&amp;#x3078;&amp;#x306E;&amp;#x30EA;&amp;#x30AF;&amp;#x30A8;&amp;#x30B9;&amp;#x30C8;&amp;#x304C;&amp;#x8D70;&amp;#x308A;&amp;#x3001;X-Vercel-Cache&amp;#x304C;STALE&amp;#x306B;&amp;#x306A;&amp;#x3063;&amp;#x3066;&amp;#x3044;&amp;#x308B;&quot; width=&quot;1200&quot; height=&quot;812&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;X-Vercel-Cache: STALE はキャッシュはあるが期限切れと判断され、再fetchが走った状態です。次のタップでは HIT になりキャッシュから即返ります&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;ブラウザの DevTools（Network タブ）を開いてホテルマーカーをタップします。&lt;code&gt;/api/rakuten/hotel-detail?hotelNo=xxxxx&lt;/code&gt; へのリクエストが1回走ります。&lt;/p&gt;

&lt;p&gt;同じマーカーをもう一度タップすると、リクエストが走りません。キャッシュから即座に表示されます。&lt;/p&gt;

&lt;p&gt;5分後に再タップすると、もう1回リクエストが走ります。&lt;code&gt;staleTime&lt;/code&gt; の5分が経過して「古いデータ」と判断されたためです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;削除できたコードの量&quot;&gt;削除できたコードの量&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; &lt;/th&gt;
&lt;th&gt; 移行前 &lt;/th&gt;
&lt;th&gt; 移行後 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; ファイルの行数 &lt;/td&gt;
&lt;td&gt; 176行 &lt;/td&gt;
&lt;td&gt; 82行 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; トップレベルの Map &lt;/td&gt;
&lt;td&gt; 2個 &lt;/td&gt;
&lt;td&gt; 0個 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; useRef &lt;/td&gt;
&lt;td&gt; 1個 &lt;/td&gt;
&lt;td&gt; 0個 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; useState &lt;/td&gt;
&lt;td&gt; 3個 &lt;/td&gt;
&lt;td&gt; 0個 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; useEffect &lt;/td&gt;
&lt;td&gt; 2個 &lt;/td&gt;
&lt;td&gt; 0個 &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;半分以下になりました。そして残ったコードは「何をするか」だけが書いてあり、「どうやるか」は TanStack Query に任せています。&lt;/p&gt;

&lt;p&gt;「動いてはいるけど不安」というコードを抱えているなら、TanStack Query への移行を検討する価値はあると思います。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;本サイト（Next.js + TypeScript + MDX）を作りながら学んでいます。&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fasync-await-then-chain-usage-map-site&quot; title=&quot;async/await と .then() チェーン——しまなみ海道観光サイトで両方使い分けた理由 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/async-await-then-chain-usage-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fuse-client-where-to-put-server-client-component&quot; title=&quot;&amp;quot;use client&amp;quot; はどこに書くのか——エラーが出るたびにつけ足していたら全部 Client Component になっていた - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/use-client-where-to-put-server-client-component&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="React" label="React" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/241e7ac902b7ab06dbeca21a3e171b62928ea554/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260421%2F20260421095137.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>jQueryとReactの対比で学ぶ宣言的UI——サイト内の絞り込み機能で気づいた「状態を渡すだけ」の正体</title>
        <link href="https://mojitonews.hateblo.jp/entry/jquery-react-declarative-ui"/>
        <id>hatenablog://entry/17179246901378496007</id>
        <published>2026-04-20T07:07:40+09:00</published>
        <updated>2026-04-20T07:07:40+09:00</updated>        <summary type="html">jQueryとReactの違いを「命令的vs宣言的」の対比で整理します。実際に本サイトのスポット検索機能を実装したときの体験をもとに、「状態を渡すだけで画面が変わる」という感覚が腹落ちするまでを書きました。</summary>
        <content type="html">&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;左：検索前の全29件表示　右：「尾道」と入力した直後の12件。コードは状態を変えただけです。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260420/20260420070319.png&quot; alt=&quot;jQuery&amp;#x3068;React&amp;#x306E;&amp;#x9055;&amp;#x3044;&amp;#x3092;&amp;#x793A;&amp;#x3059;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x691C;&amp;#x7D22;&amp;#x6A5F;&amp;#x80FD;&amp;#x306E;&amp;#x30B9;&amp;#x30AF;&amp;#x30EA;&amp;#x30FC;&amp;#x30F3;&amp;#x30B7;&amp;#x30E7;&amp;#x30C3;&amp;#x30C8;&amp;#x3002;&amp;#x5DE6;&amp;#x304C;&amp;#x691C;&amp;#x7D22;&amp;#x524D;&amp;#x306E;&amp;#x5168;&amp;#x4EF6;&amp;#x8868;&amp;#x793A;&amp;#x3001;&amp;#x53F3;&amp;#x304C;&amp;#x300C;&amp;#x5C3E;&amp;#x9053;&amp;#x300D;&amp;#x5165;&amp;#x529B;&amp;#x5F8C;&amp;#x306E;&amp;#x7D5E;&amp;#x308A;&amp;#x8FBC;&amp;#x307F;&amp;#x7D50;&amp;#x679C;&quot; width=&quot;754&quot; height=&quot;665&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;左：検索前の全29件表示　右：「尾道」と入力した直後の12件。コードは状態を変えただけです。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;「宣言的UI」という言葉、React の入門記事でよく目にするのですが、最初はいまいちピンときませんでした。&lt;/p&gt;

&lt;p&gt;「命令的との違い」を読んで、なるほど。と思いつつ、実際に手を動かすまでは腹落ちしていませんでした。その感覚が変わったのは、本サイトを Next.js で作り始めて、スポットの絞り込み機能を実装したときのことです。&lt;/p&gt;

&lt;p&gt;この記事では、jQuery を知っている人に向けて「宣言的UIとは何か」を before/after の対比で整理します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;jQuery-で書くとしたらワイが全部やるスタイル&quot;&gt;jQuery で書くとしたら——「ワイが全部やる」スタイル&lt;/h2&gt;

&lt;p&gt;観光スポットをキーワードで絞り込む機能を jQuery で書くとしたら、こうなります。&lt;/p&gt;

&lt;pre class=&quot;code lang-javascript&quot; data-lang=&quot;javascript&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;#spot-search&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;input&#39;&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synType&quot;&gt;const&lt;/span&gt; q &lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;val&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;toLowerCase&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;synComment&quot;&gt;// 全カードをいったん非表示&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;.spot-card&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;hide&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;synComment&quot;&gt;// キーワードに一致するカードだけ表示&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;.spot-card&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;name&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;toLowerCase&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;q&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;synComment&quot;&gt;// data-name属性から取得&lt;/span&gt;
  &lt;span class=&quot;synSpecial&quot;&gt;})&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;show&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;synComment&quot;&gt;// カウンターを更新&lt;/span&gt;
  &lt;span class=&quot;synType&quot;&gt;const&lt;/span&gt; count &lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;.spot-card:visible&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;length&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&#39;#result-count&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;(&lt;/span&gt;count &lt;span class=&quot;synStatement&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&#39;件&#39;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;})&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;;&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;やっていることは明確です。入力されたら、全部隠して、一致するものだけ出して、カウンターを書き換える。&lt;strong&gt;手順を一行ずつ書いています。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;なお、このコードは各カード要素に &lt;code&gt;data-name=&quot;スポット名&quot;&lt;/code&gt; 属性がついている前提です。&lt;/p&gt;

&lt;p&gt;DOM を自分の手で動かしている感覚があります。これはこれで直感的ですし、小規模なら十分に機能します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;React-で書いたら状態を渡すだけ&quot;&gt;React で書いたら——「状態を渡すだけ」&lt;/h2&gt;

&lt;p&gt;同じ機能を Next.js（React）で実装したとき、コードはこうなりました。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;synPreProc&quot;&gt;query&lt;/span&gt;, &lt;span class=&quot;synPreProc&quot;&gt;setQuery&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt; = useState(&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;);

&lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; filtered = useMemo(()&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;const&lt;/span&gt; q = query.&lt;span class=&quot;synStatement&quot;&gt;trim&lt;/span&gt;().&lt;span class=&quot;synStatement&quot;&gt;toLowerCase&lt;/span&gt;();
  &lt;span class=&quot;synStatement&quot;&gt;if&lt;/span&gt; (!q) &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; spots;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; spots.&lt;span class=&quot;synStatement&quot;&gt;filter&lt;/span&gt;(
    (&lt;span class=&quot;synPreProc&quot;&gt;s&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt;
      s.&lt;span class=&quot;synStatement&quot;&gt;name&lt;/span&gt;.&lt;span class=&quot;synStatement&quot;&gt;toLowerCase&lt;/span&gt;().&lt;span class=&quot;synStatement&quot;&gt;includes&lt;/span&gt;(q) ||
      s.shortDescription?.&lt;span class=&quot;synStatement&quot;&gt;toLowerCase&lt;/span&gt;().&lt;span class=&quot;synStatement&quot;&gt;includes&lt;/span&gt;(q) ||
      s.tags?.&lt;span class=&quot;synStatement&quot;&gt;some&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;t&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; t.&lt;span class=&quot;synStatement&quot;&gt;toLowerCase&lt;/span&gt;().&lt;span class=&quot;synStatement&quot;&gt;includes&lt;/span&gt;(q))
  );
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;, &lt;span class=&quot;synIdentifier&quot;&gt;[&lt;/span&gt;spots, query&lt;span class=&quot;synIdentifier&quot;&gt;]&lt;/span&gt;);

&lt;span class=&quot;synComment&quot;&gt;// 入力で状態を更新するだけ&lt;/span&gt;
&lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;input&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;query&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;onChange&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;e&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; setQuery(e.&lt;span class=&quot;synStatement&quot;&gt;target&lt;/span&gt;.value)&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;placeholder&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synConstant&quot;&gt;&amp;quot;スポット名・キーワードで検索…&amp;quot;&lt;/span&gt;
&lt;span class=&quot;synComment&quot;&gt;/&amp;gt;&lt;/span&gt;

&lt;span class=&quot;synComment&quot;&gt;// あとは状態に基づいてレンダリング&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;filtered.&lt;span class=&quot;synStatement&quot;&gt;map&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;spot&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; (
  &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;SpotCard &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;spot.&lt;span class=&quot;synStatement&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;spot&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;spot&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;/&amp;gt;&lt;/span&gt;
))&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;最初に書いたとき、正直戸惑いました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;「え、これだけ？ カードを hide/show しなくていいの？」&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setQuery&lt;/code&gt; で文字列を更新するだけで、&lt;code&gt;filtered&lt;/code&gt; が自動的に再計算され、&lt;code&gt;SpotCard&lt;/code&gt; が再レンダリングされます。自分で DOM を触るコードはどこにもありません。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useMemo&lt;/code&gt; は「&lt;code&gt;query&lt;/code&gt; が変わったときだけ再計算する」という最適化ですが、本質は同じです。状態（&lt;code&gt;query&lt;/code&gt;）が変われば、画面が勝手についてきます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;実際に動いている本サイトのスポット検索機能&quot;&gt;実際に動いている——本サイトのスポット検索機能&lt;/h2&gt;

&lt;p&gt;上のコードは、本サイトのスポット一覧ページ（&lt;a href=&quot;https://www.shimanami-guide.jp/spots&quot;&gt;shimanami-guide.jp/spots&lt;/a&gt;）でそのまま動いています。&lt;/p&gt;

&lt;p&gt;ページを開くと、最初はすべてのスポットが表示されています。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;しまなみ海道の観光スポット一覧。全29件が表示された初期状態です。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260420/20260420065645.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x306E;&amp;#x89B3;&amp;#x5149;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x4E00;&amp;#x89A7;&amp;#x30DA;&amp;#x30FC;&amp;#x30B8;&amp;#x3002;&amp;#x691C;&amp;#x7D22;&amp;#x30D0;&amp;#x30FC;&amp;#x306F;&amp;#x7A7A;&amp;#x6B04;&amp;#x3067;&amp;#x5168;29&amp;#x4EF6;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&quot; width=&quot;375&quot; height=&quot;668&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;しまなみ海道の観光スポット一覧。全29件が表示された初期状態です。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;検索バーに「尾道」と入力すると、一致するスポットだけが残ります。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;「尾道」と入力した瞬間に12件へ絞り込まれました。検索ボタンは配置していません。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260420/20260420070126.png&quot; alt=&quot;&amp;#x5C3E;&amp;#x9053;&amp;#x300D;&amp;#x3068;&amp;#x5165;&amp;#x529B;&amp;#x3057;&amp;#x305F;&amp;#x5F8C;&amp;#x306E;&amp;#x7D5E;&amp;#x308A;&amp;#x8FBC;&amp;#x307F;&amp;#x7D50;&amp;#x679C;&amp;#x3002;12&amp;#x4EF6;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&quot; width=&quot;373&quot; height=&quot;665&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;「尾道」と入力した瞬間に12件へ絞り込まれました。検索ボタンは配置していません。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;DOM を直接触るコードは1行もありません。&lt;code&gt;setQuery(&quot;尾道&quot;)&lt;/code&gt; が走った瞬間に &lt;code&gt;filtered&lt;/code&gt; が再計算され、画面が切り替わります。状態を変えただけで、これが起きています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;あこれが-React-の正体かという瞬間&quot;&gt;「あ、これが React の正体か」という瞬間&lt;/h2&gt;

&lt;p&gt;本サイトの地図コンポーネントを作っていて、ある瞬間に気づきました。&lt;/p&gt;

&lt;p&gt;jQuery のときは&lt;strong&gt;「どう動かすか」を書いていました。&lt;/strong&gt;&lt;br/&gt;
React のときは&lt;strong&gt;「どういう状態のとき、何を表示するか」を書いています。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;jQuery は手順書です。「Aをして、Bをして、Cを変える」と書きます。&lt;br/&gt;
React は完成図です。「この状態のとき、画面はこう見えるべき」と宣言します。&lt;/p&gt;

&lt;p&gt;これが&lt;strong&gt;宣言的UI（Declarative UI）&lt;/strong&gt;の正体でした。&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;命令的（jQuery）&lt;/strong&gt;：「ステップ1: 非表示にする。ステップ2: 対象を表示する。ステップ3: カウンターを更新する」&lt;br/&gt;
&lt;strong&gt;宣言的（React）&lt;/strong&gt;：「&lt;code&gt;query&lt;/code&gt; がこの値のとき、画面はこう見えるべき」&lt;/p&gt;&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;宣言的UIの何がうれしいのか&quot;&gt;宣言的UIの何がうれしいのか&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;状態の管理が一元化されます。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;jQuery だと、複数の場所で DOM を操作するうちに「どれが正しい状態か」が分からなくなることがあります。カードは非表示なのにカウンターが更新されていない、といったバグが起きやすいです。&lt;/p&gt;

&lt;p&gt;React は状態（&lt;code&gt;query&lt;/code&gt;）が唯一の真実です。状態が変われば、それに依存するすべての UI が自動的に整合性を保って更新されます。&lt;/p&gt;

&lt;pre class=&quot;code&quot; data-lang=&quot;&quot; data-unlink&gt;状態（state）
  └── filtered（派生）
        └── SpotCard の表示（UI）
        └── resultCount の表示（UI）&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;状態を変えれば、UI は勝手についてきます。&lt;/strong&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;jQuery-が悪いわけじゃない&quot;&gt;jQuery が悪いわけじゃない&lt;/h2&gt;

&lt;p&gt;誤解してほしくないのですが、jQuery は今も現役で正解なケースがあります。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;既存の HTML テンプレート（WordPress など）に少し動きを加えたい&lt;/li&gt;
&lt;li&gt;ページ遷移のある MPA（マルチページアプリ）で軽い操作だけしたい&lt;/li&gt;
&lt;li&gt;バンドルサイズを最小限にしたい小規模サイト&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;こういうときに React を持ち込むのは過剰です。jQuery は今も優秀なツールです。&lt;/p&gt;

&lt;p&gt;ただ、&lt;strong&gt;状態が複数あって、それに応じた UI が連動する&lt;/strong&gt;ような場面では、宣言的UIの考え方が圧倒的にコードを整理してくれます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;まとめ&quot;&gt;まとめ&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; &lt;/th&gt;
&lt;th&gt; jQuery（命令的） &lt;/th&gt;
&lt;th&gt; React（宣言的） &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; 書くもの &lt;/td&gt;
&lt;td&gt; 「何をするか」の手順 &lt;/td&gt;
&lt;td&gt; 「どう見えるべきか」の宣言 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; DOM 操作 &lt;/td&gt;
&lt;td&gt; 自分で書く &lt;/td&gt;
&lt;td&gt; React が担う &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 状態管理 &lt;/td&gt;
&lt;td&gt; 散在しやすい &lt;/td&gt;
&lt;td&gt; 一元化される &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 向いている場面 &lt;/td&gt;
&lt;td&gt; 既存 HTML への追加・小規模 &lt;/td&gt;
&lt;td&gt; 状態が多い・連動する UI &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&lt;strong&gt;jQueryは命令書、Reactは完成図を渡す。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;この一文が、宣言的UIを一番シンプルに表していると思います。本サイトを作りながら、私が手を動かして初めてその意味が腹落ちしました。&lt;/p&gt;

&lt;p&gt;「Reactよくわからん」と思っていた頃の私に、この記事を渡したかったです。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;本サイト（Next.js + TypeScript + MDX）を作りながら学んでいます。&lt;/em&gt;&lt;/p&gt;
</content>        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/563f68dcde61e555a328e33e0fbb87d258a530b0/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260420%2F20260420070319.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>&quot;use client&quot; はどこに書くのか——エラーが出るたびにつけ足していたら全部 Client Component になっていた</title>
        <link href="https://mojitonews.hateblo.jp/entry/use-client-where-to-put-server-client-component"/>
        <id>hatenablog://entry/17179246901377483579</id>
        <published>2026-04-17T13:59:39+09:00</published>
        <updated>2026-04-17T13:59:39+09:00</updated>        <summary type="html">Next.js App RouterでエラーのたびにServer/Client Componentの役割分担が理解できずに詰まった経験をもとに、&quot;use client&quot;をどこに書くべきかを実際のコードで整理します。fsが使えない理由・データをpropsで渡す設計・バンドルサイズへの影響も解説。</summary>
        <content type="html">&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;サーバー領域（緑）でgetAllSpots()を実行してSpot&lt;span data-unlink&gt;を取得し、クライアント領域（紫）のHomeClient・SpotsListClientにpropsで渡します。MapboxやuseStateなどブラウザ専用の処理（オレンジ）はツリーの末端に置きます&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260417/20260417135705.png&quot; alt=&quot;Next.js App Router&amp;#x306E;Server/Client Component&amp;#x30C4;&amp;#x30EA;&amp;#x30FC;&amp;#x56F3;&amp;mdash;&amp;mdash;page.tsx&amp;#x304C;getAllSpots()&amp;#x3067;&amp;#x30D5;&amp;#x30A1;&amp;#x30A4;&amp;#x30EB;&amp;#x3092;&amp;#x8AAD;&amp;#x307F;&amp;#x8FBC;&amp;#x307F;HomeClient&amp;#x3068;SpotsListClient&amp;#x306B;props&amp;#x3067;&amp;#x6E21;&amp;#x3057;&amp;#x3001;&amp;#x305D;&amp;#x306E;&amp;#x4E0B;&amp;#x306B;Mapbox&amp;#x3084;useState&amp;#x304C;&amp;#x914D;&amp;#x7F6E;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x69CB;&amp;#x9020;&quot; width=&quot;680&quot; height=&quot;480&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;サーバー領域（緑）でgetAllSpots()を実行してSpot&lt;/span&gt;を取得し、クライアント領域（紫）のHomeClient・SpotsListClientにpropsで渡します。MapboxやuseStateなどブラウザ専用の処理（オレンジ）はツリーの末端に置きます&lt;/figcaption&gt;&lt;/figure&gt;
以前、&lt;code&gt;fs&lt;/code&gt; を使ったらビルドエラーが出た話を書きました。&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Ffs-error-nextjs-server-client-component&quot; title=&quot;`fs` を使ったらエラーが出た——Next.js の Server / Client Component を理解するまで - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/fs-error-nextjs-server-client-component&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;あのときは「&lt;code&gt;fs&lt;/code&gt; はサーバー専用だから Client Component で呼べない」という原因にたどり着いて終わりました。ただその後も同じ問題を別の形で踏み続けました。エラーが出る → とりあえず &lt;code&gt;&quot;use client&quot;&lt;/code&gt; を先頭に追加 → 直る。この繰り返しをしているうちに、気づけばプロジェクトのほぼ全コンポーネントに &lt;code&gt;&quot;use client&quot;&lt;/code&gt; がついていました。「Pages Router のときは全部クライアントだったんだから、同じでいいんじゃないの？」と思っていました。&lt;/p&gt;

&lt;p&gt;今回はその先——「では &lt;code&gt;&quot;use client&quot;&lt;/code&gt; はどこに書くべきか」という設計の判断基準を、実際のコードで整理します。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;h2 id=&quot;App-Router-の役割分担はシンプル&quot;&gt;App Router の役割分担はシンプル&lt;/h2&gt;

&lt;p&gt;考え方の基本はこうです。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;データ取得・ファイル読み込み → Server Component でやる&lt;/li&gt;
&lt;li&gt;インタラクション（useState・イベント） → Client Component でやる&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;問題は「それがコードとしてどう表れるか」が最初はイメージしにくいことです。実際のファイルで確認します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;地図ページの場合map&quot;&gt;地図ページの場合（/map）&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/app/map/page.tsx（Server Component・&amp;#34;use client&amp;#34; なし）
import HomeClient from &amp;#34;@/components/HomeClient&amp;#34;;
import { getAllSpots } from &amp;#34;@/lib/spotsData&amp;#34;;

export default function MapPage() {
  const spots = getAllSpots(); // サーバーで実行される

  return (
    &amp;lt;main className=&amp;#34;relative h-[calc(100dvh-60px)] overflow-hidden&amp;#34;&amp;gt;
      &amp;lt;Suspense fallback={null}&amp;gt;
        &amp;lt;HomeClient spots={spots} /&amp;gt; {/* Spot[] を props で渡す */}
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/pre&gt;




&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/components/HomeClient.tsx（Client Component）
&amp;#34;use client&amp;#34;;

import PowerMap from &amp;#34;@/components/PowerMap&amp;#34;;

export default function HomeClient({ spots }: { spots: Spot[] }) {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;section className=&amp;#34;h-full&amp;#34;&amp;gt;
        &amp;lt;PowerMap spots={spots} /&amp;gt; {/* Mapbox はブラウザ専用APIを使う */}
      &amp;lt;/section&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt; はデフォルトで Server Component なので &lt;code&gt;&quot;use client&quot;&lt;/code&gt; は書きません。&lt;code&gt;getAllSpots()&lt;/code&gt; がサーバー上で実行され、結果の &lt;code&gt;Spot[]&lt;/code&gt; 配列を &lt;code&gt;HomeClient&lt;/code&gt; に props として渡します。&lt;code&gt;HomeClient&lt;/code&gt; は &lt;code&gt;&quot;use client&quot;&lt;/code&gt; を持ち、Mapbox（WebGL を使うブラウザ専用ライブラリ）を動かします。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;スポット一覧ページの場合spots&quot;&gt;スポット一覧ページの場合（/spots）&lt;/h2&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/app/spots/page.tsx（Server Component）
export default function SpotsPage() {
  const spots = getAllSpots(); // サーバーでファイル読み込み

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;SpotsListClient spots={spots} /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/pre&gt;




&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/components/spots/SpotsListClient.tsx
&amp;#34;use client&amp;#34;;

export default function SpotsListClient({ spots }: { spots: Spot[] }) {
  const [query, setQuery] = useState(&amp;#34;&amp;#34;); // テキスト検索

  const filtered = useMemo(() =&amp;gt; {
    const q = query.trim().toLowerCase();
    if (!q) return spots;
    return spots.filter(
      (s) =&amp;gt;
        s.name.toLowerCase().includes(q) ||
        s.shortDescription?.toLowerCase().includes(q) ||
        s.tags?.some((t) =&amp;gt; t.toLowerCase().includes(q))
    );
  }, [spots, query]);

  // ... 島別グルーピング・表示
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;SpotsListClient&lt;/code&gt; は &lt;code&gt;useState&lt;/code&gt;（検索クエリ）と &lt;code&gt;useMemo&lt;/code&gt;（フィルタリング）を使うので &lt;code&gt;&quot;use client&quot;&lt;/code&gt; が必要です。でも &lt;code&gt;getAllSpots()&lt;/code&gt; は呼びません。サーバー側の &lt;code&gt;page.tsx&lt;/code&gt; が呼んで渡してくれます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;全部クライアントにするの何が問題か&quot;&gt;「全部クライアントにする」の何が問題か&lt;/h2&gt;

&lt;p&gt;純粋にエラーが出なければ動くんじゃないか、と思うかもしれません。問題は2つあります。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. fs などのサーバー専用モジュールをバンドルできない&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&quot;use client&quot;&lt;/code&gt; をつけたコンポーネントが &lt;code&gt;fs&lt;/code&gt; をインポートすると、Webpack はそれをブラウザ向けバンドルに含めようとしてエラーになります。ブラウザに &lt;code&gt;fs&lt;/code&gt; は存在しないからです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Server Component の恩恵が消える&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Server Component はレンダリング結果を HTML としてサーバーで生成します。JavaScript をブラウザに送らなくて済みます。&lt;code&gt;&quot;use client&quot;&lt;/code&gt; をつけると、そのコンポーネントの JavaScript がすべてブラウザに届きます。コンポーネントが増えるほどバンドルサイズが膨らみます。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;use-client-をどこに書くべきか&quot;&gt;&amp;quot;use client&amp;quot; をどこに書くべきか&lt;/h2&gt;

&lt;p&gt;整理するとこうなります。&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 状況 &lt;/th&gt;
&lt;th&gt; &quot;use client&quot; &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; useState / useEffect を使う &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; onClick などのイベントハンドラを使う &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; Mapbox など WebGL・DOM API を使うライブラリ &lt;/td&gt;
&lt;td&gt; 必要 &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; fs でファイルを読む / DB にアクセスする &lt;/td&gt;
&lt;td&gt; 不要（サーバー専用） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; ただデータを受け取って表示するだけ &lt;/td&gt;
&lt;td&gt; 不要 &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;コンポーネントツリーの「葉」に近い部分——インタラクションが必要な部分だけに &lt;code&gt;&quot;use client&quot;&lt;/code&gt; を置く。データ取得は根（&lt;code&gt;page.tsx&lt;/code&gt; など）でやってから props で流す。これが App Router の設計の意図です。&lt;/p&gt;

&lt;p&gt;「サーバーでデータを取って、クライアントでインタラクションする」という役割分担を意識するようになってから、エラーが出るたびに &lt;code&gt;&quot;use client&quot;&lt;/code&gt; をつけ足すのではなく、「このコンポーネントはどちらに属するか」を先に考えるようになりました。その一つの判断が、開発のスピードと品質を変えていくと実感しています。&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/72ed2dde75672866b9ba2bdf7509f64d411411e4/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260417%2F20260417135705.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
    <entry>
        <title>猫の細道を訪れてMDXでサイトに追加した——frontmatterが地図ピンになる仕組みと画像管理</title>
        <link href="https://mojitonews.hateblo.jp/entry/neko-no-hosomichi-mdx-frontmatter-map-pin"/>
        <id>hatenablog://entry/17179246901377340282</id>
        <published>2026-04-17T07:46:32+09:00</published>
        <updated>2026-04-17T07:46:32+09:00</updated>        <summary type="html">尾道・猫の細道を早朝に訪れ、写真を撮ってMDXでサイトに追加しました。frontmatterのlat・lngが地図ピンに変わる仕組み・カスタムコンポーネントによる画像管理・git pushだけで完結するデプロイフローを実例で整理します。</summary>
        <content type="html">&lt;h1 id=&quot;猫の細道を訪れてMDXでサイトに追加したfrontmatterが地図ピンになる仕組みと画像管理の話&quot;&gt;猫の細道を訪れてMDXでサイトに追加した——frontmatterが地図ピンになる仕組みと画像管理の話&lt;/h1&gt;

&lt;p&gt;現地に行って写真を撮り、帰ったらMDXを書くだけでサイトが更新できる。しまなみ海道の観光ガイドサイトをNext.js 15で個人開発していて、この運用が実際に成立した話です。&lt;/p&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;猫の細道に行ってきた&quot;&gt;猫の細道に行ってきた&lt;/h2&gt;

&lt;p&gt;尾道の山手にある猫の細道は、幅1〜2m・全長約200mの石畳の路地です。千光寺へと続く坂道の途中にあり、古い民家の壁や石垣に沿って続く細い路地に、猫にまつわるオブジェや陶器が点在しています。&lt;/p&gt;

&lt;p&gt;訪れたのは早朝5時50分。観光客がまだ誰もいない時間帯で、石畳に自分の足音だけが響いていました。猫の横丁の入り口にある受付小屋には「CLOSE」の看板が出ていて、その真横に1匹の猫が座っていました。こちらをじっと見ています。まるで「今日はまだ開いていないよ」と監視しているようで、看板との位置関係が絶妙すぎて思わずカメラを向けました。&lt;/p&gt;

&lt;p&gt;標準レンズで1枚、望遠で引き寄せてもう1枚。表情がはっきり見えた望遠の写真の方が気に入ったので、こちらをサムネイルに使うことにしました。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;CLOSE看板の真横に座ってこちらを監視するように見つめる猫。引き（左）と望遠（右）で収めました&quot;&gt;&lt;div class=&quot;images-row mceNonEditable&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260417/20260417072443.jpg&quot; alt=&quot;&amp;#x732B;&amp;#x306E;&amp;#x6A2A;&amp;#x4E01;&amp;#x30FB;&amp;#x53D7;&amp;#x4ED8;CLOSE&amp;#x770B;&amp;#x677F;&amp;#x306E;&amp;#x6A2A;&amp;#x3067;&amp;#x5EA7;&amp;#x308A;&amp;#x3053;&amp;#x3061;&amp;#x3089;&amp;#x3092;&amp;#x898B;&amp;#x3064;&amp;#x3081;&amp;#x308B;&amp;#x732B;&amp;mdash;&amp;mdash;2026&amp;#x5E74;4&amp;#x6708;11&amp;#x65E5; &amp;#x65E9;&amp;#x671D;5&amp;#x6642;50&amp;#x5206;&amp;#x64AE;&amp;#x5F71;&quot; width=&quot;1200&quot; height=&quot;900&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260417/20260417072447.jpg&quot; alt=&quot;&amp;#x732B;&amp;#x306E;&amp;#x6A2A;&amp;#x4E01;&amp;#x306E;&amp;#x53D7;&amp;#x4ED8;&amp;#x6A2A;&amp;#x306B;&amp;#x5EA7;&amp;#x308B;&amp;#x732B;&amp;#x3092;&amp;#x671B;&amp;#x9060;&amp;#x64AE;&amp;#x5F71;&amp;mdash;&amp;mdash;CLOSE&amp;#x770B;&amp;#x677F;&amp;#x306E;&amp;#x756A;&amp;#x4EBA;&amp;#x306E;&amp;#x3088;&amp;#x3046;&amp;#x306A;&amp;#x8868;&amp;#x60C5;&amp;mdash;&amp;mdash;2026&amp;#x5E74;4&amp;#x6708;11&amp;#x65E5; &amp;#x65E9;&amp;#x671D;5&amp;#x6642;50&amp;#x5206;&amp;#x64AE;&amp;#x5F71;&quot; width=&quot;1200&quot; height=&quot;900&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;/div&gt;&lt;figcaption&gt;CLOSE看板の真横に座ってこちらを監視するように見つめる猫。引き（左）と望遠（右）で収めました&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;このサイトのコンテンツ管理はMDXで完結している&quot;&gt;このサイトのコンテンツ管理はMDXで完結している&lt;/h2&gt;

&lt;p&gt;このサイトのスポットページは &lt;code&gt;content/spots/&lt;/code&gt; 以下の &lt;code&gt;.mdx&lt;/code&gt; ファイルで管理しています。猫の細道なら &lt;code&gt;content/spots/neko-no-hosomichi.mdx&lt;/code&gt; が対応するファイルです。&lt;/p&gt;

&lt;p&gt;ファイルの前半はfrontmatterで、スポットのメタ情報を書きます。&lt;/p&gt;

&lt;pre class=&quot;code lang-yaml&quot; data-lang=&quot;yaml&quot; data-unlink&gt;&lt;span class=&quot;synPreProc&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; neko-no-hosomichi
&lt;span class=&quot;synIdentifier&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; 猫の細道
&lt;span class=&quot;synIdentifier&quot;&gt;island&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; onomichi
&lt;span class=&quot;synIdentifier&quot;&gt;lat&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;34.41044813852006&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;lng&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;133.1998058010011&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;shortDescription&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;synConstant&quot;&gt;&amp;quot;尾道山手の石畳路地（幅1〜2m・全長約200m）に...&amp;quot;&lt;/span&gt;
&lt;span class=&quot;synIdentifier&quot;&gt;thumbnail&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; /spots/neko-no-hosomichi/neko-no-hosomichi-morning-cat-guard-telephoto.jpg
&lt;span class=&quot;synIdentifier&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;- &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; /spots/neko-no-hosomichi/neko-no-hosomichi-morning-cat-guard-telephoto.jpg
  &lt;span class=&quot;synStatement&quot;&gt;- &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;:&lt;/span&gt; /spots/neko-no-hosomichi/neko-no-hosomichi-stone-steps-summer.jpg
&lt;span class=&quot;synPreProc&quot;&gt;---&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;id&lt;/code&gt; がURLスラッグ（&lt;code&gt;/spots/neko-no-hosomichi&lt;/code&gt;）になり、&lt;code&gt;lat&lt;/code&gt; と &lt;code&gt;lng&lt;/code&gt; が地図のピン位置になります。&lt;code&gt;images&lt;/code&gt; に並べた順がギャラリーの表示順です。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;frontmatterが地図ピンに変わる仕組み&quot;&gt;frontmatterが地図ピンに変わる仕組み&lt;/h2&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;frontmatterに書いたlat・lngが地図ピンの位置に変換されます。スポットを追加するたびに地図は自動で更新されます&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260417/20260417071631.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x5C3E;&amp;#x9053;&amp;#x30FB;&amp;#x732B;&amp;#x306E;&amp;#x7D30;&amp;#x9053;&amp;#x306E;&amp;#x5730;&amp;#x56F3;&amp;#x30D4;&amp;#x30F3;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;377&quot; height=&quot;664&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;frontmatterに書いたlat・lngが地図ピンの位置に変換されます。スポットを追加するたびに地図は自動で更新されます&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getAllSpots()&lt;/code&gt; は &lt;code&gt;content/spots/&lt;/code&gt; の &lt;code&gt;.mdx&lt;/code&gt; ファイルを全件読み込み、frontmatterだけを取り出して返すサーバー専用の関数です。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/lib/spotsData.ts（サーバー専用・fsを使うためクライアント不可）
export function getAllSpots(): Spot[] {
  return fs
    .readdirSync(CONTENT_DIR)
    .filter((f) =&amp;gt; f.endsWith(&amp;#34;.mdx&amp;#34;) &amp;amp;&amp;amp; !f.startsWith(&amp;#34;_&amp;#34;))
    .map((f) =&amp;gt; readSpot(f));
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;_&lt;/code&gt; で始まるファイルを除外しているのは、&lt;code&gt;_template.mdx&lt;/code&gt;（新規スポット追加用のテンプレート）を一覧に混入させないためです。&lt;/p&gt;

&lt;p&gt;取得した Spot[] をサーバーコンポーネントから地図コンポーネントにpropsとして渡し、lat と lngを使ってMapboxのGeoJSONに変換してピンを描画しています。新しいスポットのMDXファイルを追加するだけで、地図のコードを一切触らずにピンが増える仕組みです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;本文にはMarkdown--JSXコンポーネントが書ける&quot;&gt;本文にはMarkdown + JSXコンポーネントが書ける&lt;/h2&gt;

&lt;p&gt;MDXの強みは、Markdownの中にReactコンポーネントを直接書けることです。&lt;/p&gt;

&lt;p&gt;このサイトには &lt;code&gt;&amp;lt;SpotImage&amp;gt;&lt;/code&gt; と &lt;code&gt;&amp;lt;SpotImageRow&amp;gt;&lt;/code&gt; という2つのカスタムコンポーネントがあります。&lt;code&gt;&amp;lt;SpotImage&amp;gt;&lt;/code&gt; は画像を1枚表示し、&lt;code&gt;&amp;lt;SpotImageRow&amp;gt;&lt;/code&gt; は2枚を横並びにします。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/components/mdx/SpotImageRow.tsx
export function SpotImageRow({ children }: { children: React.ReactNode }) {
  return &amp;lt;div className=&amp;#34;grid gap-3 sm:grid-cols-2&amp;#34;&amp;gt;{children}&amp;lt;/div&amp;gt;;
}&lt;/pre&gt;


&lt;p&gt;今回の監視猫は標準版と望遠版の2枚があったので、MDX上でこう書きました。&lt;/p&gt;

&lt;pre class=&quot;code mdx&quot; data-lang=&quot;mdx&quot; data-unlink&gt;&amp;lt;SpotImageRow&amp;gt;
  &amp;lt;SpotImage
    src=&amp;#34;.../neko-no-hosomichi-morning-cat-guard.jpg&amp;#34;
    alt=&amp;#34;猫の横丁・受付CLOSE看板の横で座る猫——2026年4月11日 午前5時50分撮影&amp;#34;
    caption=&amp;#34;CLOSE看板の真横に座っていた猫。&amp;#34;
  /&amp;gt;
  &amp;lt;SpotImage
    src=&amp;#34;.../neko-no-hosomichi-morning-cat-guard-telephoto.jpg&amp;#34;
    alt=&amp;#34;猫の横丁の受付横の猫を望遠撮影——2026年4月11日 午前5時50分&amp;#34;
    caption=&amp;#34;望遠で引き寄せると表情がよくわかります。&amp;#34;
  /&amp;gt;
&amp;lt;/SpotImageRow&amp;gt;&lt;/pre&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;カスタムコンポーネントをMDXRemoteに渡す&quot;&gt;カスタムコンポーネントをMDXRemoteに渡す&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;SpotImage&amp;gt;&lt;/code&gt; などのカスタムコンポーネントは、&lt;code&gt;mdxComponents&lt;/code&gt; オブジェクトにまとめて &lt;code&gt;MDXRemote&lt;/code&gt; に渡します。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/components/mdx/index.tsx
export const mdxComponents = {
  SpotImage,
  SpotImageRow,
  // Markdownの標準要素も上書きしてスタイルを統一
  h2: ({ children }) =&amp;gt; &amp;lt;div className=&amp;#34;mt-6 border ...&amp;#34;&amp;gt;&amp;lt;h3&amp;gt;{children}&amp;lt;/h3&amp;gt;&amp;lt;/div&amp;gt;,
  p:  ({ children }) =&amp;gt; &amp;lt;p className=&amp;#34;text-sm leading-relaxed ...&amp;#34;&amp;gt;{children}&amp;lt;/p&amp;gt;,
  table: ({ children }) =&amp;gt; &amp;lt;div className=&amp;#34;overflow-x-auto&amp;#34;&amp;gt;&amp;lt;table ...&amp;gt;{children}&amp;lt;/table&amp;gt;&amp;lt;/div&amp;gt;,
};&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;h2&lt;/code&gt; や &lt;code&gt;table&lt;/code&gt; などMarkdownの標準要素もここで上書きしており、記事内のテーブルやリンクのデザインをサイト全体で統一しています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;実際の追記作業はこれだけ&quot;&gt;実際の追記作業はこれだけ&lt;/h2&gt;

&lt;p&gt;やったことをまとめると5ステップです。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;画像4枚を日本語ファイル名→英語にリネーム（URLエンコード対策）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;public/spots/neko-no-hosomichi/&lt;/code&gt; に配置&lt;/li&gt;
&lt;li&gt;MDXに新セクションを書く（Markdown + &lt;code&gt;&amp;lt;SpotImageRow&amp;gt;&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;frontmatterの &lt;code&gt;thumbnail&lt;/code&gt; と &lt;code&gt;images[0]&lt;/code&gt; を監視猫の望遠写真に変更&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git push&lt;/code&gt; → Vercelが自動デプロイ&lt;/li&gt;
&lt;/ol&gt;


&lt;p&gt;コードを1行も書かずにページが更新できました。「現地で写真を撮って帰ったらMDXを書くだけ」という運用が成立しているのは、コンテンツ管理をMDXに寄せてコードと分離した設計にしたからです。観光スポットが増えるたびにこの流れを繰り返せます。&lt;/p&gt;

&lt;p&gt;実際に手を動かして現地取材からデプロイまでを一通りやってみると、設計の意図が改めて腑に落ちます。「なぜfrontmatterにlat・lngを入れたのか」「なぜMDXにしたのか」は、コードを読んでいるだけでは実感しにくく、実際にスポットを追加してみて初めてわかることでした。&lt;/p&gt;

&lt;p&gt;もう一つ気に入っているのは、後からいつでもセクションを追記できる点です。昼間に再訪して別の猫に会えたり、季節が変わって風景が変わったりすれば、同じMDXファイルに書き足してpushするだけです。データベースもCMSの管理画面も不要で、「また行ったら更新しよう」という気軽さで運用できています。&lt;/p&gt;

&lt;p&gt;もちろん、WordPressやはてなブログのような既存のサービスを使えばもっと手っ取り早いのは事実です。ただ、自分でサイトを設計してコードを書くことで「なぜこの設計にしたのか」が体で理解できる副産物があります。技術を磨きながらサイトを育てていける、というのが個人開発を続けている一番の理由かもしれません。&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-hooks-map-site-useref-usecallback-abortcontroller&quot; title=&quot;useRef・useCallback・AbortController——しまなみ海道観光サイトに組み込んだ地図機能で&amp;quot;stateに入れない・再生成させない・止める&amp;quot;を実践した話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-hooks-map-site-useref-usecallback-abortcontroller&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-forwardref-createportal-zustand-map-site&quot; title=&quot;forwardRef・createPortal・Zustand——しまなみ海道観光サイトでReactの作法を少し外す設計をした話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-forwardref-createportal-zustand-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fasync-await-then-chain-usage-map-site&quot; title=&quot;async/await と .then() チェーン——しまなみ海道観光サイトで両方使い分けた理由 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/async-await-then-chain-usage-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/4c5bcf682365059644355d23d7ed078b4520d75e/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260417%2F20260417072447.jpg" type="image/jpeg" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>async/await と .then() チェーン——しまなみ海道観光サイトで両方使い分けた理由</title>
        <link href="https://mojitonews.hateblo.jp/entry/async-await-then-chain-usage-map-site"/>
        <id>hatenablog://entry/17179246901376936996</id>
        <published>2026-04-15T22:19:34+09:00</published>
        <updated>2026-04-15T22:30:55+09:00</updated>        <summary type="html">async/awaitと.then()チェーンをしまなみ海道観光サイトの実コードで比較。useEffectの制約・既存のPromise変数の共有など、処理の形に合わせて使い分けた理由と判断基準を整理します。</summary>
        <content type="html">&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;ピンをタップすると吹き出し型のポップアップが表示され（左）、詳細を見るボタンからボトムシートが開いてホテルの空室確認や詳細情報を確認できます（右）&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415221055.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x3092;&amp;#x30BF;&amp;#x30C3;&amp;#x30D7;&amp;#x3059;&amp;#x308B;&amp;#x3068;&amp;#x5439;&amp;#x304D;&amp;#x51FA;&amp;#x3057;&amp;#x578B;&amp;#x306E;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#xFF08;&amp;#x5DE6;&amp;#xFF09;&amp;#x3001;&amp;#x8A73;&amp;#x7D30;&amp;#x3092;&amp;#x898B;&amp;#x308B;&amp;#x30DC;&amp;#x30BF;&amp;#x30F3;&amp;#x304B;&amp;#x3089;&amp;#x30DC;&amp;#x30C8;&amp;#x30E0;&amp;#x30B7;&amp;#x30FC;&amp;#x30C8;&amp;#x304C;&amp;#x958B;&amp;#x3044;&amp;#x3066;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x306E;&amp;#x8A73;&amp;#x7D30;&amp;#x60C5;&amp;#x5831;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&amp;#xFF08;&amp;#x53F3;&amp;#xFF09;&quot; width=&quot;765&quot; height=&quot;664&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;ピンをタップすると吹き出し型のポップアップが表示され（左）、詳細を見るボタンからボトムシートが開いてホテルの空室確認や詳細情報を確認できます（右）&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;JavaScriptの非同期処理を書くとき、async/awaitと.then()チェーンのどちらを使うか迷ったことはありませんか？&lt;/p&gt;

&lt;p&gt;「async/awaitの方がモダンだから常にそっちを使う」と思っていた時期が私にもありました。しかし実際にしまなみ海道の観光サイトを開発していると、同じプロジェクトの中で両方を意図的に使い分けている箇所が出てきました。この記事ではその理由を整理します。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;h2 id=&quot;まず基本の比較&quot;&gt;まず基本の比較&lt;/h2&gt;

&lt;p&gt;同じ処理を両方の書き方で並べます。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.then()チェーン&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code js&quot; data-lang=&quot;js&quot; data-unlink&gt;fetch(&amp;#34;/data/hotels.json&amp;#34;)
  .then((res) =&amp;gt; res.json())
  .then((data) =&amp;gt; {
    console.log(data);
  })
  .catch((e) =&amp;gt; {
    console.error(e);
  });&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;async/await&lt;/strong&gt;&lt;/p&gt;

&lt;pre class=&quot;code js&quot; data-lang=&quot;js&quot; data-unlink&gt;async function loadHotels() {
  try {
    const res = await fetch(&amp;#34;/data/hotels.json&amp;#34;);
    const data = await res.json();
    console.log(data);
  } catch (e) {
    console.error(e);
  }
}&lt;/pre&gt;


&lt;p&gt;どちらも同じ処理です。読みやすさの点ではasync/awaitの方が「上から下へ順番に読める」ため、多くの場面で優れています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;asyncawaitを使った例useHotelsData&quot;&gt;async/awaitを使った例：useHotelsData&lt;/h2&gt;

&lt;p&gt;地図上にホテルのピンを表示するために、&lt;code&gt;/data/hotels.json&lt;/code&gt; をフェッチするカスタムフック &lt;code&gt;useHotelsData&lt;/code&gt; を書きました。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/map/useHotelsData.ts
useEffect(() =&amp;gt; {
  let cancelled = false;

  (async () =&amp;gt; {
    try {
      setLoading(true);

      const [hotelRes, validRes] = await Promise.all([
        fetch(&amp;#34;/data/hotels.json&amp;#34;, { cache: &amp;#34;no-store&amp;#34; }),
        fetch(&amp;#34;/data/valid-image-ids.json&amp;#34;, { cache: &amp;#34;no-store&amp;#34; }),
      ]);

      if (!hotelRes.ok) throw new Error(`HTTP ${hotelRes.status}`);

      const json = await hotelRes.json();
      let parsed = parseHotels(json);

      if (validRes.ok) {
        const validData = await validRes.json();
        const validSet = new Set(validData.hotelNos);
        parsed = {
          ...parsed,
          features: parsed.features.filter((f) =&amp;gt;
            validSet.has(f.properties.hotelNo)
          ),
        };
      }

      if (!cancelled) setHotels(parsed);
    } catch (e) {
      if (!cancelled) setError(&amp;#34;読み込みに失敗しました&amp;#34;);
    } finally {
      if (!cancelled) setLoading(false);
    }
  })().catch(() =&amp;gt; {});

  return () =&amp;gt; { cancelled = true; };
}, []);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;await Promise.all([...])&lt;/code&gt; で2つのフェッチを並列実行し、その後に絞り込み処理をしています。処理が直線的に流れるので、async/awaitが読みやすい場面です。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;thenチェーンを使った例useHotelDetail&quot;&gt;.then()チェーンを使った例：useHotelDetail&lt;/h2&gt;

&lt;p&gt;一方、マップ上でホテルをタップしたとき詳細データをオンデマンド取得する &lt;code&gt;useHotelDetail&lt;/code&gt; では、useEffectの中で.then()チェーンを使っています。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// src/hooks/hotels/useHotelDetail.ts
useEffect(() =&amp;gt; {
  // ...前略（キャッシュ確認・AbortController設定）

  const p = existing ?? fetchDetail(normalizedNo, signal);
  if (!existing) inFlight.set(normalizedNo, p);

  p.then((data) =&amp;gt; {
      if (latestNoRef.current !== normalizedNo) return;
      memoryCache.set(normalizedNo, data);
      setDetail(data);
      setLoading(false);
    })
    .catch((e: unknown) =&amp;gt; {
      if (signal.aborted) return;
      if (latestNoRef.current !== normalizedNo) return;
      setError(e instanceof Error ? e : new Error(String(e)));
      setLoading(false);
    })
    .finally(() =&amp;gt; {
      if (!existing) inFlight.delete(normalizedNo);
    });

  return () =&amp;gt; controller.abort();
}, [normalizedNo]);&lt;/pre&gt;


&lt;p&gt;useEffectのコールバックは「クリーンアップ関数をreturnする」という役割があります。async関数は必ずPromiseを返すため、useEffectのコールバックに直接asyncを付けるとクリーンアップ関数を返せなくなるという問題があります。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// ❌ これはできない
useEffect(async () =&amp;gt; {
  await fetchDetail(normalizedNo, signal);
  return () =&amp;gt; controller.abort(); // Promiseに包まれてしまいクリーンアップとして機能しない
}, [normalizedNo]);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useHotelsData&lt;/code&gt; ではIIFE（即時実行関数）でasyncを閉じ込めることで回避しています。しかし &lt;code&gt;useHotelDetail&lt;/code&gt; では事情が異なります。&lt;code&gt;p&lt;/code&gt; は &lt;code&gt;inFlight.get()&lt;/code&gt; から取り出した「すでに走っているかもしれないPromise」です。IIFEで &lt;code&gt;await p&lt;/code&gt; と書くこともできますが、その場合 &lt;code&gt;.finally()&lt;/code&gt; で &lt;code&gt;inFlight.delete()&lt;/code&gt; するタイミング管理が複雑になります。&lt;code&gt;.then().catch().finally()&lt;/code&gt; のメソッドチェーンにすることで、キャッシュへの書き込み・エラー処理・inFlightの削除がどこで行われているかが一目瞭然になりました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;判断基準をまとめると&quot;&gt;判断基準をまとめると&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 状況 &lt;/th&gt;
&lt;th&gt; 推奨 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; 処理が直線的に流れる（順番にawaitするだけ） &lt;/td&gt;
&lt;td&gt; async/await &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; try/catch/finallyで複数のエラーを整理したい &lt;/td&gt;
&lt;td&gt; async/await &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; useEffectの中で書きたいが処理が単純 &lt;/td&gt;
&lt;td&gt; IIFE + async/await &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; すでにPromise変数が手元にある &lt;/td&gt;
&lt;td&gt; .then()チェーン &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; useEffectの外でPromiseを共有・合流させたい &lt;/td&gt;
&lt;td&gt; .then()チェーン &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; .finally()だけ使いたい（クリーンアップ処理） &lt;/td&gt;
&lt;td&gt; .then()チェーンの末尾に.finally() &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;hr /&gt;

&lt;h2 id=&quot;まとめ&quot;&gt;まとめ&lt;/h2&gt;

&lt;p&gt;async/awaitは「読みやすい」という点でほとんどの場面で優れています。ただし、useEffectの制約（クリーンアップをreturnする必要がある）や、すでにPromiseとして存在している変数に処理を繋ぎたい場面では、.then()チェーンの方が構造に合うことがあります。&lt;/p&gt;

&lt;p&gt;「常にasync/await」ではなく、処理の形に合わせて選ぶ——それが判断基準です。&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-hooks-map-site-useref-usecallback-abortcontroller&quot; title=&quot;useRef・useCallback・AbortController——しまなみ海道観光サイトに組み込んだ地図機能で&amp;quot;stateに入れない・再生成させない・止める&amp;quot;を実践した話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-hooks-map-site-useref-usecallback-abortcontroller&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-forwardref-createportal-zustand-map-site&quot; title=&quot;forwardRef・createPortal・Zustand——しまなみ海道観光サイトでReactの作法を少し外す設計をした話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-forwardref-createportal-zustand-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <category term="個人開発" label="個人開発" />
        
        <category term="非同期処理" label="非同期処理" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/6ebc3b5fe4d4a3c4a0cbfb9a3c398ed7d3839aa2/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260415%2F20260415221055.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
    <entry>
        <title>forwardRef・createPortal・Zustand——しまなみ海道観光サイトでReactの作法を少し外す設計をした話</title>
        <link href="https://mojitonews.hateblo.jp/entry/react-forwardref-createportal-zustand-map-site"/>
        <id>hatenablog://entry/17179246901376711492</id>
        <published>2026-04-15T10:04:07+09:00</published>
        <updated>2026-04-15T21:33:20+09:00</updated>        <summary type="html">Next.js 15で開発したしまなみ海道の観光マップサイトを題材に、forwardRef・createPortal・Zustandの実践的な使い方を解説。命令型API・DOMへの脱出・プロバイダーなしの状態管理を、なぜその設計にしたかという視点で整理します。</summary>
        <content type="html">&lt;p&gt;しまなみ海道の観光ガイドサイトをNext.js 15で個人開発する中で、通常のReactの作法——親子の一方向データフロー・ツリー内でのDOM管理——から外れた設計が必要になる場面が3つありました。&lt;code&gt;forwardRef&lt;/code&gt; による命令型API・&lt;code&gt;createPortal&lt;/code&gt; によるDOMへの脱出・&lt;code&gt;Zustand&lt;/code&gt; によるプロバイダーなしの状態管理です。前回の記事（useRef・useCallback・AbortController編）の続きになります。&lt;/p&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;p&gt;このサイトでは地図機能を中心に据えており、&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;しまなみ海道 観光マップ&lt;/a&gt;としてスマートフォン・デスクトップどちらからでも使えるようにしています。地図上にはホテルと観光スポットのピンをレイヤーで切り替えて表示でき、ピンをクリック・タップすると吹き出し型のポップアップが開く構成です。詳細を開くと、スマートフォンではボトムシートが下からスライドして開き、デスクトップでは右サイドからパネルが開いてスポットの詳細情報を確認できます。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;観光スポットレイヤーとホテルレイヤーをReactコンポーネントとして描画しており、レイヤーで切り替えて表示できます&quot;&gt;&lt;div class=&quot;images-row mceNonEditable&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415094735.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x30EC;&amp;#x30A4;&amp;#x30E4;&amp;#x30FC;&amp;#x3092;&amp;#x30AA;&amp;#x30F3;&amp;#x306B;&amp;#x3057;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&amp;#x3067;&amp;#x3001;&amp;#x5BBF;&amp;#x6CCA;&amp;#x65BD;&amp;#x8A2D;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x304C;&amp;#x5730;&amp;#x56F3;&amp;#x4E0A;&amp;#x306B;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;376&quot; height=&quot;667&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415094745.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x89B3;&amp;#x5149;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x30EC;&amp;#x30A4;&amp;#x30E4;&amp;#x30FC;&amp;#x3092;&amp;#x30AA;&amp;#x30F3;&amp;#x306B;&amp;#x3057;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&amp;#x3067;&amp;#x3001;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x304C;&amp;#x5730;&amp;#x56F3;&amp;#x4E0A;&amp;#x306B;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;374&quot; height=&quot;669&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;/div&gt;&lt;figcaption&gt;観光スポットレイヤーとホテルレイヤーをReactコンポーネントとして描画しており、レイヤーで切り替えて表示できます&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;forwardRef--useImperativeHandle親から子に命令する&quot;&gt;forwardRef + useImperativeHandle——親から子に「命令する」&lt;/h2&gt;

&lt;p&gt;Reactのデータフローは基本的に親から子への一方通行です。しかし「親から子に対して、今すぐ何かをしろと命令したい」ケースが出てくることがあります。このサイトのボトムシート（Sheet.tsx）がまさにそうでした。&lt;/p&gt;

&lt;p&gt;モバイルのボトムシートには×ボタンがあり、押したときにアニメーション付きでシートを閉じる必要があります。最初はpropsで &lt;code&gt;forceClose: boolean&lt;/code&gt; のようなフラグを持たせることを考えました。しかしこれをやると、親側にフラグのstateを追加し、閉じ終わったらリセットする処理も書く必要があり、管理が煩雑になります。&lt;/p&gt;

&lt;p&gt;そこで &lt;code&gt;forwardRef&lt;/code&gt; と &lt;code&gt;useImperativeHandle&lt;/code&gt; を組み合わせて、&lt;code&gt;requestClose&lt;/code&gt; だけを外部に公開する命令型APIを作りました。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;export type SheetHandle = { requestClose: () =&amp;gt; void };

const Sheet = forwardRef&amp;lt;SheetHandle, SheetProps&amp;gt;(function Sheet(props, ref) {
  const requestClose = useCallback(() =&amp;gt; {
    setIsClosing(true);
    setShow(false);
    closeTimerRef.current = window.setTimeout(() =&amp;gt; {
      finalizeClose();
    }, closeMs + 80);
  }, [closeMs, finalizeClose]);

  useImperativeHandle(ref, () =&amp;gt; ({ requestClose }), [requestClose]);
});&lt;/pre&gt;


&lt;p&gt;RightPane.tsx側はrefを受け取り、×ボタンのonClickで &lt;code&gt;sheetRef.current?.requestClose()&lt;/code&gt; を呼びます。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const sheetRef = useRef&amp;lt;SheetHandle&amp;gt;(null);
// ×ボタンのonClick
sheetRef.current?.requestClose();&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useImperativeHandle&lt;/code&gt; で公開するAPIを &lt;code&gt;requestClose&lt;/code&gt; だけに絞っているのがポイントです。refでDOMをそのまま渡すと &lt;code&gt;setShow&lt;/code&gt; や &lt;code&gt;setMounted&lt;/code&gt; といった内部stateにも触れてしまいます。公開するAPIを一つに限定することで、Sheet内部の実装をあとから変えてもインターフェースが壊れない設計になっています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;createPortalReactツリーとDOMツリーを切り離す&quot;&gt;createPortal——ReactツリーとDOMツリーを切り離す&lt;/h2&gt;

&lt;h3 id=&quot;SheetのSSR対策documentbody直下にマウントする&quot;&gt;SheetのSSR対策：document.body直下にマウントする&lt;/h3&gt;

&lt;p&gt;ボトムシートのオーバーレイは &lt;code&gt;createPortal&lt;/code&gt; を使って &lt;code&gt;document.body&lt;/code&gt; の直下にマウントしています。これをしないと、親コンポーネントに &lt;code&gt;overflow: hidden&lt;/code&gt; や &lt;code&gt;z-index&lt;/code&gt; のスタッキングコンテキストが設定されている場合、オーバーレイがその内側に閉じ込められて正しく表示されません。&lt;code&gt;document.body&lt;/code&gt; 直下に逃がすことでその制約を回避しています。&lt;/p&gt;

&lt;p&gt;詰まったのがSSR対策です。Next.js App Routerではサーバーサイドでもコンポーネントが実行されますが、サーバーには &lt;code&gt;document&lt;/code&gt; が存在しません。そのため &lt;code&gt;document.body&lt;/code&gt; は &lt;code&gt;useEffect&lt;/code&gt; の中でrefに代入し、&lt;code&gt;createPortal&lt;/code&gt; にはそのrefを渡しています。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const overlayRootRef = useRef&amp;lt;HTMLElement | null&amp;gt;(null);

useEffect(() =&amp;gt; {
  overlayRootRef.current =
    typeof document !== &amp;#39;undefined&amp;#39; ? document.body : null;
}, []);

if (!mounted || !overlayRootRef.current) return null;
return createPortal(overlay, overlayRootRef.current);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;mounted&lt;/code&gt;（シートの表示状態）と &lt;code&gt;overlayRootRef.current&lt;/code&gt;（SSRガード）の両方をチェックしているのがポイントです。&lt;code&gt;document.body&lt;/code&gt; を直接 &lt;code&gt;createPortal&lt;/code&gt; に渡すのではなく、useEffect内で安全に取得してからrefに入れる、という順序が必要でした。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;ピンをタップすると吹き出し型のポップアップが表示され（左）、詳細をタップするとボトムシートが下からスライドして開きます（右）。Sheet.tsxのcreatePortalとforwardRefが動いている場面です&quot;&gt;&lt;div class=&quot;images-row mceNonEditable&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415095321.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x3092;&amp;#x30BF;&amp;#x30C3;&amp;#x30D7;&amp;#x3059;&amp;#x308B;&amp;#x3068;&amp;#x5439;&amp;#x304D;&amp;#x51FA;&amp;#x3057;&amp;#x578B;&amp;#x306E;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;377&quot; height=&quot;665&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415095328.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x306E;&amp;#x8A73;&amp;#x7D30;&amp;#x30DC;&amp;#x30BF;&amp;#x30F3;&amp;#x3092;&amp;#x30BF;&amp;#x30C3;&amp;#x30D7;&amp;#x3059;&amp;#x308B;&amp;#x3068;&amp;#x30DC;&amp;#x30C8;&amp;#x30E0;&amp;#x30B7;&amp;#x30FC;&amp;#x30C8;&amp;#x304C;&amp;#x4E0B;&amp;#x304B;&amp;#x3089;&amp;#x30B9;&amp;#x30E9;&amp;#x30A4;&amp;#x30C9;&amp;#x3057;&amp;#x3066;&amp;#x958B;&amp;#x304D;&amp;#x3001;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x306E;&amp;#x8A73;&amp;#x7D30;&amp;#x60C5;&amp;#x5831;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;376&quot; height=&quot;664&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;/div&gt;&lt;figcaption&gt;ピンをタップすると吹き出し型のポップアップが表示され（左）、詳細をタップするとボトムシートが下からスライドして開きます（右）。Sheet.tsxのcreatePortalとforwardRefが動いている場面です&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;h3 id=&quot;MapboxマーカーへのReactコンポーネント埋め込みcreateRoot版&quot;&gt;MapboxマーカーへのReactコンポーネント埋め込み：createRoot版&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;createPortal&lt;/code&gt; とは少し異なるパターンとして、Mapboxのマーカー内にReactコンポーネントを描画する実装があります。&lt;/p&gt;

&lt;p&gt;MapboxのMarkerはHTMLElementを受け取る仕様です。そのHTMLElementの中に &lt;code&gt;createRoot&lt;/code&gt; でReactのツリーを生やすことで、MapboxのマーカーをReactコンポーネントとして扱えます。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const el = document.createElement(&amp;#39;div&amp;#39;);
const root = createRoot(el);
root.render(
  React.createElement(HotelPin, { thumb, title, onClick })
);
const marker = new mapboxgl.Marker({ element: el }).setLngLat(lnglat).addTo(map);&lt;/pre&gt;


&lt;p&gt;React 18以降は &lt;code&gt;ReactDOM.render&lt;/code&gt; が非推奨のため &lt;code&gt;createRoot&lt;/code&gt; を使います。アンマウント時は &lt;code&gt;setTimeout(0)&lt;/code&gt; で遅延してから &lt;code&gt;root.unmount()&lt;/code&gt; を呼んでいます。Reactのレンダーサイクル中に &lt;code&gt;unmount&lt;/code&gt; を呼ぶと警告が出るため、次のマクロタスクに処理を遅らせています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;ZustanduseContextを使わなかった理由&quot;&gt;Zustand——useContextを使わなかった理由&lt;/h2&gt;

&lt;p&gt;このサイトの状態管理にはZustandを採用し、5つのストアを用意しています。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;useMapUiStore&lt;/code&gt;：衛星レイヤーのon/off・不透明度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useSpotStore&lt;/code&gt;：選択中のスポット&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useHotelStore&lt;/code&gt;：選択中のホテル&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useRestaurantStore&lt;/code&gt;：選択中のレストラン&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useBusStore&lt;/code&gt;：選択中のバス停&lt;/li&gt;
&lt;/ul&gt;


&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// useMapUiStore.ts
export const useMapUiStore = create&amp;lt;MapUiState&amp;gt;((set) =&amp;gt; ({
  satOn: false,
  satOpacity: 0.8,
  setSatOn: (on) =&amp;gt; set({ satOn: on }),
  setSatOpacity: (value) =&amp;gt; set({ satOpacity: value }),
}));

// どこからでもimportして使うだけ
const { satOn, setSatOn } = useMapUiStore();&lt;/pre&gt;


&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;衛星レイヤーのon/offと不透明度はuseMapUiStoreで管理しています。どのコンポーネントからでも直接importして参照・更新できます&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415095613.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x885B;&amp;#x661F;&amp;#x30EC;&amp;#x30A4;&amp;#x30E4;&amp;#x30FC;&amp;#x3092;&amp;#x30AA;&amp;#x30F3;&amp;#x306B;&amp;#x3057;&amp;#x305F;&amp;#x72B6;&amp;#x614B;&amp;#x3067;&amp;#x5730;&amp;#x56F3;&amp;#x304C;&amp;#x822A;&amp;#x7A7A;&amp;#x5199;&amp;#x771F;&amp;#x8868;&amp;#x793A;&amp;#x306B;&amp;#x5207;&amp;#x308A;&amp;#x66FF;&amp;#x308F;&amp;#x308A;&amp;#x3001;&amp;#x30EC;&amp;#x30A4;&amp;#x30E4;&amp;#x30FC;&amp;#x5207;&amp;#x308A;&amp;#x66FF;&amp;#x3048;&amp;#x30DC;&amp;#x30BF;&amp;#x30F3;&amp;#x304C;&amp;#x753B;&amp;#x9762;&amp;#x306B;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;373&quot; height=&quot;662&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;衛星レイヤーのon/offと不透明度はuseMapUiStoreで管理しています。どのコンポーネントからでも直接importして参照・更新できます&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useContext&lt;/code&gt; ではなくZustandを選んだ理由は3つあります。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Providerが不要&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Contextは &lt;code&gt;&amp;lt;Provider&amp;gt;&lt;/code&gt; を上位コンポーネントに置く必要があります。このサイトはMapbox→ピン→ポップアップ→詳細パネルとコンポーネントツリーが深く、どの階層でも状態にアクセスできるようにするには中間に大量のProviderを通す必要があります。Zustandはストアをどこからでも直接importして使えるため、この問題が起きません。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 現時点で &lt;code&gt;&#39;use client&#39;&lt;/code&gt; ファイルが64個あるサイトでどこからでも呼べる&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js App RouterではServer ComponentとClient Componentが混在します。現時点で &#39;use client&#39; のファイルが64個あるこのサイトでは、Zustandはimportするだけでどのクライアントコンポーネントからも参照できるため、Server Componentとの境界を意識せずに使えました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 不要な再レンダーが起きにくい&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Contextは値が変わると、そのContextを購読しているコンポーネントすべてが再レンダーされます。Zustandはセレクターで購読する値を絞れるため、&lt;code&gt;useHotelStore(s =&amp;gt; s.selectedHotel)&lt;/code&gt; のように書くと &lt;code&gt;selectedHotel&lt;/code&gt; が変わったときだけそのコンポーネントが再レンダーされます。地図上に複数のレイヤーが同時に存在するこのサイトでは、余計な再レンダーを減らすことが動作の安定に直結しました。&lt;/p&gt;

&lt;p&gt;前回の記事（useRef・useCallback・AbortController編）と合わせると、このサイトで使ったReact機能の主要な設計判断が一通り揃います。同じような地図系サイトや、深いコンポーネントツリーを持つ個人開発の参考になれば幸いです。&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fnextjs-personal-site-learning&quot; title=&quot;手を動かして初めてわかった　Next.js + TypeScript で個人サイトを作りながら学ぶという選択 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/nextjs-personal-site-learning&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fclaude-code-nextjs-personal-site-development&quot; title=&quot;AIと一緒に個人サイトを開発するとはどういう体験か——Claude Code を使って気づいたこと - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/claude-code-nextjs-personal-site-development&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Ffs-error-nextjs-server-client-component&quot; title=&quot;`fs` を使ったらエラーが出た——Next.js の Server / Client Component を理解するまで - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/fs-error-nextjs-server-client-component&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-hooks-map-site-useref-usecallback-abortcontroller&quot; title=&quot;useRef・useCallback・AbortController——しまなみ海道観光サイトに組み込んだ地図機能で&amp;quot;stateに入れない・再生成させない・止める&amp;quot;を実践した話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-hooks-map-site-useref-usecallback-abortcontroller&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/50b245b29c3d867c61fa0ee161801b771bfddcf9/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260415%2F20260415094735.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
    <entry>
        <title>useRef・useCallback・AbortController——しまなみ海道観光サイトに組み込んだ地図機能で&quot;stateに入れない・再生成させない・止める&quot;を実践した話</title>
        <link href="https://mojitonews.hateblo.jp/entry/react-hooks-map-site-useref-usecallback-abortcontroller"/>
        <id>hatenablog://entry/17179246901376700392</id>
        <published>2026-04-15T09:25:16+09:00</published>
        <updated>2026-04-15T21:34:36+09:00</updated>        <summary type="html">Next.js 15で開発したしまなみ海道の観光マップサイトを題材に、useRef・useCallback・AbortControllerの実践的な使い方を解説。「stateに入れない・再生成させない・止める」の3軸でReact Hooksの設計判断を整理します。</summary>
        <content type="html">&lt;p&gt;しまなみ海道の観光ガイドサイトをNext.js 15で個人開発する中で、Reactの非同期処理まわりで詰まったことを3つのテーマで整理します。「stateに入れない（useRef）」「再生成させない（useCallback）」「止める（AbortController）」——この3軸で読んでいただければと思います。&lt;/p&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;p&gt;このサイトでは地図機能を中心に据えており、&lt;a href=&quot;https://www.shimanami-guide.jp/map&quot;&gt;しまなみ海道 観光マップ&lt;/a&gt;としてスマートフォン・デスクトップどちらからでも使えるようにしています。地図上にはホテルと観光スポットのピンをレイヤーで切り替えて表示でき、ピンをクリック・タップすると吹き出し型のポップアップが開く構成です。詳細を開くと、スマートフォンではボトムシートが下からスライドして開き、デスクトップでは右サイドからパネルが開いてスポットの詳細情報を確認できます。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;デスクトップ版ではピンをクリックすると吹き出し型のポップアップが表示され、詳細を押すと右サイドからパネルが開いてスポットの詳細情報を確認できます&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415091906.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;#x306E;&amp;#x30C7;&amp;#x30B9;&amp;#x30AF;&amp;#x30C8;&amp;#x30C3;&amp;#x30D7;&amp;#x753B;&amp;#x9762;&amp;mdash;&amp;mdash;&amp;#x5730;&amp;#x56F3;&amp;#x4E0A;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x3092;&amp;#x30AF;&amp;#x30EA;&amp;#x30C3;&amp;#x30AF;&amp;#x3059;&amp;#x308B;&amp;#x3068;&amp;#x5439;&amp;#x304D;&amp;#x51FA;&amp;#x3057;&amp;#x578B;&amp;#x306E;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3001;&amp;#x8A73;&amp;#x7D30;&amp;#x30DC;&amp;#x30BF;&amp;#x30F3;&amp;#x304B;&amp;#x3089;&amp;#x53F3;&amp;#x30B5;&amp;#x30A4;&amp;#x30C9;&amp;#x30D1;&amp;#x30CD;&amp;#x30EB;&amp;#x306B;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x60C5;&amp;#x5831;&amp;#x304C;&amp;#x958B;&amp;#x3044;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x72B6;&amp;#x614B;&quot; width=&quot;1200&quot; height=&quot;745&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;デスクトップ版ではピンをクリックすると吹き出し型のポップアップが表示され、詳細を押すと右サイドからパネルが開いてスポットの詳細情報を確認できます&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;地図上にはホテルと観光スポットのピンをレイヤーで切り替えて表示できます。MapboxのMarkerをReactコンポーネントとして描画しています&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415085757.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30DB;&amp;#x30C6;&amp;#x30EB;&amp;#x3068;&amp;#x89B3;&amp;#x5149;&amp;#x30B9;&amp;#x30DD;&amp;#x30C3;&amp;#x30C8;&amp;#x306E;&amp;#x30D4;&amp;#x30F3;&amp;#x304C;&amp;#x30EC;&amp;#x30A4;&amp;#x30E4;&amp;#x30FC;&amp;#x3054;&amp;#x3068;&amp;#x306B;&amp;#x5730;&amp;#x56F3;&amp;#x4E0A;&amp;#x306B;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;369&quot; height=&quot;662&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;地図上にはホテルと観光スポットのピンをレイヤーで切り替えて表示できます。MapboxのMarkerをReactコンポーネントとして描画しています&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;useRefstateに入れると困るものを保持する&quot;&gt;useRef——「stateに入れると困るもの」を保持する&lt;/h2&gt;

&lt;p&gt;useRefは「DOMを参照するためのもの」というイメージが強いですが、実際には「再レンダーをトリガーせずに値を保持したい」場面でのほうがよく使いました。&lt;/p&gt;

&lt;h3 id=&quot;パターン1タイマーIDの保持&quot;&gt;パターン1：タイマーIDの保持&lt;/h3&gt;

&lt;p&gt;ボトムシート（Sheet.tsx）では、&lt;code&gt;requestAnimationFrame&lt;/code&gt; のIDを &lt;code&gt;raf1Ref&lt;/code&gt; / &lt;code&gt;raf2Ref&lt;/code&gt; に、&lt;code&gt;setTimeout&lt;/code&gt; のIDを &lt;code&gt;closeTimerRef&lt;/code&gt; に持たせています。これらはアニメーション中にクリーンアップが必要なだけで、画面の表示には影響しません。stateに入れると値が変わるたびに再レンダーが走りますが、refならその心配がありません。&lt;/p&gt;

&lt;h3 id=&quot;パターン2ドラッグ中フラグとクロージャ問題&quot;&gt;パターン2：ドラッグ中フラグとクロージャ問題&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;draggingRef&lt;/code&gt;（ドラッグ中かどうかのフラグ）も同様です。ドラッグ中はポインターイベントが高頻度で発火するので、毎フレーム再レンダーが走るとアニメーションが崩れます。「画面には影響しないが、ロジック上は最新値を持ちたい」ものはrefに入れる、という判断です。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;showRef&lt;/code&gt; はもう少し特殊で、&lt;code&gt;show&lt;/code&gt; ステートの最新値をrefにコピーしています。イベントハンドラはクロージャなので、登録時点の &lt;code&gt;show&lt;/code&gt; の値しか見えません。&lt;code&gt;showRef.current&lt;/code&gt; を参照するようにすることで、ハンドラが「今この瞬間のshowはどうか」を正しく確認できるようになります。&lt;/p&gt;

&lt;h3 id=&quot;パターン3レースコンディション防止&quot;&gt;パターン3：レースコンディション防止&lt;/h3&gt;

&lt;p&gt;useHotelDetail.ts では &lt;code&gt;latestNoRef&lt;/code&gt; でレースコンディションを防いでいます。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const latestNoRef = useRef&amp;lt;number | null&amp;gt;(null);

useEffect(() =&amp;gt; {
  latestNoRef.current = hotelNo;
}, [hotelNo]);

// フェッチ完了後
p.then((data) =&amp;gt; {
  if (latestNoRef.current !== normalizedNo) return; // 古い結果は捨てる
  setDetail(data);
});&lt;/pre&gt;


&lt;p&gt;たとえばポップアップから「ホテルA→ホテルB」と素早く切り替えたとき、Aのリクエストが遅れて返ってくることがあります。このとき何もしなければ、最終的にAの情報がボトムシートに表示されてしまいます。&lt;code&gt;latestNoRef&lt;/code&gt; に常に最新のhotelNoを書き込んでおき、フェッチ完了時に一致しなければ捨てる、というパターンです。&lt;/p&gt;

&lt;p&gt;MapboxのMarkerインスタンスも &lt;code&gt;markersRef&lt;/code&gt;（&lt;code&gt;Map&amp;lt;string, Marker&amp;gt;&lt;/code&gt; 型）に保持しています。Mapboxのインスタンスをstateに入れると、ピンが一つ追加されるたびに地図コンポーネント全体が再レンダーされます。Mapboxは自前でDOMを管理しているため、Reactの再レンダーが走ると競合することもあります。「Reactの管理外のオブジェクトはrefに入れる」はほぼ鉄則だと感じました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;useCallback再生成させないが必要なケース&quot;&gt;useCallback——「再生成させない」が必要なケース&lt;/h2&gt;

&lt;p&gt;useCallbackはパフォーマンス最適化のイメージがありますが、使ってみると「バグ防止」として機能する場面が多かったです。&lt;/p&gt;

&lt;h3 id=&quot;パターン1useEffectの無限ループ防止&quot;&gt;パターン1：useEffectの無限ループ防止&lt;/h3&gt;

&lt;p&gt;Sheet.tsx の &lt;code&gt;finalizeClose&lt;/code&gt;（シートが閉じ終わったあとの後処理）は、useEffectの依存配列に含まれています。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;const finalizeClose = useCallback(() =&amp;gt; {
  onClose();
  setMounted(false);
}, [onClose]);

useEffect(() =&amp;gt; {
  closeTimerRef.current = window.setTimeout(() =&amp;gt; {
    finalizeClose();
  }, closeMs);
}, [open, closeMs, finalizeClose]);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;useCallback&lt;/code&gt; を外すと、&lt;code&gt;finalizeClose&lt;/code&gt; は毎レンダーで新しい関数オブジェクトになります。するとuseEffectが「依存配列が変わった」と判断して再実行し、また &lt;code&gt;finalizeClose&lt;/code&gt; が再生成され……という無限ループになります。依存配列に関数を入れるときは、useCallbackで参照を安定させるのがほぼ必須です。&lt;/p&gt;

&lt;h3 id=&quot;パターン2イベントリスナーの解除ができなくなる&quot;&gt;パターン2：イベントリスナーの解除ができなくなる&lt;/h3&gt;

&lt;p&gt;useGeolocate.ts では、&lt;code&gt;navigator.geolocation.watchPosition&lt;/code&gt; に渡すコールバックを &lt;code&gt;useCallback&lt;/code&gt; で安定化しています。これをしないと、レンダーのたびに新しい関数が生成されてwatchの登録が毎回やり直されます。&lt;/p&gt;

&lt;p&gt;useSatelliteLayer.ts では &lt;code&gt;map.on(&#39;style.load&#39;, ensureSatellite)&lt;/code&gt; のようにMapboxのイベントリスナーを登録しています。イベントリスナーは「登録時と同じ関数の参照」を &lt;code&gt;map.off&lt;/code&gt; に渡さないと解除できません。&lt;code&gt;useCallback&lt;/code&gt; を使わずに毎レンダーで新しい関数を渡していると、&lt;code&gt;off&lt;/code&gt; が空振りし続けてリスナーが溜まっていきます。これは開発中になかなか気づきにくいバグです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;AbortController非同期処理を止める&quot;&gt;AbortController——非同期処理を「止める」&lt;/h2&gt;

&lt;h3 id=&quot;シンプルパターンstyleUrlが変わったらキャンセル&quot;&gt;シンプルパターン：styleUrlが変わったらキャンセル&lt;/h3&gt;

&lt;p&gt;useMapStyle.ts では最もベーシックな使い方をしています。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;useEffect(() =&amp;gt; {
  const ac = new AbortController();
  (async () =&amp;gt; {
    try {
      const spec = await fetchAndLocalizeStyle(styleUrl, ac.signal);
      if (!ac.signal.aborted) setStyleSpec(spec);
    } catch {
      if (!ac.signal.aborted) setStyleErr(&amp;#39;読み込み失敗&amp;#39;);
    }
  })();
  return () =&amp;gt; ac.abort();
}, [styleUrl]);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;styleUrl&lt;/code&gt; が変わったとき、クリーンアップ関数で &lt;code&gt;ac.abort()&lt;/code&gt; を呼んで前のフェッチをキャンセルします。フェッチ完了後に &lt;code&gt;ac.signal.aborted&lt;/code&gt; を確認しているのは、中断後のstate更新を防ぐためです。これをしないと「キャンセルしたはずのリクエストが完了してstateを書き換える」という問題が起きます。&lt;/p&gt;

&lt;h3 id=&quot;複合パターン重複リクエスト排除との組み合わせ&quot;&gt;複合パターン：重複リクエスト排除との組み合わせ&lt;/h3&gt;

&lt;p&gt;ポップアップから詳細をタップするとホテルの詳細情報を取得しますが、素早く別のピンに切り替えると複数のリクエストが同時に飛びます。useHotelDetail.ts では AbortController に加えて、モジュールレベルの &lt;code&gt;inFlight&lt;/code&gt; と &lt;code&gt;memoryCache&lt;/code&gt; を組み合わせてこの問題に対応しています。&lt;/p&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;ピンをタップすると吹き出し型のポップアップが表示され、詳細をタップするとボトムシートが下からスライドして開きます。このSheet.tsxが今回の記事で扱うコンポーネントです&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260415/20260415085855.png&quot; alt=&quot;&amp;#x3057;&amp;#x307E;&amp;#x306A;&amp;#x307F;&amp;#x6D77;&amp;#x9053;&amp;#x89B3;&amp;#x5149;&amp;#x30DE;&amp;#x30C3;&amp;#x30D7;&amp;mdash;&amp;mdash;&amp;#x30D4;&amp;#x30F3;&amp;#x3092;&amp;#x30BF;&amp;#x30C3;&amp;#x30D7;&amp;#x3059;&amp;#x308B;&amp;#x3068;&amp;#x5439;&amp;#x304D;&amp;#x51FA;&amp;#x3057;&amp;#x578B;&amp;#x306E;&amp;#x30DD;&amp;#x30C3;&amp;#x30D7;&amp;#x30A2;&amp;#x30C3;&amp;#x30D7;&amp;#x304C;&amp;#x8868;&amp;#x793A;&amp;#x3055;&amp;#x308C;&amp;#x3001;&amp;#x8A73;&amp;#x7D30;&amp;#x30DC;&amp;#x30BF;&amp;#x30F3;&amp;#x304B;&amp;#x3089;&amp;#x30DC;&amp;#x30C8;&amp;#x30E0;&amp;#x30B7;&amp;#x30FC;&amp;#x30C8;&amp;#x304C;&amp;#x958B;&amp;#x3044;&amp;#x3066;&amp;#x3044;&amp;#x308B;&amp;#x30B9;&amp;#x30DE;&amp;#x30FC;&amp;#x30C8;&amp;#x30D5;&amp;#x30A9;&amp;#x30F3;&amp;#x753B;&amp;#x9762;&quot; width=&quot;377&quot; height=&quot;667&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;ピンをタップすると吹き出し型のポップアップが表示され、詳細をタップするとボトムシートが下からスライドして開きます。このSheet.tsxが今回の記事で扱うコンポーネントです&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// モジュールレベル（複数のフックインスタンスで共有）
const memoryCache = new Map&amp;lt;number, HotelDetail&amp;gt;();
const inFlight = new Map&amp;lt;number, Promise&amp;lt;HotelDetail&amp;gt;&amp;gt;();

useEffect(() =&amp;gt; {
  const controller = new AbortController();
  const existing = inFlight.get(normalizedNo);
  const p = existing ?? fetchDetail(normalizedNo, controller.signal);
  if (!existing) inFlight.set(normalizedNo, p);

  p.then((data) =&amp;gt; {
    memoryCache.set(normalizedNo, data);
    setDetail(data);
  }).finally(() =&amp;gt; {
    if (!existing) inFlight.delete(normalizedNo);
  });

  return () =&amp;gt; controller.abort();
}, [normalizedNo]);&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;inFlight&lt;/code&gt; は「同じhotelNoへのリクエストが進行中なら、そのPromiseを使い回す」ための仕組みです。複数箇所から同じホテルの詳細を要求しても、フェッチは1本しか走りません。&lt;code&gt;memoryCache&lt;/code&gt; は一度取得したデータをメモリに持ち続け、同じホテルを再度開いたときに即表示します。&lt;/p&gt;

&lt;p&gt;この3つ（AbortController・inFlight・memoryCache）に、前述の &lt;code&gt;latestNoRef&lt;/code&gt; も加わります。「キャンセル・重複排除・キャッシュ・レースコンディション防止」の4つが連動して、非同期処理の信頼性を担保しています。最初は全部必要なのか疑問でしたが、実際に地図上で素早くピンをタップして試してみると、どれか一つでも欠けると挙動がおかしくなりました。&lt;/p&gt;

&lt;p&gt;ここまでが「Reactの内側で完結する」処理の話です。次の記事では、ReactツリーそのものをはみだすUIの実装——&lt;code&gt;forwardRef&lt;/code&gt; による命令型API・&lt;code&gt;createPortal&lt;/code&gt; によるDOMへの脱出・&lt;code&gt;Zustand&lt;/code&gt; による状態管理——を同じサイトの実例で整理します。&lt;/p&gt;

&lt;p&gt;記事の続きは以下へ&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Freact-forwardref-createportal-zustand-map-site&quot; title=&quot;forwardRef・createPortal・Zustand——しまなみ海道観光サイトでReactの作法を少し外す設計をした話 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/react-forwardref-createportal-zustand-map-site&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/efb75a415c4034babbf46f7ba5ae6603e68fd16a/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260415%2F20260415091906.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>`fs` を使ったらエラーが出た——Next.js の Server / Client Component を理解するまで</title>
        <link href="https://mojitonews.hateblo.jp/entry/fs-error-nextjs-server-client-component"/>
        <id>hatenablog://entry/17179246901376321085</id>
        <published>2026-04-14T07:20:25+09:00</published>
        <updated>2026-04-15T21:35:12+09:00</updated>        <summary type="html">Next.js App Routerでfsモジュールを使ったらビルドエラーが発生。Server ComponentとClient Componentの違いを理解できず30分迷走した実体験をもとに、分離設計の考え方とserver-onlyパッケージの使い方を解説します。</summary>
        <content type="html">&lt;h2 id=&quot;きっかけはビルドエラーでした&quot;&gt;きっかけはビルドエラーでした&lt;/h2&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;p&gt;しまなみ海道の観光ガイドサイトを開発していたとき、MDX ファイルを読み込む処理を書きました。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// lib/spots.ts
import fs from &amp;#39;fs&amp;#39;
import path from &amp;#39;path&amp;#39;

export function getAllSpots() {
  const dir = path.join(process.cwd(), &amp;#39;content/spots&amp;#39;)
  return fs.readdirSync(dir)
}&lt;/pre&gt;


&lt;p&gt;「動くだろう」と思ってビルドしたら、こんなエラーが出ました。&lt;/p&gt;

&lt;pre class=&quot;code&quot; data-lang=&quot;&quot; data-unlink&gt;Module not found: Can&amp;#39;t resolve &amp;#39;fs&amp;#39;&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;fs&lt;/code&gt; が見つからない？ Node.js の標準モジュールなのになぜ、と最初は意味がわかりませんでした。&lt;/p&gt;

&lt;h2 id=&quot;最初は原因がわからず迷走しました&quot;&gt;最初は原因がわからず迷走しました&lt;/h2&gt;

&lt;p&gt;最初にやったのは &lt;code&gt;fs&lt;/code&gt; のインポート文を確認することでした。タイポかと思ったのですが、スペルに問題はありません。次に &lt;code&gt;tsconfig.json&lt;/code&gt; を疑い、&lt;code&gt;node&lt;/code&gt; の型定義が入っているか確認しました。&lt;/p&gt;

&lt;pre class=&quot;code lang-json&quot; data-lang=&quot;json&quot; data-unlink&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;
  &amp;quot;&lt;span class=&quot;synStatement&quot;&gt;compilerOptions&lt;/span&gt;&amp;quot;: &lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;
    &amp;quot;&lt;span class=&quot;synStatement&quot;&gt;types&lt;/span&gt;&amp;quot;: &lt;span class=&quot;synSpecial&quot;&gt;[&lt;/span&gt;&amp;quot;&lt;span class=&quot;synConstant&quot;&gt;node&lt;/span&gt;&amp;quot;&lt;span class=&quot;synSpecial&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;これも問題なし。&lt;code&gt;@types/node&lt;/code&gt; もインストール済みでした。&lt;/p&gt;

&lt;pre class=&quot;code bash&quot; data-lang=&quot;bash&quot; data-unlink&gt;npm list @types/node
# @types/node@20.x.x ← 入っている&lt;/pre&gt;


&lt;p&gt;それでもエラーは消えません。30分ほど同じところをぐるぐるして、ようやく「そもそもなぜブラウザで &lt;code&gt;fs&lt;/code&gt; を使おうとしているのか」という根本的な問いにたどり着きました。&lt;/p&gt;

&lt;h2 id=&quot;デフォルトがサーバーを理解していませんでした&quot;&gt;「デフォルトがサーバー」を理解していませんでした&lt;/h2&gt;

&lt;p&gt;調べてわかったのは、&lt;strong&gt;&lt;code&gt;fs&lt;/code&gt; を呼び出していたコンポーネントに &lt;code&gt;&#39;use client&#39;&lt;/code&gt; が書いてあった&lt;/strong&gt;ということです。&lt;/p&gt;

&lt;p&gt;Next.js の App Router では、コンポーネントはデフォルトで Server Component として動きます。&lt;code&gt;&#39;use client&#39;&lt;/code&gt; を書いた瞬間、そのコンポーネントはブラウザで実行されるようになります。&lt;/p&gt;

&lt;p&gt;そして &lt;code&gt;fs&lt;/code&gt; は Node.js のモジュールなので、ブラウザには存在しません。だから「見つからない」と怒られていたわけです。&lt;/p&gt;

&lt;p&gt;整理するとこういうことです。&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; &lt;/th&gt;
&lt;th&gt; Server Component &lt;/th&gt;
&lt;th&gt; Client Component &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; 実行環境 &lt;/td&gt;
&lt;td&gt; Node.js（サーバー） &lt;/td&gt;
&lt;td&gt; ブラウザ &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;fs&lt;/code&gt; / &lt;code&gt;path&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; ✅ 使える &lt;/td&gt;
&lt;td&gt; ❌ 使えない &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; &lt;code&gt;useState&lt;/code&gt; / &lt;code&gt;useEffect&lt;/code&gt; &lt;/td&gt;
&lt;td&gt; ❌ 使えない &lt;/td&gt;
&lt;td&gt; ✅ 使える &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; イベントハンドラ &lt;/td&gt;
&lt;td&gt; ❌ 不可 &lt;/td&gt;
&lt;td&gt; ✅ 使える &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; DB への直接アクセス &lt;/td&gt;
&lt;td&gt; ✅ 可能 &lt;/td&gt;
&lt;td&gt; ❌ 不可 &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;わかってしまえばシンプルですが、&lt;strong&gt;「どこで動いているか」を意識せずにコードを書いていた&lt;/strong&gt;のが原因でした。&lt;/p&gt;

&lt;h2 id=&quot;直し方データ取得はサーバーに閉じ込める&quot;&gt;直し方：データ取得はサーバーに閉じ込める&lt;/h2&gt;

&lt;p&gt;解決策はシンプルで、&lt;code&gt;fs&lt;/code&gt; を使う処理を &lt;strong&gt;Server Component 側に移す&lt;/strong&gt;だけでよかったです。&lt;/p&gt;

&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// app/spots/page.tsx（Server Component・&amp;#39;use client&amp;#39; なし）
import { getAllSpots } from &amp;#39;@/lib/spots&amp;#39;

export default async function SpotsPage() {
  const spots = await getAllSpots() // サーバーで実行される
  return &amp;lt;SpotList spots={spots} /&amp;gt;
}&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;SpotList&lt;/code&gt; の中でクリック処理や &lt;code&gt;useState&lt;/code&gt; が必要なら、そのコンポーネントだけ &lt;code&gt;&#39;use client&#39;&lt;/code&gt; にします。&lt;strong&gt;データを取ってくる層と UI を操作する層を分ける&lt;/strong&gt;、という考え方です。&lt;/p&gt;

&lt;pre class=&quot;code lang-tsx&quot; data-lang=&quot;tsx&quot; data-unlink&gt;&lt;span class=&quot;synComment&quot;&gt;// components/SpotList.tsx&lt;/span&gt;
&lt;span class=&quot;synConstant&quot;&gt;&#39;use client&#39;&lt;/span&gt;

&lt;span class=&quot;synStatement&quot;&gt;type &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;Props &lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synIdentifier&quot;&gt;spots&lt;/span&gt;: &lt;span class=&quot;synType&quot;&gt;string&lt;/span&gt;[]
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;synSpecial&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;synStatement&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;synIdentifier&quot;&gt;SpotList&lt;/span&gt;(&lt;span class=&quot;synPreProc&quot;&gt;{ spots }&lt;/span&gt;:&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;Props&lt;/span&gt;) &lt;span class=&quot;synIdentifier&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;synStatement&quot;&gt;return&lt;/span&gt; (
    &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;ul&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;spots.&lt;span class=&quot;synStatement&quot;&gt;map&lt;/span&gt;((&lt;span class=&quot;synPreProc&quot;&gt;spot&lt;/span&gt;)&lt;span class=&quot;synPreProc&quot;&gt; &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;=&amp;gt;&lt;/span&gt; (
        &lt;span class=&quot;synComment&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;li &lt;/span&gt;&lt;span class=&quot;synType&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;synStatement&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;spot&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;synSpecial&quot;&gt;{&lt;/span&gt;spot&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;li&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;
      ))&lt;span class=&quot;synSpecial&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;synComment&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;synIdentifier&quot;&gt;ul&lt;/span&gt;&lt;span class=&quot;synComment&quot;&gt;&amp;gt;&lt;/span&gt;
  )
&lt;span class=&quot;synIdentifier&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;


&lt;p&gt;Server Component でデータを取得してから props として渡す——このパターンを覚えてから、設計がずいぶんスムーズになりました。&lt;/p&gt;

&lt;h2 id=&quot;server-only-パッケージという保険もある&quot;&gt;&lt;code&gt;server-only&lt;/code&gt; パッケージという保険もある&lt;/h2&gt;

&lt;p&gt;今回のような事故を未然に防ぐ方法もあります。&lt;code&gt;server-only&lt;/code&gt; というパッケージを使うと、そのファイルが Client Component から import されたときにビルドエラーを出してくれます。&lt;/p&gt;

&lt;pre class=&quot;code bash&quot; data-lang=&quot;bash&quot; data-unlink&gt;npm install server-only&lt;/pre&gt;




&lt;pre class=&quot;code ts&quot; data-lang=&quot;ts&quot; data-unlink&gt;// lib/spots.ts
import &amp;#39;server-only&amp;#39; // ← これを先頭に追加するだけ
import fs from &amp;#39;fs&amp;#39;
import path from &amp;#39;path&amp;#39;

export function getAllSpots() {
  const dir = path.join(process.cwd(), &amp;#39;content/spots&amp;#39;)
  return fs.readdirSync(dir)
}&lt;/pre&gt;


&lt;p&gt;クライアントから呼ばれた瞬間に「このモジュールはサーバー専用です」というエラーが出るので、うっかりミスを防げます。APIキーを扱うファイルなど、&lt;strong&gt;絶対にクライアントに渡したくない処理&lt;/strong&gt;に入れておくのがおすすめです。&lt;/p&gt;

&lt;h2 id=&quot;境界線の引き方&quot;&gt;境界線の引き方&lt;/h2&gt;

&lt;p&gt;この経験以来、コンポーネントを書くときに「これはどちらに置くべきか」を先に考えるようになりました。自分の中での判断基準はこうです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;サーバーに置くもの&lt;/strong&gt;
- &lt;code&gt;fs&lt;/code&gt; でファイルを読む処理
- API キーを使う処理（クライアントに渡したくない）
- DB への直接アクセス
- メタデータの生成（SEO）&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;クライアントに置くもの&lt;/strong&gt;
- &lt;code&gt;onClick&lt;/code&gt; などのイベントハンドラ
- &lt;code&gt;useState&lt;/code&gt; / &lt;code&gt;useEffect&lt;/code&gt; を使う状態管理
- Mapbox のようなブラウザ専用ライブラリ
- ユーザーの操作に応じてリアルタイムで変わる UI&lt;/p&gt;

&lt;p&gt;迷ったときは「&lt;strong&gt;ブラウザが必要な処理か？&lt;/strong&gt;」と自問するとだいたい決まります。ブラウザが不要なら、サーバーに置いて損はありません。&lt;/p&gt;

&lt;h2 id=&quot;Server-Component-のメリットを実感した&quot;&gt;Server Component のメリットを実感した&lt;/h2&gt;

&lt;p&gt;この一件でサーバーに閉じ込めることのメリットも体感できました。&lt;/p&gt;

&lt;p&gt;ひとつはパフォーマンスです。サーバーで処理してから HTML を返すので、クライアントに送る JavaScript の量が減ります。MDX ファイルの読み込みや変換処理をサーバーで完結させることで、ブラウザ側の負荷が大幅に下がりました。&lt;/p&gt;

&lt;p&gt;もうひとつはセキュリティです。API キーや DB の接続情報をサーバーに閉じ込めれば、ブラウザの開発者ツールから見えません。以前は &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; プレフィックスをつけた環境変数をクライアントに渡していましたが、Server Component を使えばそもそも公開する必要がなくなります。&lt;/p&gt;

&lt;h2 id=&quot;やってよかったこと&quot;&gt;やってよかったこと&lt;/h2&gt;

&lt;p&gt;最初のエラーで止まっていたら、そのまま &lt;code&gt;&#39;use client&#39;&lt;/code&gt; を外して終わりにしていたかもしれません。でも「なぜ &lt;code&gt;fs&lt;/code&gt; が使えないのか」を調べたことで、App Router の設計思想がようやく腑に落ちました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;デフォルトがサーバーというのは、セキュリティとパフォーマンスのための意図的な設計&lt;/strong&gt;です。クライアントに余計なコードを送らない、秘匿情報をサーバーに閉じ込める、という考え方が根底にあります。&lt;/p&gt;

&lt;p&gt;エラーメッセージをしっかりと読まないといけないと、感じた出来事でした。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;Next.js 15 + TypeScript + MDX でしまなみ海道の観光ガイドサイト（&lt;a href=&quot;https://shimanami-guide.jp&quot;&gt;shimanami-guide.jp&lt;/a&gt;）を個人開発しています。詰まったことを記事にしていきます。&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fnextjs-personal-site-learning&quot; title=&quot;手を動かして初めてわかった　Next.js + TypeScript で個人サイトを作りながら学ぶという選択 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/nextjs-personal-site-learning&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fclaude-code-nextjs-personal-site-development&quot; title=&quot;AIと一緒に個人サイトを開発するとはどういう体験か——Claude Code を使って気づいたこと - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/claude-code-nextjs-personal-site-development&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="個人開発" label="個人開発" />
        
        <category term="React" label="React" />
        
        <category term="TypeScript" label="TypeScript" />
        
        <link rel="enclosure" href="https://ogimage.blog.st-hatena.com/10328537792363370792/17179246901376321085/1776256512" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
    
    
    <entry>
        <title>AIと一緒に個人サイトを開発するとはどういう体験か——Claude Code を使って気づいたこと</title>
        <link href="https://mojitonews.hateblo.jp/entry/claude-code-nextjs-personal-site-development"/>
        <id>hatenablog://entry/17179246901375886794</id>
        <published>2026-04-12T23:24:08+09:00</published>
        <updated>2026-04-15T21:35:27+09:00</updated>        <summary type="html">Next.jsの個人サイト開発にClaude Codeを使ってみた体験を率直に記録。実際の開発フロー・便利だと感じた場面・思っていたのと違った場面を、学習目的の個人開発者の視点でまとめています。</summary>
        <content type="html">&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;猫の細道のスポットページをMDXで管理している。frontmatterに緯度経度・タグ・画像リストを書いておくと、地図・一覧・記事ページを1ファイルで制御できる。&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260412/20260412231744.png&quot; alt=&quot;VSCode&amp;#x306E;&amp;#x30BF;&amp;#x30FC;&amp;#x30DF;&amp;#x30CA;&amp;#x30EB;&amp;#x3067;Claude Code&amp;#x304C;&amp;#x7DE8;&amp;#x96C6;&amp;#x3059;&amp;#x308B;&amp;#x732B;&amp;#x306E;&amp;#x7D30;&amp;#x9053;&amp;#x306E;MDX&amp;#x30D5;&amp;#x30A1;&amp;#x30A4;&amp;#x30EB;&amp;mdash;&amp;mdash;frontmatter&amp;#x3068;SpotImage&amp;#x30B3;&amp;#x30F3;&amp;#x30DD;&amp;#x30FC;&amp;#x30CD;&amp;#x30F3;&amp;#x30C8;&amp;#x304C;&amp;#x4E26;&amp;#x3076;&amp;#x753B;&amp;#x9762;&quot; width=&quot;1200&quot; height=&quot;718&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;猫の細道のスポットページをMDXで管理している。frontmatterに緯度経度・タグ・画像リストを書いておくと、地図・一覧・記事ページを1ファイルで制御できる。&lt;/figcaption&gt;&lt;/figure&gt;&lt;/p&gt;

&lt;h2 id=&quot;はじめに&quot;&gt;はじめに&lt;/h2&gt;

&lt;p&gt;「AIにコードを書いてもらう」という表現をよく見かけます。でも実際のところ、少し違います。&lt;/p&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;p&gt;Anthropic が提供する AI CLI ツール「Claude Code」を使いながら Next.js の個人サイトを開発してみてわかったのは、「一緒に考えながら作る」という感覚に近いということでした。&lt;/p&gt;

&lt;p&gt;開発環境は VSCode で、Claude Code は VSCode のターミナルから起動して使っています。&lt;/p&gt;

&lt;p&gt;この記事では、実際の開発フローや便利だと感じた場面、逆に思っていたのと違った場面を率直に書いていきます。&lt;/p&gt;

&lt;h2 id=&quot;Claude-Code-とはどんなツールか&quot;&gt;Claude Code とはどんなツールか&lt;/h2&gt;

&lt;p&gt;Claude Code は Anthropic が提供する CLI ツールです。ブラウザ版の Claude とは別物で、ターミナルから起動してプロジェクトのファイル構造を丸ごと把握した状態で動きます。ファイルの読み書き・編集・git 操作・コマンド実行まで自律的にこなせるのが特徴です。&lt;/p&gt;

&lt;p&gt;ブラウザ版 Claude がチャット画面でやりとりするのに対して、Claude Code はコードベース全体を理解した上で作業するため、「このコンポーネントを修正して」「このエラーを調べて直して」といった指示がそのまま通ります。複数ファイルにまたがる変更や、エラーの原因究明から修正までを一気に行えるのはブラウザ版にはない強みです。&lt;/p&gt;

&lt;p&gt;利用には Pro プラン（月額 $20）以上のサブスクリプションが必要です。私は Pro プランで使っています。個人開発の学習目的であれば、まず Pro プランで十分だと感じています。そのうち物足りなくなればMAXプランも使いたいなと思っています。&lt;/p&gt;

&lt;h2 id=&quot;実際の開発フローどう指示を出すか&quot;&gt;実際の開発フロー——どう指示を出すか&lt;/h2&gt;

&lt;p&gt;Claude Code への指示は自然言語で話しかけるだけです。たとえば実際にこんな指示を出したことがあります。&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;「立花海岸のMDXファイルに早朝の画像を4枚追加して、SEOの shortDescription を110文字以上に修正して、H2見出しにキーワードを追加してほしい」&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;これを一回伝えると、Claude Code は以下を自律的に実行します。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;対象ファイルを読む&lt;/li&gt;
&lt;li&gt;画像ファイルの存在を確認する&lt;/li&gt;
&lt;li&gt;frontmatter を編集する&lt;/li&gt;
&lt;li&gt;記事本文に画像コンポーネントを追加する&lt;/li&gt;
&lt;li&gt;文字数を数えて shortDescription を調整する&lt;/li&gt;
&lt;li&gt;修正内容を説明する&lt;/li&gt;
&lt;/ol&gt;


&lt;p&gt;指示の出し方にはコツがあって、ファイル名や条件を具体的に伝えるほど精度が上がります。この辺りはまた別の機会に詳しく書くつもりです。&lt;/p&gt;

&lt;p&gt;一方で、私がやることも明確にあります。方針・優先順位の決定（何をどの順番で作るか）、事実確認（「360度パノラマ」と書いてあったが実際は約240度だった、など現地に行かないとわからないこと）、写真の撮影とファイルの配置、最終的なデプロイ判断——これらは私が行います。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;「何を作るか」を決めるのは人間、「どう実装するか」を一緒に考えるのが Claude Code&lt;/strong&gt; という役割分担が、だんだん見えてきました。&lt;/p&gt;

&lt;h2 id=&quot;やってみてわかったこと便利な場面とそうでない場面&quot;&gt;やってみてわかったこと——便利な場面と、そうでない場面&lt;/h2&gt;

&lt;p&gt;使ってみて便利だと感じた場面は主に3つです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;大量の同じ作業&lt;/strong&gt;：複数ファイルのH2見出しに一括でキーワードを追加するといった繰り返し作業は、Claude Code に任せると一気に片付きます。手作業でやると確実にミスが出るような作業ほど、効果を実感します。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;調査→修正の繰り返し&lt;/strong&gt;：SEOレポートを出して問題を洗い出し、修正して再確認する、というサイクルをそのまま会話の流れで進められます。「次に何をすべきか」を一緒に考えながら動いてくれる感覚です。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;エラーの原因究明&lt;/strong&gt;：「この画像が粗くなる理由を調べて解決して」と伝えると、ファイルを読んで原因を仮説立てして修正まで追ってくれます。独学でひとり詰まっているときに、相談相手がいる感覚になります。&lt;/p&gt;

&lt;p&gt;一方で、思っていたのと違った場面もあります。&lt;/p&gt;

&lt;p&gt;完全に任せると意図と違う実装になることがあります。指示の粒度が大事で、曖昧な指示を出した側の問題だと気づくことが多いです。また、Claude Code が書いたコードを理解しないままにしておくと、後でレビューも修正もできなくなります。「何が問題か」を自分でわかっていないと、そもそもうまく指示が出せない、という場面も何度かありました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AIが書いたコードを理解する責任は自分にある&lt;/strong&gt;——これは使い始めて早い段階で気づいた、一番大事なことだと思っています。&lt;/p&gt;

&lt;h2 id=&quot;書いてもらうではなく一緒に作りながら理解するという使い方&quot;&gt;「書いてもらう」ではなく「一緒に作りながら理解する」という使い方&lt;/h2&gt;

&lt;p&gt;Claude Code を使い始めて変わったのは、座学で学んだ知識が「実際に動くコード」として目の前に現れる速度です。エラーの原因を調べて修正するまでの時間が短くなり、その分だけ「なぜそうなるのか」を考える余裕が生まれました。&lt;/p&gt;

&lt;p&gt;ただ、私の場合は「個人サイトを作りながら学ぶ」という目的があるので、AIに全部お任せすると本末転倒になります。提案されたコードは必ず自分で読んで理解してから使う、という意識を持つようにしてから、学習としての手応えが出てきました。&lt;/p&gt;

&lt;p&gt;「じゃあ別の書き方はどうなる？」と聞いてみると複数の実装パターンを出してくれるので、それを比べながら自分で選ぶ、という使い方もしています。こうすることで選択肢を知りながら学べるのが面白いところです。&lt;/p&gt;

&lt;p&gt;「AIにコードを書いてもらう」ではなく「一緒に作りながら理解する」——この使い方が、学習目的の個人開発には合っていると感じています。&lt;/p&gt;

&lt;h2 id=&quot;あなたはどう使っていますか&quot;&gt;あなたはどう使っていますか？&lt;/h2&gt;

&lt;p&gt;Claude Code や GitHub Copilot など、AI と一緒に開発している方がいれば、どんな使い方をしているかぜひコメントで教えてください。&lt;/p&gt;

&lt;p&gt;「こう使うともっといいよ」「その使い方は後でつらくなるよ」といったフィードバックも大歓迎です。バックエンド・フロントエンドの先輩エンジニアの意見もお待ちしています。&lt;/p&gt;

&lt;p&gt;これからも作りながら率直に記録していきます。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;しまなみ海道観光ガイドサイト：&lt;a href=&quot;https://www.shimanami-guide.jp&quot;&gt;https://www.shimanami-guide.jp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fnextjs-personal-site-learning&quot; title=&quot;手を動かして初めてわかった　Next.js + TypeScript で個人サイトを作りながら学ぶという選択 - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/nextjs-personal-site-learning&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/78a86be3fb0138b695aa166e1944dd0141ffdb79/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260412%2F20260412231744.png" type="image/png" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
    <entry>
        <title>手を動かして初めてわかった　Next.js + TypeScript で個人サイトを作りながら学ぶという選択</title>
        <link href="https://mojitonews.hateblo.jp/entry/nextjs-personal-site-learning"/>
        <id>hatenablog://entry/17179246901375858406</id>
        <published>2026-04-12T22:31:25+09:00</published>
        <updated>2026-04-15T21:35:44+09:00</updated>        <summary type="html">座学だけでは身につかないと気づき、Next.js 15 + TypeScript + MDXでしまなみ海道の観光ガイドサイトを制作中。実際に詰まった壁や技術スタックの選定理由を記録しています。</summary>
        <content type="html">&lt;hr /&gt;

&lt;h2 id=&quot;座学だけでは身につかない実践学習に切り替えた理由&quot;&gt;座学だけでは身につかない——実践学習に切り替えた理由&lt;/h2&gt;

&lt;p&gt;正直に言うと、座学はそれなりにやってきたつもりでした。&lt;/p&gt;

&lt;p&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550880/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/adad63b72f1d6545b2ba2538c3fc2923b2fd5989/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fcomputers.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;プログラミング&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt; &lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/11696248318754550865/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/5d52120ed23f3640806daa319e974493d3e0137f/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.blog.st-hatena.com%2Fimages%2Fcircle%2Fofficial-circle-icon%2Fhobbies.gif&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;旅行&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;div class=&quot;embed-group&quot;&gt;&lt;a href=&quot;https://blog.hatena.ne.jp/-/group/10328749687235378243/redirect&quot; class=&quot;embed-group-link js-embed-group-link&quot;&gt;&lt;div class=&quot;embed-group-icon&quot;&gt;&lt;img src=&quot;https://cdn.image.st-hatena.com/image/square/3e62f76a360ca7db1953a867a7957c2bdef7a774/backend=imagemagick;height=80;version=1;width=80/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcircle_image%2F117419673%2F1514353089900055&quot; alt=&quot;&quot; width=&quot;40&quot; height=&quot;40&quot;&gt;&lt;/div&gt;&lt;div class=&quot;embed-group-content&quot;&gt;&lt;span class=&quot;embed-group-title-label&quot;&gt;ランキング参加中&lt;/span&gt;&lt;div class=&quot;embed-group-title&quot;&gt;弱小ブロガーズ&lt;/div&gt;&lt;/div&gt;&lt;/a&gt;&lt;/div&gt;&lt;/p&gt;

&lt;p&gt;JavaScriptの文法、Reactのコンポーネント設計、HTTPの仕組み、DBの正規化……。本を読み、Udemyを視聴し、ハンズオン記事を写経する。一通り「わかった気」になれる体験を繰り返してきました。&lt;/p&gt;

&lt;p&gt;でも、ある日ふと気づいてしまいました。&lt;strong&gt;「これじゃ何も作れるようにはならない」&lt;/strong&gt;と。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt; の依存配列についてはある程度説明できる。でも「じゃあ実際のプロジェクトで、どこに何を置いて、どうデプロイするの？」と聞かれると途端に詰まる。知識がパズルのピースとして頭の中に散らばっているだけで、このピースをどこに当てはめるのかわからない状況です。&lt;/p&gt;

&lt;p&gt;座学の定着率に限界を感じたとき、出した結論はシンプルでした。&lt;strong&gt;「動くものを作って、運用する」&lt;/strong&gt; しかねぇと。
それも、自分で興味のある分野を狙って作ることでずっと続けることができるもの。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;テーマはしまなみ海道地元題材で作るNextjs観光ガイドサイト&quot;&gt;テーマはしまなみ海道——地元題材で作るNext.js観光ガイドサイト&lt;/h2&gt;

&lt;p&gt;&lt;figure class=&quot;figure-image figure-image-fotolife&quot; title=&quot;尾道市向島町立花の海岸です。一度はサイクリングに訪れるべきスポット&quot;&gt;&lt;span itemscope itemtype=&quot;http://schema.org/Photograph&quot;&gt;&lt;img src=&quot;https://cdn-ak.f.st-hatena.com/images/fotolife/m/morningglorycloud0203/20260412/20260412221128.jpg&quot; alt=&quot;&amp;#x5C3E;&amp;#x9053;&amp;#x5E02;&amp;#x5411;&amp;#x5CF6;&amp;#x753A;&amp;#x7ACB;&amp;#x82B1;&amp;#x306E;&amp;#x6D77;&amp;#x5CB8;&amp;#x3000;&amp;#x30B5;&amp;#x30A4;&amp;#x30AF;&amp;#x30EA;&amp;#x30F3;&amp;#x30B0;&amp;#x7528;&amp;#x306E;&amp;#x30D6;&amp;#x30EB;&amp;#x30FC;&amp;#x30E9;&amp;#x30A4;&amp;#x30F3;&amp;#x304C;&amp;#x3042;&amp;#x308A;&amp;#x307E;&amp;#x3059;&amp;#x3002;&quot; width=&quot;1200&quot; height=&quot;900&quot; loading=&quot;lazy&quot; title=&quot;&quot; class=&quot;hatena-fotolife&quot; itemprop=&quot;image&quot;&gt;&lt;/span&gt;&lt;figcaption&gt;尾道市向島町立花の海岸です。一度はサイクリングに訪れるべきスポット&lt;/figcaption&gt;&lt;/figure&gt;
「何を作るか」は意外と重要で、ここで詰まると学習自体が止まります。&lt;/p&gt;

&lt;p&gt;候補はいくつか考えました。TODOアプリ、ポートフォリオ、ブログ……でも「またTODOアプリか」という気持ちになってしまい、手が動かない。モチベーションが続く題材でないと、技術的につまずいたときに逃げてしまいます。&lt;/p&gt;

&lt;p&gt;そこで選んだのが、&lt;strong&gt;しまなみ海道エリアの観光ガイドサイト&lt;/strong&gt;です。&lt;/p&gt;

&lt;p&gt;広島県尾道から愛媛県今治をつなぐしまなみ海道は、自分の地元に近いエリアです。サイクリングロードとして国際的にも知名度があり、島ごとに異なる風景や食・アートがある。ネタには困らないし、「地域の情報を自分の手でまとめて発信する」という目的があれば、コンテンツを作る動機にもなります。
それから、外に出て体を動かしながら色々巡ることができるというのも大きな点でした。&lt;/p&gt;

&lt;p&gt;作っているサイトはこちらです。
→ &lt;strong&gt;&lt;a href=&quot;https://www.shimanami-guide.jp&quot;&gt;https://www.shimanami-guide.jp&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;（まだ工事中の箇所だらけですが、それも含めて記録していくつもりです）&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;「作るものが決まれば、学習の方向性も決まる」&lt;/strong&gt;——これは実感としてあります。「画像を最適化したい」「地図を埋め込みたい」「記事ページを増やしたい」と具体的な目標が生まれると、調べることが一気にはっきりします。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;Nextjs-15--TypeScript--MDXを選んだ理由と構成&quot;&gt;Next.js 15 + TypeScript + MDXを選んだ理由と構成&lt;/h2&gt;

&lt;p&gt;現在の構成はこうなっています。&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt; 用途 &lt;/th&gt;
&lt;th&gt; 技術 &lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt; フレームワーク &lt;/td&gt;
&lt;td&gt; Next.js 15（App Router） &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 言語 &lt;/td&gt;
&lt;td&gt; TypeScript &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; スタイリング &lt;/td&gt;
&lt;td&gt; Tailwind CSS &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; コンテンツ管理 &lt;/td&gt;
&lt;td&gt; MDX &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; ホスティング &lt;/td&gt;
&lt;td&gt; Vercel &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt; 開発補助 &lt;/td&gt;
&lt;td&gt; Claude Code（AI CLI） &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;Next.js を選んだのは、「フロントエンドの現場でよく使われている」という理由が正直なところです。Next.js は React ベースのフレームワークなので、実質的に React を実践で使う経験も積めるという点も選んだ理由のひとつです。App Router は Pages Router と設計思想がかなり異なり、&lt;code&gt;use client&lt;/code&gt; と &lt;code&gt;use server&lt;/code&gt; の境界感覚はまだ手探り中です。&lt;/p&gt;

&lt;p&gt;TypeScript は「型があると安心」という話を聞き続けていたので採用しましたが、実際に書いてみると &lt;code&gt;any&lt;/code&gt; で逃げたくなる誘惑との戦いが続いています。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MDX によるコンテンツ管理&lt;/strong&gt;は、観光サイトという性質上、記事をたくさん書くことになるため選びました。Markdown で書けてコンポーネントも埋め込める、というのが理想的に見えたのですが、ビルド設定やfrontmatterの型定義まわりで最初はかなり詰まりました。&lt;/p&gt;

&lt;p&gt;開発には &lt;strong&gt;Claude Code&lt;/strong&gt;（AnthropicのAI CLIツール）を使っています。エラーメッセージを貼り付けると原因の仮説を出してくれたり、「このコンポーネントどう分割する？」と相談できたりするのは、独学者にとって思いのほか心強いです。ただし、提案をそのままコピペすると後で意味がわからなくなるので、必ず自分で読んで理解してから使う、を意識しています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;実際に作って詰まった壁座学では気づけなかったこと&quot;&gt;実際に作って詰まった壁——座学では気づけなかったこと&lt;/h2&gt;

&lt;p&gt;座学では「そういうものだ」と流せた話が、実際に手を動かすと急に解像度を上げてくる——その連続です。&lt;/p&gt;

&lt;p&gt;たとえばこんなことがありました。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;画像の最適化&lt;/strong&gt;：&lt;code&gt;next/image&lt;/code&gt; を使えばいいとは知っていたけど、外部ドメインの画像を表示しようとして &lt;code&gt;next.config.js&lt;/code&gt; の &lt;code&gt;remotePatterns&lt;/code&gt; を設定する必要があることを、エラーを見て初めて理解した&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;App Router のキャッシュ&lt;/strong&gt;：開発環境では反映されているのに本番では古いデータが表示される、という現象に遭遇して、fetch のキャッシュ戦略を初めてちゃんと調べた&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind のレスポンシブ&lt;/strong&gt;：&lt;code&gt;md:&lt;/code&gt; プレフィックスの仕組みは知っていたが、実際のデザインを整えようとすると「モバイルファースト」の思考に切り替えるのにかなり時間がかかった&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel へのデプロイ&lt;/strong&gt;：&lt;code&gt;vercel --prod&lt;/code&gt; でデプロイするだけのはずが、環境変数の設定漏れでビルドが通らず、ローカルと本番の差異を初めて体で覚えた&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;どれも「知識として知っていた」と「手を動かして理解した」の間にある深い溝です。&lt;/p&gt;

&lt;p&gt;そして気づいたのですが、&lt;strong&gt;この詰まりが全部、記事のネタになります&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;「こうやって詰まって、こう解決した」という記録を残すことで、同じところで困っている誰かの役に立てるかもしれない。それ自体も、サイトを作り続ける動機になっています。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;このプロジェクトで学べること学べないこと&quot;&gt;このプロジェクトで学べること・学べないこと&lt;/h2&gt;

&lt;p&gt;正直に書いておきます。このサイトで実際に触れていることと、触れていないことです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;触れていること&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;フロントエンドは Next.js 15 App Router（Server / Client Component の分離）、TypeScript による型安全な実装、Tailwind CSS でのレスポンシブ・ダークモード対応、MDX によるコンテンツ管理、&lt;code&gt;next/image&lt;/code&gt; の最適化オプション（fill / sizes / priority）を扱っています。&lt;/p&gt;

&lt;p&gt;インフラ・運用面では Vercel への本番デプロイ、Google Search Console と GA4 の実データを見ながらの改善、sharp.js による画像圧縮スクリプト、git / GitHub でのバージョン管理を実践しています。&lt;/p&gt;

&lt;p&gt;SEO・設計面では metadata / OGP / JSON-LD によるメタデータ設計、Hub &amp;amp; Spoke のページ構成設計、パンくずリストの実装にも取り組んでいます。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;触れていないこと&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;このサイトはファイルベースのため、データベース・認証・ログイン機能・API設計（REST / GraphQL）には触れていません。テストコードも書けていません。&lt;/p&gt;

&lt;p&gt;フロントエンドと運用の実践としては十分な内容ですが、バックエンド（DB・API・認証）を深めたい場合は別途機能を追加するか、別プロジェクトが必要だと認識しています。ただ、このサイト自体に機能を追加していくにつれて、学べることは増えていくはずだとも思っています。&lt;/p&gt;

&lt;h2 id=&quot;フィードバック歓迎一緒に学べる方へ&quot;&gt;フィードバック歓迎——一緒に学べる方へ&lt;/h2&gt;

&lt;p&gt;バックエンド・フロントエンドの両方で、まだ見えていない地雷がたくさんあると思っています。「そのやり方は後でつらくなるよ」「ここはこう考えた方がいい」というフィードバックを、もしよければコメントでいただけると非常に助かります。&lt;/p&gt;

&lt;p&gt;技術的に間違ったことを書いてしまう可能性もあります。そのときもご指摘いただけると、自分の学習にとってこれ以上ない励みになります。&lt;/p&gt;

&lt;p&gt;作りながら記録する、という方針でこのブログを続けていくつもりです。同じように「座学から実践へ踏み出した」タイミングの方がいれば、ぜひ一緒に詰まりましょう。&lt;/p&gt;

&lt;p&gt;これからここで、ちゃんと記録を残していきます。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;しまなみ海道観光ガイドサイト：&lt;a href=&quot;https://www.shimanami-guide.jp&quot;&gt;https://www.shimanami-guide.jp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe src=&quot;https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmojitonews.hateblo.jp%2Fentry%2Fclaude-code-nextjs-personal-site-development&quot; title=&quot;AIと一緒に個人サイトを開発するとはどういう体験か——Claude Code を使って気づいたこと - Shimanami RouteLab｜しまなみ海道のスポット紹介サイトを個人開発&quot; class=&quot;embed-card embed-blogcard&quot; scrolling=&quot;no&quot; frameborder=&quot;0&quot; style=&quot;display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;&quot; loading=&quot;lazy&quot;&gt;&lt;/iframe&gt;&lt;cite class=&quot;hatena-citation&quot;&gt;&lt;a href=&quot;https://mojitonews.hateblo.jp/entry/claude-code-nextjs-personal-site-development&quot;&gt;mojitonews.hateblo.jp&lt;/a&gt;&lt;/cite&gt;&lt;/p&gt;
</content>        
        <category term="Next.js" label="Next.js" />
        
        <category term="個人開発" label="個人開発" />
        
        <link rel="enclosure" href="https://cdn.image.st-hatena.com/image/scale/f9b8457cb8bf5b6abb0d6c26f9b4915cff1c06ec/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmorningglorycloud0203%2F20260412%2F20260412221128.jpg" type="image/jpeg" length="0" />

        <author>
            <name>morningglorycloud0203</name>
        </author>
    </entry>
    
  
</feed>
