5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【比較】Alpine.js・Reactを使って同じ画面を作ってみた

Last updated at Posted at 2025-10-03

はじめに

Alpine.jsの紹介をしたいが、Alpine.jsのみでは取っつきにくい気がしたので、同じ画面・機能を作ってReact比較してみよう!となったのが成行です。
(Reactタグも付けられるし。。。)

Alpine.jsとは?

Alpine.jsは、小規模アプリ向けの超軽量Javascriptフレームワークで、HTMLに直接Javascriptを記述することでアプリケーションに動的なUIを実装できます。

Alpine・Reactで同じ画面を作成

今回作成した簡単なTODOアプリは、以下で公開してます。
https://github.com/shiranaiHito999/alpine-vite

Alpine.js, React, 関連モジュールの導入などは割愛します。

アプリ画面は以下で、右上のボタンで両フレームワークを使ったページを切り替えられるようにしています。画面レイアウト・データは共通です。

  • アプリ画面(Alpine.js / react)
    スクリーンショット 2025-09-29 145230.png

今回実装した機能

  • TODOアプリとしての基本CRUD操作
  • インライン編集機能
  • フィルタリング機能
  • 完了済み一括削除
  • LocalStorageによるデータ共有

DB,APIなどは用意せず、ローカルストレージを利用してAlpine.js/React間のデータ共有をしています。

ファイル構成

Alpine.js React
HTML index.html react.html
UI index.html FrontPage.tsx, TodoRow.tsx
初期化 main.js main.jsx
状態管理 alpine-store.js TodosContext.tsx
ストレージ連携 alpine-store.js useLocalStorage.ts
型定義 - todo.ts
スタイル styles.css styles.css

各フレームワークの利点を活かせるよう、ファイル構成で意識したこと

  • Alpine.js: 軽量フレームワークらしくシンプルな構成
  • React: 型定義・コンポーネント分割を意識した型安全な構成

React側もjavascript onlyで書くほうが比較として良いのでは?とは思いました。。。

コード比較(HTML・UI)

Alpine.js

index.html
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link rel="icon" type="image/svg+xml" href="/alpinejs-logo-icon.svg" />
    <link rel="stylesheet" href="/common/styles.css" />
    <title>Alpine TODO</title>
  </head>
  <body>
    <h1>Alpine.js TODO</h1>

    <a class="switch-link" href="/react.html">React version</a>

    <!-- フィルタ状態はこの root のローカル state に持たせる -->
    <div
      x-data="{ 
      filter: 'all',
      filters: [
        { label: 'All', value: 'all' },
        { label: 'Active', value: 'active' },
        { label: 'Completed', value: 'completed' }
      ]
    }"
    >
      <div
        class="input-row"
        x-data="{ 
        draft: '', 
        addTodo() { 
          if(this.draft && this.draft.trim()){ 
            $store.todos.add(this.draft.trim()); 
            this.draft = '' 
          } 
        } 
      }"
      >
        <input
          type="text"
          placeholder="新しいTODOを入力して Enter"
          x-model="draft"
          @keyup.enter="addTodo()"
        />
        <button class="input-button" @click="addTodo()">追加</button>
      </div>

      <div class="controls">
        <div class="small">
          表示:
          <template x-for="filterItem in filters" :key="filterItem.value">
            <button
              :class="{ 'font-weight-bold': filter === filterItem.value }"
              @click="filter = filterItem.value"
              x-text="filterItem.label"
            ></button>
          </template>
        </div>
        <div style="margin-left: auto">
          <button
            class="clear-completed"
            @click="$store.todos.clearCompleted()"
          >
            Clear completed
          </button>
        </div>
      </div>

      <div style="margin-top: 1rem">
        <template
          x-for="todo in $store.todos.getFilteredList(filter)"
          :key="todo.id"
        >
          <div class="todo" x-data="todoItem(todo)">
            <input
              type="checkbox"
              :checked="todo.completed"
              @change="$store.todos.setCompleted(todo.id, $event.target.checked)"
            />
            <div class="text">
              <template x-if="!editing">
                <div>
                  <span
                    x-text="todo.text"
                    :class="{ 'completed': todo.completed }"
                  ></span>
                </div>
              </template>

              <template x-if="editing">
                <div style="display: flex; gap: 0.5rem">
                  <input
                    x-ref="input"
                    type="text"
                    x-model="tempText"
                    @keyup.enter="save()"
                    @keyup.escape="cancel()"
                  />
                  <button @click="save()">Save</button>
                  <button @click="cancel()">Cancel</button>
                </div>
              </template>
            </div>

            <button @click="startEdit()">Edit</button>
            <button @click="$store.todos.remove(todo.id)">Delete</button>
          </div>
        </template>

        <div
          x-show="$store.todos.list.length === 0"
          class="small"
          style="margin-top: 0.5rem"
        >
          TODO は空です
        </div>
      </div>
    </div>

    <script type="module" src="/alpine/main.js"></script>
  </body>
