197
179

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者完全版】0からDockerをフルスタックアプリを開発しながら学べるチュートリアル【React /TypeScript/Hono/docker-compose】

Posted at

image.png

はじめに

エンジニアをやっていると大きな山場のようなものがいくつかあります。

CI/CD / AWS / Docker / Clean Architecture

これらは私がジュニアレベルからミドルレベルに上がる中でも特に大変だったなと思う項目です。これを見ている方も憧れの技術になっているのではないでしょうか?

そんな中でもコンテナの知識は多くのスキルの基盤になっています。
今回はReactでアプリを開発しながらDockerを学べる実践的なチュートリアルを紹介します。

いやいや、Reactやったことないからわからないよ

そういう方もいるかと思います。
このチュートリアルはReactも0から丁寧に解説していきます。

DockerどころかReactがわからなくても最後までできるように設計をしていますので、モダンなスキルを身に着けたいという方には最適なチュートリアルに仕上がっています。

Dockerの難しさは書籍などで学習しても実際に利用するイメージがわかないところにあります。実際に私も最初に書籍をやっていましたが実際のアプリでどうやってデプロイするんだろう?という疑問がありました。

そこで今回は実施のプロジェクトでDockerを利用することでイメージがしやすいように設計しています。

エンジニアとして大きく成長するきっかけになれば嬉しいです。

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください。

対象者

  • Dockerを始めたい人
  • Reactを始めたい人
  • 実践形式で学びたい人
  • モダンなスキルを身に着けたい人

このチュートリアルはHTML/CSS/JavaScriptがわかる方であれば2時間程度でできます。

使用する技術

今回はフロントエンドからバックエンドまでDockerを用いてフルスタックアプリを開発します。

image.png

0. Reactの環境構築

まずはNode.jsの環境を用意しましょう。
Node.jsとはJavaScriptを実行するための開発環境です。JavaScriptをウェブブラウザだけでなく、パソコン上で直接動かせるようにするプログラムのことです。
ReactはJavaScriptのライブラリなので、JavaScriptを実行できる環境が必要です。

インストールは以下のサイトから行えます。

もしわからない方がいたらQiitaでインストール方法を調べるとたくさん記事が出てきますので、ここでは省略します。

インストールが終わったらインストールできているかを確認しましょう

node -v
v22.4.0

ここでエラーがでていなければNode.jsが正しくインストールされて使える状態です。

次にViteを使ってReact環境を構築します。
Viteは最新のフロントエンド開発ツールで、特に高速な開発環境を提供するビルドツールです。Viteを使ってReactを開発する理由は以下の3つです。

image.png

ReactはJavaScriptを誰にも書きやすくしたライブラリなので最後は純粋なJavaScriptに変換しないといけません。そこでViteを利用することでReactを純粋なJavaScriptに翻訳するために利用しています。

環境構築は公式サイトをみれば簡単にできます。

npm create vite@latest
Need to install the following packages:
create-vite@8.0.2
Ok to proceed? (y) y


> npx
> create-vite

│
◇  Project name:
│  todo-docker-app
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with npm and start now?
│  No

cd todo-docker-aop
npm install
npm run dev

サーバーが起動したらhttp://localhost:5173にアクセスしてください

image.png

この画面が表示されたらReactの環境構築ができました

次にスタイリングのためにTailwindCSSを導入します。

TailwindCSSを使うことでクラスを当てるだけでCSSを利用することができます。
ドキュメント通りにインストールしましょう

npm install tailwindcss @tailwindcss/vite

vite.config.tsを修正します

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

src/index.cssに読み込みのインポートを書きます

src/index.css
@import "tailwindcss";

これで設定は完了です。3のスタイリングで実際に設定完了しているかを確認するのでもしスタイルが効いていない場合は見直してみてください。

2. TODOアプリを開発する

まずはTODOアプリを開発していきます。
画面の開発はsrc/App.tsxを修正することで行えます。
まずは「Vite + React」の部分を変更してみましょう

src/App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      {/* 修正 */}
      <h1>こんにちは、世界</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

image.png

文字を変更するとViteが変更を検知して画面に反映してくれます。
それでは最初にTODOをテストデータで用意して一覧で表示してみましょう。
ReactではJavaScriptとHTMLを同時に書くことが可能です。いま書いてあるコードをすべて消して以下に変更します。

src/App.tsx
import "./App.css";

function App() {

  // ここがJavaScriptの部分
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  console.log(todos);

  return (
    <>
      <div>ここがHTMLの部分</div>
    </>
  );
}

export default App;

画面を開いて「右クリック」「検証」からデベロッパーツールを開いて「Console」を見るとTodoが表示されるはずです。

image.png

それではTodoをすべて画面に表示してみましょう
ReactではHTML部分にJavaScriptを簡単に書くことができるのでmapを使って表示します。

src/App.tsx
import "./App.css";

function App() {
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
      </div>
    </>
  );
}

export default App;

image.png

HTMLの中に{ }を書くことでJavaScriptを使うことができます。

        {todos.map((todo) => (
          ここにHTMLを書く
        ))}

todos.mapをすることでそれぞれのtodoに対してHTMLを作ります
最後はHTML(JSX)の配列が描画されます。

        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}

        // [<div key={1}>Todo1</div>, <div key={2}>Todo2</div>, <div key={3}>Todo3</div>]

