<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-04-17T13:59:39+09:00</updated>
  <author>
    <name>morningglorycloud0203</name>
  </author>
  <generator uri="https://blog.hatena.ne.jp/" version="f5154c74e6e38109ce2f0c7b9afd51">Hatena::Blog</generator>
  <id>hatenablog://blog/10328537792363370792</id>

  
    
    
    <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>