</html>

React

メインはAlpine.jsなので気になれば、という感じです。

react.html
react.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link rel="icon" type="image/svg+xml" href="/react-2.svg" />
    <title>Todos — React</title>
  </head>
  <body>
    <!-- 切替リンク:ReactページからAlpineへ移動する -->
    <a class="switch-link" href="/">Alpine version</a>

    <div id="root"></div>

    <script type="module" src="/react/main.tsx"></script>
  </body>
</html>
FrontPage.tsx
FrontPage.tsx
import { useState, useRef, useMemo, useEffect } from "react"
import type { Filter } from "../types/todo"
import TodoRow from "./components/TodoRow"
import { useTodos } from "./context/TodosContext"

const FILTERS: { label: string; value: Filter }[] = [
  { label: "All", value: "all" },
  { label: "Active", value: "active" },
  { label: "Completed", value: "completed" },
]

export default function FrontPage() {
  const { todos, add, update, remove, setCompleted, clearCompleted } =
    useTodos()
  const [draft, setDraft] = useState("")
  const [filter, setFilter] = useState<Filter>("all")
  const inputRef = useRef<HTMLInputElement | null>(null)

  function handleAdd() {
    const t = draft.trim()
    if (!t) return
    add(t)
    setDraft("")
    inputRef.current?.focus()
  }

  const visible = useMemo(() => {
    return todos.filter((t) => {
      if (filter === "all") return true
      if (filter === "active") return !t.completed
      return t.completed
    })
  }, [todos, filter])

  return (
    <div>
      <h1>React TODO</h1>

      <div className="input-row">
        <input
          ref={inputRef}
          type="text"
          placeholder="新しいTODOを入力して Enter"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") handleAdd()
          }}
        />
        <button onClick={handleAdd} className="input-button">
          追加
        </button>
      </div>

      <div className="controls">
        <div className="small">
          表示:
          {FILTERS.map(({ label, value }) => (
            <button
              key={value}
              onClick={() => setFilter(value)}
              style={{ fontWeight: filter === value ? 600 : 400 }}
            >
              {label}
            </button>
          ))}
        </div>
        <div style={{ marginLeft: "auto" }}>
          <button className="clear-completed" onClick={clearCompleted}>
            Clear completed
          </button>
        </div>
      </div>

      <div style={{ marginTop: "1rem" }}>
        {visible.map((todo) => (
          <TodoRow
            key={todo.id}
            todo={todo}
            onToggle={(v) => setCompleted(todo.id, v)}
            onRemove={() => remove(todo.id)}
            onSave={(newText) => update(todo.id, newText)}
          />
        ))}

        {visible.length === 0 && (
          <div className="small" style={{ marginTop: "0.5rem" }}>
            {todos.length === 0 ? "TODO は空です" : "該当するTODOがありません"}
          </div>
        )}
      </div>
    </div>
  )
}
TodoRow.tsx
TodoRow.tsx
import { useEffect, useRef, useState } from "react"
import { Todo } from "../../types/todo"