Reactには「配列の中にJSX要素があったら、それらを順番に並べて表示する」という仕組みがあるのですべてのTodoが表示されるようになります。

次にTodoを追加できるような実装を行います。
まずはインプットフォームとボタンを追加しましょう

src/App.tsx
import "./App.css";

function App() {
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  console.log(todos);

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input type="text" name="title" placeholder="Add a todo" />
        <button type="submit">Add</button>
      </div>
    </>
  );
}

export default App;

HTMLを書いただけなので難しくはないと思います。

image.png

これではTODOの追加はできません。
TODOをステートで管理しましょう

ステート(state)とは、画面の状態を管理するものです。
例えば「現在何個のTODOがあるか」「インプットに何が入力されているか」といった変化する情報を記録しておく場所のことです。普通のJavaScript変数と違って、ステートが変更されるとReactが自動的に画面を更新してくれます。

image.png

image.png

ステートを使って書き換えてみましょう

src/App.tsx
import { useState } from "react";
import "./App.css";

function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Add a todo"
        />
        <button>Add</button>
        <div>{title}</div>
      </div>
    </>
  );
}

export default App;

ここではステートを使うためにuseStateという関数を利用しています。

image.png

Todo以外にも今回はtitleというステートも追加しました

src/App.tsx
  const [title, setTitle] = useState("");

titleの値を画面にも表示するようにHTMLの中に変数titleを{}で書きました

        <div>{title}</div>

実際の画面でインプットフォームに入力をするとインプットフォームの属性であるonChangeイベントハンドラが実行されます。

image.png

          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a todo"
          />

onChangeはフォームの内容が変更されたときに以下の関数を実行します。

(e) => setTitle(e.target.value)

setTilteにe.target.valueつまりインプットフォームに入力された値でステートを更新しています。
実際に画面でタイピングをするとその値が画面に表示されるはずです。これがステートを更新したら画面が更新されるということです。

image.png

同じくTodoも追加したら画面に反映したいのでステートで用意しました。

  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

useState()の中にある配列がステートの初期値となります。
それでは実際に値を追加できるようにボタンをクリックしたら追加する実装をしましょう

import { useState } from "react";
import "./App.css";

function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

  // 追加
  const handleAddTodo = () => {
    setTodos([
      ...todos,
      {
        id: todos.length + 1,
        title: title,
        completed: false,
      },
    ]);
    setTitle("");
  };

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Add a todo"
        />
        {/* 修正 */}
        <button onClick={handleAddTodo}>Add</button>
        <div>{title}</div>
      </div>
    </>
  );
}

export default App;

ボタンが押されるとonClickというイベントハンドラに書かれた関数(handleAddTodo)が実行されます

        <button onClick={handleAddTodo}>Add</button>
  const handleAddTodo = (e: React.FormEvent) => {
    setTodos([
      ...todos,
      {
        id: todos.length + 1,
        title: title,
        completed: false,
      },
    ]);
    setTitle("");
  };

handleAddTodoではすこし特殊な書き方をしていますので細かく解説します。

setTodos([
  ...todos,
  新しいTODO
]);

この...todosの部分でスプレッド演算子を使って配列を展開しています。
例えば

const todos = [
  { id: 1, title: "買い物" },
  { id: 2, title: "掃除" }
];

// スプレッド演算子を使った場合
const newTodos = [...todos, { id: 3, title: "洗濯" }];

// 結果: 
// [
//   { id: 1, title: "買い物" },
//   { id: 2, title: "掃除" },
//   { id: 3, title: "洗濯" }
// ]

このようにすると既存の配列に対して新しい値を追加することができるようになります。
JavaScriptをやったことある人であれば、「なんでこんな書き方するの?」「pushでいいじゃん」そうおもうかもしれません

Reactではステートを直接変更してはいけないというルールがあります。

// 悪い例:元の配列を直接変更
todos.push(新しいTODO);
setTodos(todos);

// 良い例:新しい配列を作成
setTodos([...todos, 新しいTODO]);

直接変更すると、Reactが「配列が変わった」ことに気づかず、画面が更新されないのです。
なのでスプレッド演算子を利用して新しいTodoの配列を作成してその配列でステートを更新しています。

実際に画面から確認すると新しいTodoが追加できるようになっています!

image.png

3. スタイルをあてる

今回はTaliwindCSSを用いてスタイルを当てていきます。
TailwindCSSについては解説をおこなわないので詳しいことを知りたい方は各自調べてみてください
また一部追加で機能があるのでこのあと解説します。

src/App.tsx
import { useState } from "react";

function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

  const handleAddTodo = () => {
    setTodos([
      ...todos,
      {
        id: todos.length + 1,
        title: title,
        completed: false,
      },
    ]);

    setTitle("");
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>
          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

image.png

デザインのためにすこし実装を追加したので確認していきましょう。
まずはTODOの完了を記録しておくためのチェックボックスです。

                  <input 
                    type="checkbox" 
                    checked={todo.completed} 
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />

チェックするかしないかはTodoのステートのcompletedがtrue/falseで判断しています。
またチェックをするとonChangeで関数を実行してチェックしたボタンのidを渡しています。

  const handleToggleTodo = (id: number) => {
    setTodos(todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo))
  }

ここではsetTodosの中で、todosをmapしてidが一致しているtodoのcompletedを反転(trueならfalse)しています。idが一致しなかったらそのまま同じtodoを返しています。
mapすることで新しい配列が作成されるため、先程スプレッド演算子のところで話したルールをパスすることができるので更新ができます。

最後に完了したTodoを表示するようにしました。

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter(todo => todo.completed).length} / {todos.length}
              </p>
            </div>
          )}

完了済みのtodoの数はfilterを利用して配列からcompletedがtrueの値のtodoだけをフィルタリングして配列の要素数を表示することで実現しています。また&&を利用することでtodosがあるときだけ完了済みの部分を表示することができます

4. Docker環境構築

ここからは本題のDockerをやっていきましょう。
まずはDockerをインストールするところから始めます。

Windows/Macを利用している人は以下をインストールしてください

Linuxを利用している人はこちらの手順で行ってください

Dockerがインストールされたかを確認しましょう。

docker -v
Docker version 28.3.3, build 980b856

まずはDockerでこれから何をやるのかの全体感を理解するところから始めます。

Dockerはコンテナ型仮想化技術を使って、アプリケーションを動かすための環境をパッケージ化するツールです。

Reactでアプリを作るまでに私たちは以下の工程を踏んできました。

  1. Node.jsをインストールする
  2. Reactの依存関係をインストール (npm install)
  3. TailwindCSSをインストールする
  4. Reactサーバーを起動する (npm run dev)

誰か別のメンバーがあなたのチームにジョインしてきたとしましょう。
その際に、新しいメンバーは同じ手順を踏む必要があります。「Node.jsのバージョンは?」「npmのバージョンは?」「なんかエラーが出るんだけど...」こんな会話、聞き覚えがありませんか?

image.png

この問題を解決するのがDockerです!

Dockerの中心となる概念がコンテナです。
コンテナは例えるならゲームソフトのようなものです。

ソフト1つに必要な設定(プログラム)がされているので、私たちはゲーム機にソフトをいれて起動すれば誰でも同じゲームを遊ぶことができます。

コンテナもゲームに似ています。
コンテナはゲームソフトのようなもので、Docker(ゲーム機)があれば誰でも起動してアプリ開発ができます。
仮に新メンバーの環境にNode.jsが入っていなくても、Dockerさえあれば開発が可能です。

image.png

実は、環境の違いによる問題は開発メンバー間だけの話ではありません。
最も深刻なのは、開発環境と本番環境の違いです。

おそらく多くの方はWindowsやMacを利用していると思います。
しかし本番環境のサーバーは軽量で高速なLinuxを利用していることが多いです。そのためWindowsやMacの環境では動いていたアプリでも本番環境(Linux)では動かないということは起こりえます。

image.png

Dockerを利用することでそのような環境差異をなくすことができるのがDockerなのです。

5. Dockerfileでイメージを作成しよう

先程コンテナはゲームソフトのようなものと言いましたが、もう少し細かく分けることができます。ゲームソフト自体(ハードウェア)はコンテナで、中に入っているプログラムをDockerの世界ではイメージと呼んでいます。

image.png

ここではコンテナの中で実際に使うイメージをDockerfileというテキストファイルを使って作成していきます。

Dockerfileとは、Dockerイメージをどのように構築するかを定義したレシピのようなものです。アプリケーション実行に必要な全ての手順(OSのベースイメージ選択、必要なパッケージのインストール、設定ファイルのコピーなど)を記述します。

では、Reactアプリケーション用のDockerfileを作成してみましょう

touch Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Dockerfileの中でやることは私たちが実際に環境構築をしてサーバーを起動する手順と変わりません。

まずはコンテナの中にベースイメージを設定します。
イメージを作成するときはネット上(DockerHubというサイトに有志がイメージを公開してくれている)に用意されているイメージを土台に作成することが多いです。ポケモンのゲームソフトを土台に新しいゲームを作るみたいなことができます。すでにあるゲームを土台にすれば基本的なものは揃っているのでイメージ作成が楽になります。

FROM node:20-alpine

今回はNode.js 20のAlpineバージョン(軽量Linux)をベースイメージとして使用します。
FROMコマンドを使うことでベースイメージを利用することが可能です。

次にコンテナの中の/appというディレクトリに移動をしています。
コンテナは例えるなら1つの仮想のPCのようなものです。私たちがcd todo-docker-appしたのと同じようにコンテナの中でディレクトリ移動をします。移動にはWORKDIRコマンドが利用できます。

WORKDIR /app

私たちはnpm installをして依存ライブラリをダウンロードしました。(流れでやっていますが)
npm installをすることでpackage.jsonDependenciesdevDependenciesに書いてあるライブラリをローカルにダウンロードしたのでReact開発ができるようになっていたのです。

image.png

つまりコンテナの中でアプリを起動するにはpackage.jsonをコンテナの中でも用意して、依存ライブラリのインストールコマンドを叩いてあげる必要があります。

COPY . .

このコマンドはローカルのファイルやディレクトリをコンテナ(空の仮想PC)にコピーすることができます。. .とすることで右は移動先、左はコピー元を表します。今回は全てのファイルをコンテナにコピーします。