export default function TodoRow({
  todo,
  onToggle,
  onRemove,
  onSave,
}: {
  todo: Todo
  onToggle: (v: boolean) => void
  onRemove: () => void
  onSave: (t: string) => void
}) {
  const [editing, setEditing] = useState(false)
  const [tempText, setTempText] = useState(todo.text)
  const ref = useRef<HTMLInputElement | null>(null)

  useEffect(() => {
    setTempText(todo.text)
  }, [todo.text])

  useEffect(() => {
    if (editing) ref.current?.focus()
  }, [editing])

  function cancelEdit() {
    setEditing(false)
    setTempText(todo.text)
  }

  function save() {
    const t = tempText.trim()
    if (!t) {
      cancelEdit()
      return
    }
    onSave(t)
    setEditing(false)
  }

  return (
    <div className="todo">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={(e) => onToggle(e.target.checked)}
      />
      <div className="text">
        {!editing ? (
          <span className={todo.completed ? "completed" : ""}>{todo.text}</span>
        ) : (
          <div style={{ display: "flex", gap: "0.5rem" }}>
            <input
              ref={ref}
              type="text"
              value={tempText}
              onChange={(e) => setTempText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") save()
                if (e.key === "Escape") cancelEdit()
              }}
            />
            <button onClick={save}>Save</button>
            <button onClick={cancelEdit}>Cancel</button>
          </div>
        )}
      </div>
      <button onClick={() => setEditing(true)}>Edit</button>
      <button onClick={onRemove}>Delete</button>
    </div>
  )
}

Alpine.jsは、htmlに直接javascriptを記述できるため、値を利用するタグの近くで定義できます。
また、今回はTODOの状態管理を分かりやすく行うために分けていますが、基本的にhtmlファイルだけで完結するのがいい感じでした。

コードとしては以下のように、x-dataとして値・関数を定義して、子要素で利用できます。

Alpine.js
<div
  x-data="{ 
  filter: 'all',
  filters: [
    { label: 'All', value: 'all' },
    { label: 'Active', value: 'active' },
    { label: 'Completed', value: 'completed' }
  ]
}"
>
  <div
    class="input-row"
    x-data="{ 
    draft: '', 
    addTodo() { 
      if(this.draft && this.draft.trim()){ 
        $store.todos.add(this.draft.trim()); 
        this.draft = '' 
      } 
    } 
  }"
 >

React側では、FrontPage.tsxに同等の定義がありますが、利用のためにFrontPagemain.tsxreact.htmlと3ファイル利用して読み込んでフロントに表示しています。

React
React
const FILTERS: { label: string; value: Filter }[] = [
  { label: "All", value: "all" },
  { label: "Active", value: "active" },
  { label: "Completed", value: "completed" },
]

function handleAdd() {
 const t = draft.trim()
 if (!t) return
 add(t)
 setDraft("")
 inputRef.current?.focus()
}

x-dataがAlpine.jsの思想として一番分かりやすいですが、その他にも、x-model,x-text,x-for,x-if(x-show),x-refなどを利用しています。
それぞれ簡単に説明すると、以下です。

  • x-model: 入力要素の値を定義したデータにバインドさせる
  • x-text: 定義したデータをテキストとして返す
  • x-for: リストを反復処理する
  • x-ifx-show): 条件で要素の表示・非表示制御
  • x-ref: DOM要素にアクセスする

まあ大体V-〇〇と一緒です

コード比較(状態管理・ストレージ連携)

Alpine.js

alpine-store.js
alpine-store.js
import Alpine from "alpinejs"
import persist from "@alpinejs/persist"
import { v4 as uuidv4 } from "uuid"

export function createAlpineStore() {
  return {
    // $persistを使用してローカルストレージと自動同期
    // 既存のReact実装と同じキーを使用して互換性を保つ
    list: Alpine.$persist([]).as("alpine_todos_v1"),

    add(text) {
      const trimmedText = text.trim()
      if (!trimmedText) return
      this.list.push({
        id: uuidv4(),
        text: trimmedText,
        completed: false,
      })
    },

    update(id, newText) {
      const item = this.list.find((todo) => todo.id === id)
      if (item) {
        item.text = newText
      }
    },

    remove(id) {
      const index = this.list.findIndex((todo) => todo.id === id)
      if (index >= 0) {
        this.list.splice(index, 1)
      }
    },

    setCompleted(id, value) {
      const item = this.list.find((todo) => todo.id === id)
      if (item) {
        item.completed = !!value
      }
    },

    clearCompleted() {
      this.list = this.list.filter((todo) => !todo.completed)
    },

    get active() {
      return this.list.filter((todo) => !todo.completed)
    },

    get completed() {
      return this.list.filter((todo) => todo.completed)
    },

    // フィルタ状態に応じたリストを返すヘルパー
    getFilteredList(filter) {
      switch (filter) {
        case "active":
          return this.active
        case "completed":
          return this.completed
        default:
          return this.list
      }
    },
  }
}