次に、依存関係をインストールするコマンドを実行します。

RUN npm install

RUNコマンドはDockerイメージ構築時に実行されるコマンドです。ここでは、コピーしたpackage.jsonを元に必要なパッケージをインストールしています。

次にポートの話が出てきますがこれは私たちは環境構築のときに設定をしていない項目です。すこし難しいので丁寧に解説します。

EXPOSE 5173

ポートとは、コンピュータが通信するための「出入口」のようなものです。
React(Vite)アプリケーションは開発サーバーを起動すると、デフォルトで5173番ポートでWebサイトを提供します。

  1. npm run devコマンドを実行
  2. Viteの開発サーバーが起動
  3. 5173番ポートでWebサイトが公開
  4. ブラウザでhttp://localhost:5173にアクセスすることでReactアプリが表示される

これがローカル開発環境でReactアプリを実行するときの流れです。
コンテナ内でのポートDockerコンテナの中でも同じことが起こります。

  1. コンテナ内でnpm run devが実行される(CMDコマンドにより)
  2. コンテナの中でViteの開発サーバーが起動する
  3. コンテナの中の5173番ポートでWebサイトが公開される

EXPOSE 5173は「このコンテナの中では、アプリケーションが5173番ポートを使ってWebサイトを公開します」ということを示しています。私たちはこれまでの開発の中でアプリは5173ポートで起動することを知っていますが、新しく入ってきたメンバーはそれを知りません。そういうときにでもDockerfileにEXPOSEコマンドで書いておけばDockerfileを読めば理解することが可能です。(書かなくても困らないけど書くのが一般的です)

最後にアプリを起動するコマンドを実行します。

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

これまではnpm run devだけでしたが、Dockerコンテナ内で動かす場合には少し違いがあります。

通常、Vite開発サーバーはデフォルトでlocalhost(コンピュータ内部からのみアクセス可能)でリッスンします。しかし、Dockerコンテナの中ではlocalhostはコンテナ自身を指すため、コンテナの外(あなたのPC)からアクセスするためには、サーバーを0.0.0.0(外部からでもアクセスできる特殊なアドレス)で起動する必要があります。

--host 0.0.0.0オプションがその設定をしています。これにより、コンテナの外からもアプリケーションにアクセス可能になります。Dockerではコンテナ同士や他のホストからも通信することもあるのでこの設定をしないといけません

-- の部分は、前のnpm run devコマンドと後の引数を区切るためのもので、npm特有の書き方です。

CMDコマンドを使うことでコンテナが起動したときに実行されるデフォルトのコマンドを指定できます。

ここまででDockerfileの中身を理解できたと思うので実際にDockerfileからイメージを作成してみましょう。

docker build .

[+] Building 9.5s (9/9) FINISHED                                                                        docker:default
 => [internal] load build definition from Dockerfile                                                              0.0s
 => => transferring dockerfile: 164B                                                                              0.0s
 => [internal] load metadata for docker.io/library/node:20-alpine                                                 2.5s
 => [internal] load .dockerignore                                                                                 0.0s
 => => transferring context: 2B                                                                                   0.0s
 => [1/4] FROM docker.io/library/node:20-alpine@sha256:1ab6fc5a31d515dc7b6b25f6acfda2001821f2c2400252b6cb61044bd  0.0s
 => [internal] load build context                                                                                 0.6s
 => => transferring context: 456.60kB                                                                             0.6s
 => CACHED [2/4] WORKDIR /app                                                                                     0.0s
 => [3/4] COPY . .                                                                                                1.9s
 => [4/4] RUN npm install                                                                                         3.0s
 => exporting to image                                                                                            1.3s
 => => exporting layers                                                                                           1.3s
 => => writing image sha256:257956ea4b3d853495ba9e3a5e84d6799db46b55a1509ed7d29b19a927189a0e                      0.0s

このコマンドは「カレントディレクトリにあるDockerfileを使ってイメージを作成してください」という意味です。.はカレントディレクトリを表しています。

イメージができたのでイメージを確認してみましょう

 docker images
REPOSITORY                        TAG         IMAGE ID       CREATED              SIZE
<none>                            <none>      257956ea4b3d   About a minute ago   258MB

イメージができたのはCREATEDの時間で判断できますが、わかりづらいのでイメージに名前をつけてビルドしてみます。

docker build -t tutorial .
docker images

REPOSITORY                        TAG         IMAGE ID       CREATED          SIZE
tutorial                          latest      257956ea4b3d   3 minutes ago    258MB

-t tutorialの部分は「tutorialという名前をつけて」という意味です。これで、イメージに「tutorial」という名前がつきました。

これでゲームソフトが用意できたのであとはDockerで起動するだけです。

docker run tutorial

> todo-docker-app@0.0.0 dev
> vite --host 0.0.0.0

6:41:35 AM [vite] (client) Re-optimizing dependencies because lockfile has changed

  VITE v7.1.10  ready in 602 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: http://172.17.0.2:5173/

これで、tutorialイメージからコンテナが作成され、起動します。

しかし、このままでは問題があります。
Reactアプリは5173ポートで動いていますが、コンテナの外(あなたのPC)からそのポートにアクセスできないのです。

image.png

なぜコンテナ外からアクセスできないのか?

この理由を理解するために、Dockerコンテナの「隔離性」について考えます。
Dockerコンテナは、セキュリティと安定性のために、デフォルトでは外部から完全に隔離されています。これは、コンテナが独自の仮想的なネットワーク環境を持っているためです。

コンテナ内のアプリケーションは5173ポートでリッスンしていますが、このポートはコンテナの内部ネットワーク内だけで有効です。

DockerfileのEXPOSE 5173は単なるドキュメントのようなもので、実際にポートを外部に公開するわけではありません。

コンテナの外(ホストマシン)からアクセスするためには、コンテナのポートとホストのポートを明示的に「マッピング」する必要があります。後ほど解説します。

image.png

コンテナを停止するにはCtrl + cを押すか、コマンドで止めます。

❯ docker ps
CONTAINER ID   IMAGE      COMMAND                   CREATED         STATUS         PORTS      NAMES
645e39b95178   tutorial   "docker-entrypoint.s…"   3 seconds ago   Up 2 seconds   5173/tcp   nice_volhard

docker psで起動しているコンテナが一覧で表示されます。
コンテナにはランダムな名前(NAMES)がついているので名前を指定して止めてみます。(各自で名前は置き換えてください)

docker stop コンテナ名

❯ docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

コンテナを停止することができました。
実は停止しただけでコンテナ自体はDockerの中に残っています。

❯ docker ps -a

CONTAINER ID   IMAGE                       COMMAND                   CREATED          STATUS                            PORTS                                                   NAMES
645e39b95178   tutorial                    "docker-entrypoint.s…"   2 minutes ago    Exited (143) About a minute ago                                                           nice_volhard

docker ps -aとすることで停止しているコンテナを含めて全て表示できます。
コンテナを完全に削除します。

docker rm コンテナ名

それでは先程コンテナ外からアクセスできなかった問題を解決しましょう

docker run --rm --name my-container -p 5173:5173 tutorial

いくつかコマンドのオプションを追加しています。

--rmはコンテナが停止したときに自動的に削除するオプションです。開発中は頻繁にコンテナの起動と停止を繰り返すので、このオプションを付けておくと便利です。これにより、docker stopCtrl+cの後にdocker rmを実行する必要がなくなります。

--name my-containerはコンテナに「my-container」という名前を付けます。これにより、コンテナを参照するときに名前を使えるようになるので、管理が楽になります。先程はランダムな名前だったのですこし手間が省けます。

-p 5173:5173が最も重要なオプションです。
「ホスト(あなたのPC)の5173ポートを、コンテナの5173ポートに接続する」 という意味です。

左側の数字(最初の5173)がホスト側(あなたのPC)のポート
右側の数字(2番目の5173)がコンテナ側のポート

この設定により、ブラウザでhttp://localhost:5173にアクセスすると、Dockerコンテナ内で動作しているReactアプリにアクセスできるようになります。

image.png

-pオプションを使うことで本来は外部アクセスできないところをローカルの5173ポートでアクセスすれば、コンテナの中の5173ポートのアプリにつなげるようになります。

6. APIを開発する

ここからはTODOアプリを管理するバックエンドAPIをHonoを用いて作成します。
まずはプロジェクトを作成しましょう

// todo-docker-appの下で実行
npm create hono@latest

✔ Using target directory … todo-api
✔ Which template do you want to use? nodejs
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? npm
cd todo-api
npm run dev

これでサーバーが起動するのでAPIが叩けるかチェックします。

❯ curl localhost:3000
Hello Hono!

それでは実際にコードを見てみましょう

todo-api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

まずはサーバーの設定からです。

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

サーバーはポート3000番で起動します。なのでCURLコマンドでlocalhost:3000に対してリクエストを送りました。

そして叩いたAPIのエンドポイントは/になります。

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

getメソッドで/を叩くとHello Hono!という文字が返ってくるようになっています。

それではTODOの一覧を返すエンドポイントGET: /todosを同じように書いてみましょう

todo-api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

// 追加
interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const todos: Todo[] = []; // 追加

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

// 追加
app.get("/todos", (c) => {
  return c.json({ todos });
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

エンドポイントとして/todosを追加しました。返すのはjson形式でtodosにしています。

app.get("/todos", (c) => {
  return c.json({ todos });
});

そしてtodosは空の配列にしています。このあと追加のエンドポイントを叩いたときに配列に値が入るようにします。

const todos: Todo[] = [];

ここでTypeScriptの型であるTodo[]が使われています。
TypeScriptはJavaScriptを拡張したプログラミング言語です。最大の特徴は「型」を定義できることで、これによりコードの品質を向上させることができます。

Todoはこのような定義がされています

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}
  • idはnumber型(数値)
  • titleはstring型(文字列)
  • completedはboolean型(真偽値:trueまたはfalse)

を持つことを宣言しています。

const todos: Todo[] = [];

todosに対してTodo[]とすることでtodosにはTodo型の配列が入るということを宣言しています。こうすることでもし仮にtodosの中にTodo以外の形のオブジェクトなどが入る可能性があるときにエディタがエラーを出してくれます。(すこし難しいのでここでは飛ばしても大丈夫です)

それでは実際にAPIを叩いてみましょう

❯ curl localhost:3000/todos
{"todos":[]}

空の配列が帰ってきました。
次は実際にTodoを追加するエンドポイントPost /todosを追加します。

todo-api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const todos: Todo[] = [];

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/todos", (c) => {
  return c.json({ todos });
});