// Re-usable data
Alpine.data("todoItem", (todo) => {
  return {
    editing: false,
    tempText: todo.text,

    startEdit() {
      this.editing = true
      this.tempText = todo.text
      this.$nextTick(() => {
        if (this.$refs && this.$refs.input) {
          this.$refs.input.focus()
          this.$refs.input.select()
        }
      })
    },

    save() {
      const text = this.tempText.trim()
      if (!text) {
        this.cancel()
        return
      }
      this.$store.todos.update(todo.id, text)
      this.editing = false
    },

    cancel() {
      this.editing = false
      this.tempText = todo.text
    },
  }
})

// Alpine.jsのストアを初期化
export default function initAlpineStore() {
  Alpine.plugin(persist)
  Alpine.store("todos", createAlpineStore())
}

React

useLocalStorage.tsx
useLocalStorage.tsx
import { useState, Dispatch, SetStateAction } from "react"

/**
 * localStorageと同期するカスタムフック
 * @param key - localStorageのキー
 * @param initialValue - 初期値
 * @returns [value, setValue] - 状態と更新関数のタプル
 */
export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue: Dispatch<SetStateAction<T>> = (value) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value
    setStoredValue(valueToStore)
    window.localStorage.setItem(key, JSON.stringify(valueToStore))
  }

  return [storedValue, setValue]
}

TodoContext.tsx
TodoContext.tsx
import { createContext, useContext, useCallback, ReactNode } from "react"
import { useLocalStorage } from "../hooks/useLocalStorage"
import type { Todo } from "../../types/todo"
import { v4 as uuidv4 } from "uuid"

// Alpine.js側と同じローカルストレージキーを使用
const STORAGE_KEY = "alpine_todos_v1"

/**
 * Todosコンテキストの型定義
 */
interface TodosContextValue {
  todos: Todo[]
  add: (text: string) => void
  update: (id: string, newText: string) => void
  remove: (id: string) => void
  setCompleted: (id: string, completed: boolean) => void
  clearCompleted: () => void
}

const TodosContext = createContext<TodosContextValue | undefined>(undefined)

/**
 * Todosプロバイダーコンポーネント
 * アプリケーション全体でTODOの状態を管理します
 */
export function TodosProvider({ children }: { children: ReactNode }) {
  const [todos, setTodos] = useLocalStorage<Todo[]>(STORAGE_KEY, [])

  /**
   * 新しいTODOを追加
   */
  const add = useCallback(
    (text: string) => {
      if (!text || !text.trim()) return

      setTodos((prev) => [
        ...prev,
        {
          id: uuidv4(),
          text: text.trim(),
          completed: false,
        },
      ])
    },
    [setTodos]
  )

  /**
   * TODOのテキストを更新
   */
  const update = useCallback(
    (id: string, newText: string) => {
      setTodos((prev) =>
        prev.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo))
      )
    },
    [setTodos]
  )

  /**
   * TODOを削除
   */
  const remove = useCallback(
    (id: string) => {
      setTodos((prev) => prev.filter((todo) => todo.id !== id))
    },
    [setTodos]
  )

  /**
   * TODOの完了状態を設定
   */
  const setCompleted = useCallback(
    (id: string, completed: boolean) => {
      setTodos((prev) =>
        prev.map((todo) => (todo.id === id ? { ...todo, completed } : todo))
      )
    },
    [setTodos]
  )

  /**
   * 完了済みのTODOをすべて削除
   */
  const clearCompleted = useCallback(() => {
    setTodos((prev) => prev.filter((todo) => !todo.completed))
  }, [setTodos])

  const value: TodosContextValue = {
    todos,
    add,
    update,
    remove,
    setCompleted,
    clearCompleted,
  }

  return <TodosContext.Provider value={value}>{children}</TodosContext.Provider>
}

/**
 * Todosコンテキストを使用するカスタムフック
 * @throws {Error} TodosProvider外で使用された場合
 * @returns TodosContextValue - TODOの状態と操作関数
 */
export function useTodos(): TodosContextValue {
  const context = useContext(TodosContext)
  if (context === undefined) {
    throw new Error("useTodos must be used within a TodosProvider")
  }
  return context
}