// 追加
app.post("/todos", async (c) => {
  const { title } = await c.req.json();
  const todo: Todo = {
    id: todos.length + 1,
    title,
    completed: false,
  };
  todos.push(todo);
  return c.json({ todo });
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

Post /todosでは追加するtodoのタイトルを含めてリクエストを送っています。
受けとったリクエストからタイトル部分を取得しているコードが以下です。

  const { title } = await c.req.json();

async awaitは非同期関数を表すものです。
この後詳しく解説するので進んでください

そして受け取ったタイトルをもとにオブジェクトを作成して配列に追加します。

  const todo: Todo = {
    id: todos.length + 1,
    title,
    completed: false,
  };
  todos.push(todo);

それでは実際に叩いてみましょう

❯ curl -XPOST http://localhost:3000/todos -H "Content-Type: application/json" -d '{"title":"買い物に行く"}'
{"todo":{"id":1,"title":"買い物に行く","completed":false}}

次にcompletedを更新するエンドポイントPUT /todos/:idを作ります。

todo-api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const todos: Todo[] = [];

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/todos", (c) => {
  return c.json({ todos });
});

app.post("/todos", async (c) => {
  const { title } = await c.req.json();
  const todo: Todo = {
    id: todos.length + 1,
    title,
    completed: false,
  };
  todos.push(todo);
  return c.json({ todo });
});

// 追加
app.put("/todos/:id", async (c) => {
  const { id } = c.req.param();
  const { completed } = await c.req.json();
  const todo = todos.find((todo) => todo.id === Number(id));
  if (!todo) {
    return c.notFound();
  }
  todo.completed = completed;
  return c.json({ todo });
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

今回はエンドポイントにidがあり、リクエストにcompletedの値があるので取り出します。

  const { id } = c.req.param();
  const { completed } = await c.req.json();

そしてidに一致する更新対象のtodoを見つけます。

  const todo = todos.find((todo) => todo.id === Number(id));

もし更新対象のTodoがない場合(存在しないid)は404を返します。

  if (!todo) {
    return c.notFound();
  }

もしTodoがある場合はTodoのcompletedを更新します。

  todo.completed = completed;

またc.jsonは返却するオブジェクトの型がないとエラーになるためインターフェースというものを利用して型情報を付与しています。こうすることでtodosの中にはid,title,completedのオブジェクトしか入らないことを保証することができます。(仮にtitleでなくnameにしたらエディタでエラーがでるのですぐにバグに気づけます)

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const todos: Todo[] = [];

実際に確かめてみましょう
コードを修正するとサーバーが再起動してtodosの配列が空になってしますので登録から行います。

❯ curl -XPOST http://localhost:3000/todos -H "Content-Type: application/json" -d '{"title":"買い物に行く"}'
{"todo":{"id":1,"title":"買い物に行く","completed":false}}
❯ curl -XPUT -H "Content-Type: application/json" -d '{"completed": true}' http://localhost:3000/todos/1
{"todo":{"id":1,"title":"買い物に行く","completed":true}}

更新ができたことが確認できました。
ここまででAPIの準備ができたので次は実際にReactとつなぎこみをしてみましょう

7. クライアントとサーバーをつなぎこむ

まずはAPIをDockerで起動できるようにDockerfileを作成します。

touch Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["npm", "run", "dev"]

APIも同じくnpmを利用しているのでアプリ側と設定は大きくは変わりません。

EXPOSE 3000

CMD ["npm", "run", "dev"]

ポートは3000を利用しているのとViteを利用していないのでサーバー起動はnpm run devで大丈夫です。

それではイメージを作成して起動してみましょう

docker build -t api .

docker run --rm --name my-api -p 3000:3000 api

起動ができたらCURLコマンドを実行してアクセスできるかを確認します。

❯ curl localhost:3000/todos
{"todos":[]}

ちゃんとDockerでAPIを起動して接続することができました。
それではアプリからAPIを利用するように変更してみましょう。

docker-todo-app/src/App.tsx
import { useEffect, useState } from "react";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [title, setTitle] = useState("");

  useEffect(() => {
    fetchTodos();
  }, []);

  const fetchTodos = async () => {
    const response = await fetch("http://localhost:3000/todos");
    const data = await response.json();
    setTodos(data.todos);
  };

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>

          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

まずは画面表示するまえにAPIを叩いてデータを取得してtodosのステートに初期値として設定します。
ここでuseEffectというReactの機能について解説します。

image.png

useEffectは画面表示前に行う処理を扱うための機能です。
今回は画面表示する前にAPIを叩いてTodoを取得しておき初期値として画面表示したいので使っています。

  useEffect(() => {
    fetchTodos();
  }, []);

この書き方は「コンポーネントが最初に表示されたときに一度だけfetchTodos関数を実行する」という意味です。第二引数の[](空の配列)が「一度だけ」を表しています。

useEffectでデータ取得をするのはアンチパターンとされています。
今回は簡単さを優先して利用しています。

そしてfetchTodosでは非同期処理でAPIからデータを取得しています。

  const fetchTodos = async () => {
    const response = await fetch("http://localhost:3000/todos");
    const data = await response.json();
    setTodos(data.todos);
  };

ここで初心者がつまづきやすい非同期処理について詳しく解説していきます。
まずは「同期処理」と「非同期処理」の違いから理解していきましょう。

カレーとサラダを作ることを想像してください

image.png

同期処理は順番に作業をする方法です。カレーを作り終わってからサラダを作成します。
つまりカレーを煮込んでいる間はずっと鍋を見つめているのが「同期処理」です。

それに対しておそらく皆さんがやっているのは「非同期処理」です。
鍋を煮込んでいる間にサラダを作ることで時間の節約をしています。

これをよりプログラミング的に説明すると、自分が作業する主要作業を「メインスレッド」と呼びます。
同期処理ではメインスレッドのみですべての作業を行うため直列的になってしまいます。

しかし、煮込みの時間は別の作業をしたいと考えるので煮込みの作業になったタイミングでメインスレッドから優先度を落として裏側で並列して煮込みを行いつつ、メインスレッドではサラダを作るということをします。

今回はtodoを取得する処理を別スレッドで行うことでアプリがメインスレッドで別の処理ができるように非同期処理で行っています。

const fetchTodos = async () => {
  // 関数の中身
};

asyncを使うことでこの関数が非同期処理を行うことを宣言しています。

    const response = await fetch("http://localhost:3000/todos");
    const data = await response.json();

awaitは「この処理が完了するまで待つ」という指示です。これがないとtodoを取得する前に次の処理に進んでしまうのでレスポンスが返ってくるまで待っています。
叩くURLは私たちが作ったエンドポイントlocalhost:3000/todosになっています。

最後に取得したTodoをステートに保存しています。

    setTodos(data.todos);

また外部APIからデータが返ってくる場合にtodosの中に入る値がid,title,completedのオブジェクトである保証が持てなくなります。(APIでnameと実装されている可能性もある)

TypeScriptではこのようなときに明示的にtodosのデータ構造を指定する必要があります。(そうしないとエディタでエラーになります)

そこでAPIのときと同様にインターフェースを定義しました。

interface Todo = {
  id: number;
  title: string;
  completed: boolean;
};

(省略)

  const [todos, setTodos] = useState<Todo[]>([]);

それでは実際に起動をしてみましょう。
ローカルで起動をしてみます。

npm run dev

実際にhttp://localhost:5173にアクセスしてみます。
すると画面上は問題なさそうに見えますがコンソールをみるとエラーが出ています。

image.png

Access to fetch at 'http://localhost:3000/todos' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

このエラーはオリジン間リソース共有(CORS) というHTTPのセキュリティによって発生しているものです。

API側(localhost:3000)がクライアント側(localhost:5173)のアクセスを拒否しているために起こっています。APIをどんなサイトからも叩かれてしまってはセキュリティ的にまずいのでAPIはアクセスを拒否するという仕組みがあると思ってください。

image.png

ちなみにオリジンとは「プロトコル」+「ホスト名」+「ポート番号」のことを言います。

https://example.com:5173
↑      ↑            ↑
プロトコル  ホスト名      ポート番号

ということで今回はAPI(Hono)にlocalhost:3000からのアクセスは許可するように設定しましょう

todo-api/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors"; // 追加

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const todos: Todo[] = [];

const app = new Hono();

// 追加
app.use(
  cors({
    origin: "http://localhost:5173",
  })
);

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/todos", (c) => {
  return c.json({ todos });
});

app.post("/todos", async (c) => {
  const { title } = await c.req.json();
  const todo: Todo = {
    id: todos.length + 1,
    title,
    completed: false,
  };
  todos.push(todo);
  return c.json({ todo });
});

app.put("/todos/:id", async (c) => {
  const { id } = c.req.param();
  const { completed } = await c.req.json();
  const todo = todos.find((todo) => todo.id === Number(id));
  if (!todo) {
    return c.notFound();
  }
  todo.completed = completed;
  return c.json({ todo });
});

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

それではもう一度APIのコンテナを起動しましょう。
イメージを更新しないとコードが反映されないのでビルドから行います。

// Ctrl+cで止める
docker build -t api .

docker run --rm --name my-api -p 3000:3000 api

アプリをリロードするとエラーが消えたことがわかります。

image.png

APIをCurlで叩いてTodoを追加してみましょう

curl -X POST -H "Content-Type: application/json" -d '{"title":"買い物に行く"}' http://localhost:3000/todos

image.png

アプリをリロードすると初期データが表示されるようになりました!

8. docker-composeで起動する

ここまででクライアントとAPIをDockerで起動することができました。
しかし2つを起動するのは大変であったり、起動コマンドにオプションが多くありました。

// クライアント
docker run --rm --name my-container -p 5173:5173 tutorial

// API
docker run --rm --name my-api -p 3000:3000 api

これらを楽に起動するためにdocker-composeというツールを使います。
docker-composeは複数のコンテナをまとめて管理するためのツールです。