状態管理(alpine-store.js)では、Alpine.data,Alpine.storeを利用しています。
それぞれグローバルで利用できるもので、動作は以下になります。

  • Alpine.data: 呼び出しごとに新しいインスタンスを生成する
    →再利用するコンポーネントロジック、コンポーネントごとのローカル状態などに利用するとよい
  • Alpine.store: アプリケーション全体で単一の状態を持つ
    →アプリケーション全体で共有したいデータ、複数コンポーネントから参照・変更するデータに利用するとよい

Alpine.dataは、以下のようにTodoの編集ロジックを一つ一つのTodoに実装するのに利用しています。

Alpine.js
Alpine.data("todoItem", (todo) => {
  return {
    editing: false,
    tempText: todo.text,

    startEdit() {
      this.editing = true
      this.tempText = todo.text
      this.$nextTick(() => {
        if (this.$refs && this.$refs.input) {
          this.$refs.input.focus()
          this.$refs.input.select()
        }
      })
    },

    save() {
      const text = this.tempText.trim()
      if (!text) {
        this.cancel()
        return
      }
      this.$store.todos.update(todo.id, text)
      this.editing = false
    },

    cancel() {
      this.editing = false
      this.tempText = todo.text
    },
  }
})
呼び出し側
index.html
<div class="todo" x-data="todoItem(todo)">
<input
  type="checkbox"
  :checked="todo.completed"
  @change="$store.todos.setCompleted(todo.id, $event.target.checked)"
/>
<div class="text">
  <template x-if="!editing">
    <div>
      <span
        x-text="todo.text"
        :class="{ 'completed': todo.completed }"
      ></span>
    </div>
  </template>

  <template x-if="editing">
    <div style="display: flex; gap: 0.5rem">
      <input
        x-ref="input"
        type="text"
        x-model="tempText"
        @keyup.enter="save()"
        @keyup.escape="cancel()"
      />
      <button @click="save()">Save</button>
      <button @click="cancel()">Cancel</button>
    </div>
  </template>
</div>

<button @click="startEdit()">Edit</button>
<button @click="$store.todos.remove(todo.id)">Delete</button>
</div>

React側では、TodoRow.tsxがコンポーネントロジックとUIを実装しているので、Alpine.data+呼び出しに対応しています。

Alpine.storeは、以下のような記述でアプリケーション全体にTodolistの状態・CRUD操作を共有しています。

Alpine.js
export function createAlpineStore(){
 // Todolistの状態
 // CRUD操作
}

Alpine.store("todos", createAlpineStore())

React側では、TodosContext.tsxで似た動作を実装していますが、こちらはグローバルに登録するのではなく、main.tsxFrontPage.tsxをラップして、ラップした範囲内での状態管理を行う実装(Context)になっています。


最後にローカルスタックとの連携ですが、Alpine.jsではプラグインが用意されており、$persistを利用することで簡単にローカルスタックへ値を連携出来ます。

Alpine.js
list: Alpine.$persist([]).as("alpine_todos_v1")

※React側は、JavaScript標準機能(window.localStorage)を利用しているだけなので割愛します。

所感

業務ではVueをメインで触っているおかげか、Alpine.jsはかなり取っつきやすかったです。Vueのv-○○がx-○○になっているだけで動作は同じ、という機能が多かったです。

今回は、Reactとの比較をしたいのもあり、viteのようなビルドツールと組み合わせましたが、Alpine.js単体でシンプルな構造にするなら、CDN+serve起動にすればインストールすら必要なくて、超軽量感が出そうだなと思いました。

諸々踏まえると、alpine.jsは複雑なロジックを含めないフロントアプリデモなど、既存htmlに簡単なデータ・表示制御を行って画面に動きを付けたい場合には向いているかなと思いました。

まだまだ反転途上のフレームワークですが、ドキュメントの充実や日本語化などが進めば、もう少し盛り上がるかも?と思いました。

(個人的にはビルドツール・UIライブラリとの連携などもしてほしいですが、そしたらもうVueで良くねとなってしまいますね。。。)

参考にした記事

→記事の構成、そもそもの同じ画面作る的な発想などを参考にさせて頂きました。

5
0
0

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?