docker-composeはDockerデスクトップを利用していればすでにインストール済みです。
Linuxを利用している人は別途インストールしてください。

では、docker-composeの設定ファイルを作成しましょう。

touch docker-compose.yml // docker-todo-appの下
docker stop my-api // 起動したコンテナはすべて止めておく
docker ps // コンテナが起動してないことを確認
docker-compose.yml
version: '3.8'
services:
  client:
    build: .
    container_name: my-container
    ports:
      - "5173:5173"
    depends_on:
      - api

  api:
    build: ./todo-api
    container_name: api
    ports:
      - "3000:3000"

docker-compose.ymlファイルでは、各サービス(コンテナ)の設定を定義しています。
主要な設定項目を解説します:

version: '3.8'

docker-composeのバージョンを指定しています。

services:

サービス(コンテナ)の定義を始めます。ここではclientapiという2つのサービスを定義しています。

  client:
    build: .

コンテナ名はcontainer_nameでつけられます。

    container_name: my-container

buildでは、Dockerfileがある場所を指定します。(ここではdocker-compose.ymlと同じ階層)
これにより、docker-compose upコマンドを実行したときに自動的にイメージをビルドしてくれます。

    ports:
      - "5173:5173"

ポートマッピングを指定します。これまで-p 5173:5173オプションで行っていたことと同じです。

    depends_on:
      - api

depends_onは依存関係を示します。この設定により、apiサービスが起動した後にclientサービスが起動するようになります。APIが立ち上がっていないとToDoアプリの表示ができないのでAPIが起動したらクライアント側が起動するようにしています。

APIはbuildでtodo-api/Dockerfileを指定しています。
ポートは3000:3000としています。

  api:
    build: ./todo-api
    container_name: api
    ports:
      - "3000:3000"

それでは起動してみましょう

docker compose up

curl -X POST -H "Content-Type: application/json" -d '{"title":"買い物に行く"}' http://localhost:3000/todos
{"todo":{"id":1,"title":"買い物に行く","completed":false}}

http://localhost:5173にアクセスしてアプリに初期データが表示されれば環境構築ができています。

image.png

それでは「Todoアプリ!」というタイトルを変更してみましょう

src/App.tsx
import { useEffect, useState } from "react";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [title, setTitle] = useState("");

  useEffect(() => {
    fetchTodos();
  }, []);

  const fetchTodos = async () => {
    const response = await fetch("http://localhost:3000/todos");
    const data = await response.json();
    setTodos(data.todos);
  };

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            
            📝 Todoアプリ
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>

          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

アプリの名前を変更しました。

          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ
          </h1>

しかしアプリは更新されません。Viteのホットリロードが実行されているはずなのに不思議です。

image.png

これはボリュームマウントという問題が関係しています。
Dockerfileでは、コンテナ作成時に以下のような処理が行われています

FROM node:20-alpine

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

ここで問題になっているのはCOPY . .の部分です。この命令はビルド時にローカルのファイルをコンテナにコピーします。しかし、いったんビルドされた後は、ローカルのファイル変更はコンテナ内には反映されません。

なのでローカルのファイルを変更してもコンテナの中のファイルに変更は反映されていないのです。
これを解決するためにボリュームマウントという機能を使います。これにより、ホスト(あなたのPC)とコンテナの間でディレクトリを共有できるようになります。

つまりローカルのファイルを変更したら、コンテナのファイルも同時に変更されることになります。

docker-compose.ymlを以下のように修正しましょう

docker-compose.yml
version: '3.8'
services:
  client:
    build: .
    container_name: my-container
    ports:
      - "5173:5173"
    depends_on:
      - api
    volumes:
      - .:/app
      - /app/node_modules

  api:
    build: ./todo-api
    container_name: api
    ports:
      - "3000:3000"
    volumes:
      - ./todo-api:/app
      - /app/node_modules

追加した部分を説明します

volumes:
      - .:/app
      - /app/node_modules

.:/appはカレントディレクトリ(. つまり docker-todo-app配下)をコンテナ内の/appディレクトリにマウントします。これにより、ローカルでファイルを変更すると、リアルタイムでコンテナ内にも反映されます。

/app/node_modulesはコンテナ内のnode_modulesディレクトリをそのまま保持します。
これがないと、ホストのnode_modules(存在しない場合も)がマウントされてしまい、依存関係がうまく動作しなくなる可能性があります。(プラクティスという認識で大丈夫です)

// Ctrl + cでdocker-composeを止める

docker compose up

image.png

更新されるようになりました!

発展課題

ここまでの学びを身につけるための課題を用意しました。ぜひ挑戦してみてください。

  1. Todoの追加と更新がまだできていないのでAPIを叩くように直してください
  2. APIのデータを永続化させるためにPostgresqlでDBのコンテナを作成してください
  3. HonoにPrismaを追加してDBでTodoを管理できるようにしてください

おわりに

いかがでしたでしょうか?
Dockerを利用することで安定して起動することができました
コンテナの威力はAWSへのデプロイなどで更に発揮するのでぜひ挑戦してみてください!

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

  • 中嶋様
  • ナツキ様
  • tokec様
  • Kento様
  • 野沢様
  • ARISA様
  • kazu様
  • Fumiya様
197
179
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
197
179

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?