T-CREATOR

Preact Signals チートシート:signal/computed/effect 実用スニペット 30

Preact Signals チートシート:signal/computed/effect 実用スニペット 30

フロントエンド開発において、状態管理は常に悩みの種ですよね。Preact Signals を使えば、驚くほどシンプルに、そして高速にリアクティブな状態管理を実現できます。

本記事では、Preact Signals の 3 つの核となる API(signal、computed、effect)について、すぐに使える実用的なスニペットを 30 個厳選してご紹介します。基本的な使い方から応用パターンまで、実務で即戦力となるコード例を豊富に用意しましたので、ぜひコピー&ペーストして活用してください。

早見表

signal 早見表

#パターン用途キーポイント
1基本的な値の作成プリミティブ値の管理.value でアクセス
2オブジェクトの管理複雑なデータ構造イミュータブルに更新
3配列の管理リスト状態の保持スプレッド構文で更新
4真偽値トグルON/OFF 状態の切り替え! 演算子で反転
5カウンター数値の増減管理+=-= で更新
6フォーム入力値ユーザー入力の保持イベントハンドラで更新
7複数 signal の組み合わせ関連する状態の管理独立した signal を複数作成
8初期値の動的設定環境に応じた初期化関数の戻り値を初期値に
9非同期データAPI 取得結果の保存async/await と組み合わせ
10ネストしたオブジェクト深い階層の状態管理スプレッド構文で部分更新

computed 早見表

#パターン用途キーポイント
11基本的な計算値の変換・加工依存 signal が更新時に自動再計算
12複数 signal の結合複数の状態から導出全依存 signal を自動追跡
13フィルタリング配列の絞り込みfilter メソッド活用
14ソート配列の並び替えsort で動的ソート
15条件分岐状態に応じた値の切り替え三項演算子や if 文
16文字列整形テキストの加工テンプレートリテラル活用
17数値計算合計・平均などの算出reduce で集計
18バリデーション入力値の検証結果真偽値を返す
19マッピング配列の変換map で各要素を変換
20検索・マッチングデータの検索findincludes

effect 早見表

#パターン用途キーポイント
21基本的な副作用console 出力などsignal 変更時に自動実行
22ローカルストレージ保存永続化状態変更を自動で保存
23DOM 操作直接的な DOM 変更document API と連携
24API 呼び出しデータ同期async 関数を内部で実行
25タイマー設定遅延処理setTimeout との組み合わせ
26イベントリスナー登録グローバルイベント監視クリーンアップ関数で解除
27複数 signal の監視複数状態への反応複数の signal を参照
28条件付き実行特定条件時のみ処理if 文で条件判定
29デバウンス処理連続実行の制御タイマーでデバウンス実装
30クリーンアップリソース解放戻り値の関数で後処理

背景

Preact Signals とは

Preact Signals は、Preact チームが開発した軽量で高速なリアクティブ状態管理ライブラリです。React、Preact、そしてバニラ JavaScript でも使用できる汎用性の高さが特徴ですね。

従来の状態管理ライブラリと比較して、Signals は以下の点で優れています。

  • 自動的な依存関係追跡:どのコンポーネントがどの状態に依存しているかを自動で把握します
  • 細粒度の更新:変更された部分だけを効率的に再レンダリングします
  • シンプルな API:signal、computed、effect の 3 つの基本 API で大半のユースケースをカバーできます

以下の図は、Preact Signals の基本的な仕組みを示しています。

mermaidflowchart TB
  sig1["signal<br/>(基本的な状態)"] --> comp["computed<br/>(派生状態)"]
  sig2["signal<br/>(別の状態)"] --> comp
  sig1 -.監視.-> eff["effect<br/>(副作用)"]
  comp --> ui["UI コンポーネント<br/>(自動再レンダリング)"]
  eff --> external["外部システム<br/>(API、LocalStorageなど)"]

図の要点:signal は基本的な状態を保持し、computed は複数の signal から計算された値を生成、effect は状態変更に応じて副作用を実行します。

3 つの核となる API

Preact Signals は、たった 3 つの API で構成されています。

signalは、リアクティブな値を作成するための基本的な API です。値を保持し、その値が変更されたときに依存しているすべての場所に自動的に通知します。

computedは、他の signal から計算される派生値を作成します。依存する signal が変更されると、自動的に再計算されるため、常に最新の値を保持できますね。

effectは、signal の変更に応じて副作用を実行するための API です。API コール、ローカルストレージへの保存、DOM 操作など、外部システムとの連携に使います。

課題

従来の状態管理の問題点

React の useState や Redux などの従来の状態管理には、いくつかの課題がありました。

まず、パフォーマンスの問題が挙げられます。状態が変更されると、関連するコンポーネント全体が再レンダリングされるため、大規模なアプリケーションではパフォーマンスが低下しがちです。

次に、ボイラープレートコードの多さです。Redux のようなライブラリでは、action、reducer、selector など、多くのコードを書く必要がありました。

また、依存関係の管理も複雑でした。useEffect の依存配列を正しく管理するのは意外と難しく、バグの温床になっていましたね。

以下の図は、従来の状態管理における課題を示しています。

mermaidflowchart LR
  state["状態変更"] --> rerender["コンポーネントツリー<br/>全体を再レンダリング"]
  rerender --> perf["パフォーマンス低下"]
  rerender --> complex["依存関係の<br/>手動管理が必要"]
  complex --> bugs["バグの発生"]

図で理解できる要点

  • 状態変更時にコンポーネントツリー全体が再レンダリングされる非効率性
  • 手動での依存関係管理がバグを生みやすい構造

チートシートの必要性

Preact Signals は強力ですが、公式ドキュメントだけでは実務での具体的な使い方がイメージしにくいという声も聞かれます。

特に以下のような場面で、すぐに参照できるチートシートがあると便利です。

  • フォーム入力の管理方法がわからない
  • 配列やオブジェクトの更新パターンを知りたい
  • 非同期データの扱い方に迷う
  • effect のクリーンアップの書き方を確認したい

実用的なスニペット集があれば、コピー&ペーストで素早く実装でき、開発効率が大幅に向上しますね。

解決策

signal/computed/effect の役割分担

Preact Signals の 3 つの API は、それぞれ明確な役割を持っています。適切に使い分けることで、保守性の高いコードを書けます。

signal の使いどころは、ユーザー入力、API 取得結果、アプリケーションの基本的な状態など、「ソースとなる状態」を保持する場面です。

computed の使いどころは、signal から計算される値、フィルタリングされたリスト、バリデーション結果など、「派生する状態」を表現する場面ですね。

effect の使いどころは、ローカルストレージへの保存、分析イベントの送信、DOM 操作など、「外部システムとの連携」が必要な場面です。

以下の図は、3 つの API の関係性と役割分担を示しています。

mermaidflowchart TB
  input["ユーザー入力"] --> sig["signal<br/>(ソースとなる状態)"]
  api["API レスポンス"] --> sig

  sig --> comp1["computed<br/>(フィルタリング)"]
  sig --> comp2["computed<br/>(バリデーション)"]
  comp1 --> comp2

  sig -.変更検知.-> eff1["effect<br/>(LocalStorage保存)"]
  comp2 -.変更検知.-> eff2["effect<br/>(分析イベント送信)"]

  comp1 --> render["レンダリング"]
  comp2 --> render

図で理解できる要点

  • signal は入力ソースから状態を受け取る起点
  • computed は複数の signal や computed を組み合わせて派生値を作成
  • effect は状態変更を監視して外部システムと連携

実用スニペットの活用方法

本記事で紹介する 30 個のスニペットは、以下のように分類しています。

**signal 編(スニペット 1-10)**では、基本的な値の作成から、配列・オブジェクトの管理、フォーム入力、非同期データまで、signal の基本的な使い方を網羅します。

**computed 編(スニペット 11-20)**では、計算、フィルタリング、ソート、バリデーションなど、派生値の作成パターンを豊富に用意しました。

**effect 編(スニペット 21-30)**では、副作用の基本から、永続化、API 呼び出し、クリーンアップまで、実務で頻出するパターンを集めています。

各スニペットは独立しているため、必要な部分だけを抜き出して使えます。コピー&ペーストして、プロジェクトに合わせてカスタマイズしてください。

具体例

signal 編(スニペット 1-10)

ここからは、signal の実用的なスニペットを 10 個ご紹介します。基本的な使い方から、実務でよく使う応用パターンまで順番に見ていきましょう。

スニペット 1:基本的な値の作成

最もシンプルな signal の作成方法です。プリミティブ値(文字列、数値、真偽値など)を管理できます。

typescriptimport { signal } from '@preact/signals';

// 文字列のsignal
const username = signal('田中太郎');

// 数値のsignal
const count = signal(0);

// 真偽値のsignal
const isLoggedIn = signal(false);

signal は .value プロパティで値にアクセスし、値を更新します。

typescript// 値の取得
console.log(username.value); // "田中太郎"

// 値の更新
username.value = '山田花子';
count.value = 5;
isLoggedIn.value = true;

スニペット 2:オブジェクトの管理

オブジェクト型のデータを管理する場合は、イミュータブルに更新するのがポイントです。

typescriptimport { signal } from '@preact/signals';

// ユーザー情報を保持するsignal
const user = signal({
  id: 1,
  name: '田中太郎',
  email: 'tanaka@example.com',
  age: 25,
});

オブジェクトのプロパティを更新する際は、スプレッド構文を使って新しいオブジェクトを作成します。

typescript// プロパティの更新
user.value = {
  ...user.value,
  name: '田中次郎',
  age: 26,
};

// 複数プロパティの同時更新
user.value = {
  ...user.value,
  email: 'tanaka.jiro@example.com',
  age: 27,
};

スニペット 3:配列の管理

配列を管理する場合も、イミュータブルな操作を心がけます。

typescriptimport { signal } from '@preact/signals';

// TODOリストを保持
const todos = signal([
  { id: 1, text: '買い物に行く', done: false },
  { id: 2, text: '掃除をする', done: true },
]);

配列の操作には、スプレッド構文や filter、map などの配列メソッドを活用します。

typescript// 要素の追加
todos.value = [
  ...todos.value,
  { id: 3, text: '洗濯をする', done: false },
];

// 要素の削除(id=2を削除)
todos.value = todos.value.filter((todo) => todo.id !== 2);

// 要素の更新(id=1のdoneをtrueに)
todos.value = todos.value.map((todo) =>
  todo.id === 1 ? { ...todo, done: true } : todo
);

スニペット 4:真偽値トグル

ON/OFF 状態を切り替えるトグル機能は、モーダル表示やダークモードなどで頻繁に使います。

typescriptimport { signal } from '@preact/signals';

// モーダルの表示状態
const isModalOpen = signal(false);

// ダークモードの有効/無効
const isDarkMode = signal(false);

トグル関数を用意すると便利です。

typescript// トグル関数
const toggleModal = () => {
  isModalOpen.value = !isModalOpen.value;
};

const toggleDarkMode = () => {
  isDarkMode.value = !isDarkMode.value;
};

// 使用例
toggleModal(); // false → true
toggleModal(); // true → false

スニペット 5:カウンター

カウンターは数値の増減を管理する基本的なパターンです。ページネーションやいいね数などに使えますね。

typescriptimport { signal } from '@preact/signals';

// カウンター
const counter = signal(0);

// いいね数
const likeCount = signal(42);

増減の操作関数を定義します。

typescript// インクリメント
const increment = () => {
  counter.value += 1;
};

// デクリメント
const decrement = () => {
  counter.value -= 1;
};

// 指定値を加算
const addAmount = (amount: number) => {
  counter.value += amount;
};

// リセット
const reset = () => {
  counter.value = 0;
};

スニペット 6:フォーム入力値

フォーム入力値の管理は、Web アプリケーション開発で最も頻繁に使うパターンの一つです。

typescriptimport { signal } from '@preact/signals';

// フォームの各フィールド
const email = signal('');
const password = signal('');
const agreeToTerms = signal(false);

イベントハンドラで値を更新します。

typescript// テキスト入力のハンドラ
const handleEmailChange = (e: Event) => {
  const target = e.target as HTMLInputElement;
  email.value = target.value;
};

// チェックボックスのハンドラ
const handleTermsChange = (e: Event) => {
  const target = e.target as HTMLInputElement;
  agreeToTerms.value = target.checked;
};

スニペット 7:複数 signal の組み合わせ

関連する状態を複数の signal で管理すると、柔軟性が高まります。

typescriptimport { signal } from '@preact/signals';

// ページネーション関連の状態
const currentPage = signal(1);
const pageSize = signal(10);
const totalItems = signal(0);
const isLoading = signal(false);

各 signal を独立して更新できるため、状態管理がシンプルになりますね。

typescript// ページ変更
const goToPage = (page: number) => {
  currentPage.value = page;
  isLoading.value = true;
  // データ取得処理...
};

// ページサイズ変更
const changePageSize = (size: number) => {
  pageSize.value = size;
  currentPage.value = 1; // 1ページ目にリセット
};

スニペット 8:初期値の動的設定

環境変数や設定ファイルから初期値を設定する場合は、関数の戻り値を使います。

typescriptimport { signal } from '@preact/signals';

// ローカルストレージから取得
const theme = signal(
  localStorage.getItem('theme') || 'light'
);

// 環境変数から取得
const apiEndpoint = signal(
  process.env.API_ENDPOINT || 'http://localhost:3000'
);

初期値を計算する関数を作成することもできます。

typescript// デバイスの画面幅に応じた初期値
const getInitialView = () => {
  return window.innerWidth < 768 ? 'mobile' : 'desktop';
};

const viewMode = signal(getInitialView());

スニペット 9:非同期データ

API 取得結果などの非同期データを保持する場合は、初期値に null や空配列を設定します。

typescriptimport { signal } from '@preact/signals';

// ユーザーデータ(初期値null)
const userData = signal(null);

// 投稿リスト(初期値空配列)
const posts = signal([]);

// ローディング状態も合わせて管理
const isLoading = signal(false);
const error = signal(null);

非同期関数でデータを取得し、signal を更新します。

typescript// データ取得関数
const fetchUserData = async (userId: string) => {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    userData.value = data;
  } catch (e) {
    error.value = e.message;
  } finally {
    isLoading.value = false;
  }
};

スニペット 10:ネストしたオブジェクト

深い階層を持つオブジェクトも、スプレッド構文で部分的に更新できます。

typescriptimport { signal } from '@preact/signals';

// ネストした設定オブジェクト
const settings = signal({
  user: {
    profile: {
      name: '田中太郎',
      avatar: '/avatar.jpg',
    },
    preferences: {
      language: 'ja',
      notifications: true,
    },
  },
  app: {
    theme: 'light',
    fontSize: 14,
  },
});

深い階層のプロパティを更新する際は、各レベルでスプレッド構文を使います。

typescript// user.profile.nameを更新
settings.value = {
  ...settings.value,
  user: {
    ...settings.value.user,
    profile: {
      ...settings.value.user.profile,
      name: '山田花子',
    },
  },
};

// app.themeを更新
settings.value = {
  ...settings.value,
  app: {
    ...settings.value.app,
    theme: 'dark',
  },
};

computed 編(スニペット 11-20)

次に、computed の実用的なスニペットを 10 個ご紹介します。派生値の作成パターンを習得すると、コードがぐっとシンプルになりますよ。

スニペット 11:基本的な計算

computed は、signal から計算される値を自動的に更新します。

typescriptimport { signal, computed } from '@preact/signals';

// 商品の単価と数量
const price = signal(1000);
const quantity = signal(3);

// 合計金額を計算
const totalPrice = computed(() => {
  return price.value * quantity.value;
});

依存する signal が変更されると、computed も自動的に再計算されます。

typescriptconsole.log(totalPrice.value); // 3000

quantity.value = 5;
console.log(totalPrice.value); // 5000(自動更新)

price.value = 1200;
console.log(totalPrice.value); // 6000(自動更新)

スニペット 12:複数 signal の結合

複数の signal を組み合わせて、より複雑な計算を行えます。

typescriptimport { signal, computed } from '@preact/signals';

// ユーザー情報
const firstName = signal('太郎');
const lastName = signal('田中');
const age = signal(25);
const country = signal('日本');

複数の signal から一つの値を導出します。

typescript// フルネームを生成
const fullName = computed(() => {
  return `${lastName.value} ${firstName.value}`;
});

// プロフィール文を生成
const profile = computed(() => {
  return `${fullName.value}さん(${age.value}歳、${country.value})`;
});

console.log(profile.value);
// "田中 太郎さん(25歳、日本)"

スニペット 13:フィルタリング

配列をフィルタリングして、条件に合う要素だけを抽出できます。

typescriptimport { signal, computed } from '@preact/signals';

// タスクリスト
const tasks = signal([
  { id: 1, title: '買い物', done: false, priority: 'high' },
  { id: 2, title: '掃除', done: true, priority: 'low' },
  { id: 3, title: '洗濯', done: false, priority: 'high' },
  { id: 4, title: '料理', done: false, priority: 'medium' },
]);

様々な条件でフィルタリングできます。

typescript// 未完了タスクのみ
const incompleteTasks = computed(() => {
  return tasks.value.filter((task) => !task.done);
});

// 優先度が高いタスクのみ
const highPriorityTasks = computed(() => {
  return tasks.value.filter(
    (task) => task.priority === 'high'
  );
});

// 未完了かつ優先度が高いタスク
const urgentTasks = computed(() => {
  return tasks.value.filter(
    (task) => !task.done && task.priority === 'high'
  );
});

スニペット 14:ソート

配列を動的にソートして、並び替えた結果を取得できます。

typescriptimport { signal, computed } from '@preact/signals';

// 製品リスト
const products = signal([
  { id: 1, name: 'ノートPC', price: 120000 },
  { id: 2, name: 'マウス', price: 3000 },
  { id: 3, name: 'キーボード', price: 15000 },
]);

// ソート順
const sortOrder = signal('asc');

ソート順に応じて、動的に並び替えます。

typescript// 価格順にソート
const sortedProducts = computed(() => {
  const sorted = [...products.value].sort((a, b) => {
    if (sortOrder.value === 'asc') {
      return a.price - b.price;
    } else {
      return b.price - a.price;
    }
  });
  return sorted;
});

// ソート順を変更すると自動的に再ソート
sortOrder.value = 'desc';

スニペット 15:条件分岐

状態に応じて異なる値を返すことができます。

typescriptimport { signal, computed } from '@preact/signals';

// ユーザーの年齢とステータス
const userAge = signal(17);
const isPremium = signal(false);

複雑な条件分岐も簡潔に表現できますね。

typescript// 利用可能な機能を判定
const availableFeatures = computed(() => {
  if (userAge.value < 18) {
    return ['基本機能'];
  }

  if (isPremium.value) {
    return [
      '基本機能',
      '高度な分析',
      '優先サポート',
      'API アクセス',
    ];
  }

  return ['基本機能', '高度な分析'];
});

// 表示メッセージ
const welcomeMessage = computed(() => {
  return isPremium.value
    ? 'プレミアム会員様、ようこそ!'
    : '無料会員としてご利用いただけます';
});

スニペット 16:文字列整形

テキストの加工や整形も、computed で簡単に実現できます。

typescriptimport { signal, computed } from '@preact/signals';

// ユーザー入力
const searchQuery = signal(' Hello World ');
const email = signal('USER@EXAMPLE.COM');

様々な文字列操作を自動化できます。

typescript// トリム&小文字化
const normalizedQuery = computed(() => {
  return searchQuery.value.trim().toLowerCase();
});

// メールアドレスを小文字化
const normalizedEmail = computed(() => {
  return email.value.toLowerCase();
});

// 文字数をカウント
const queryLength = computed(() => {
  return normalizedQuery.value.length;
});

// バリデーションメッセージ
const searchMessage = computed(() => {
  const len = queryLength.value;
  if (len === 0) return '検索ワードを入力してください';
  if (len < 2) return '2文字以上入力してください';
  return `"${normalizedQuery.value}" で検索`;
});

スニペット 17:数値計算

配列データから合計や平均を計算する場合に便利です。

typescriptimport { signal, computed } from '@preact/signals';

// カート内の商品
const cartItems = signal([
  { name: '商品A', price: 1000, quantity: 2 },
  { name: '商品B', price: 1500, quantity: 1 },
  { name: '商品C', price: 800, quantity: 3 },
]);

reduce メソッドで集計処理を行います。

typescript// カート内の合計金額
const cartTotal = computed(() => {
  return cartItems.value.reduce((total, item) => {
    return total + item.price * item.quantity;
  }, 0);
});

// 商品点数
const itemCount = computed(() => {
  return cartItems.value.reduce((count, item) => {
    return count + item.quantity;
  }, 0);
});

// 平均単価
const averagePrice = computed(() => {
  if (cartItems.value.length === 0) return 0;
  const total = cartItems.value.reduce((sum, item) => {
    return sum + item.price;
  }, 0);
  return Math.round(total / cartItems.value.length);
});

スニペット 18:バリデーション

入力値の検証結果を computed で管理すると、リアルタイムなフィードバックを実現できます。

typescriptimport { signal, computed } from '@preact/signals';

// フォーム入力値
const username = signal('');
const password = signal('');
const email = signal('');

各フィールドのバリデーション結果を計算します。

typescript// ユーザー名の検証
const isUsernameValid = computed(() => {
  const name = username.value;
  return name.length >= 3 && name.length <= 20;
});

// パスワードの検証
const isPasswordValid = computed(() => {
  return password.value.length >= 8;
});

// メールアドレスの検証
const isEmailValid = computed(() => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email.value);
});

// フォーム全体の検証
const isFormValid = computed(() => {
  return (
    isUsernameValid.value &&
    isPasswordValid.value &&
    isEmailValid.value
  );
});

スニペット 19:マッピング

配列の各要素を変換して、新しい配列を生成できます。

typescriptimport { signal, computed } from '@preact/signals';

// ユーザーリスト
const users = signal([
  { id: 1, firstName: '太郎', lastName: '田中', age: 25 },
  { id: 2, firstName: '花子', lastName: '山田', age: 30 },
  { id: 3, firstName: '次郎', lastName: '佐藤', age: 28 },
]);

map メソッドでデータを変換します。

typescript// フルネームのリスト
const fullNames = computed(() => {
  return users.value.map((user) => {
    return `${user.lastName} ${user.firstName}`;
  });
});

// 表示用のラベルリスト
const userLabels = computed(() => {
  return users.value.map((user) => {
    return `${user.lastName} ${user.firstName}${user.age}歳)`;
  });
});

// IDと名前のマップ
const userMap = computed(() => {
  return users.value.map((user) => ({
    id: user.id,
    displayName: `${user.lastName} ${user.firstName}`,
  }));
});

スニペット 20:検索・マッチング

配列から特定の要素を検索したり、条件に合うかチェックしたりできます。

typescriptimport { signal, computed } from '@preact/signals';

// 商品リスト
const products = signal([
  {
    id: 1,
    name: 'ノートPC',
    category: '電子機器',
    inStock: true,
  },
  {
    id: 2,
    name: 'マウス',
    category: '周辺機器',
    inStock: true,
  },
  {
    id: 3,
    name: 'キーボード',
    category: '周辺機器',
    inStock: false,
  },
]);

// 検索クエリ
const searchTerm = signal('');
const selectedCategory = signal('');

様々な検索パターンを実装できます。

typescript// 検索結果
const searchResults = computed(() => {
  let results = products.value;

  // テキスト検索
  if (searchTerm.value) {
    const term = searchTerm.value.toLowerCase();
    results = results.filter((p) =>
      p.name.toLowerCase().includes(term)
    );
  }

  // カテゴリフィルター
  if (selectedCategory.value) {
    results = results.filter(
      (p) => p.category === selectedCategory.value
    );
  }

  return results;
});

// 在庫ありの商品が存在するか
const hasInStockProducts = computed(() => {
  return searchResults.value.some((p) => p.inStock);
});

// 特定IDの商品を検索
const selectedId = signal(2);
const selectedProduct = computed(() => {
  return products.value.find(
    (p) => p.id === selectedId.value
  );
});

effect 編(スニペット 21-30)

最後に、effect の実用的なスニペットを 10 個ご紹介します。副作用の適切な管理は、アプリケーションの品質を大きく左右しますよ。

スニペット 21:基本的な副作用

effect は、signal の値が変更されたときに自動的に実行されます。

typescriptimport { signal, effect } from '@preact/signals';

// カウンター
const count = signal(0);

console.log などのシンプルな副作用から始めましょう。

typescript// カウントが変更されたらログ出力
effect(() => {
  console.log(`現在のカウント: ${count.value}`);
});

// カウントを変更すると自動的にログ出力される
count.value = 1; // コンソール: "現在のカウント: 1"
count.value = 2; // コンソール: "現在のカウント: 2"

スニペット 22:ローカルストレージ保存

状態をローカルストレージに自動保存すると、永続化を簡単に実現できます。

typescriptimport { signal, effect } from '@preact/signals';

// テーマ設定
const theme = signal(
  localStorage.getItem('theme') || 'light'
);

// ユーザー設定
const userPreferences = signal(
  JSON.parse(localStorage.getItem('preferences') || '{}')
);

effect で自動保存を実装します。

typescript// テーマ変更を自動保存
effect(() => {
  localStorage.setItem('theme', theme.value);
});

// ユーザー設定を自動保存
effect(() => {
  localStorage.setItem(
    'preferences',
    JSON.stringify(userPreferences.value)
  );
});

// これで値を変更するだけで自動的に保存される
theme.value = 'dark'; // 自動的にlocalStorageに保存

スニペット 23:DOM 操作

DOM を直接操作する必要がある場合にも、effect が活躍します。

typescriptimport { signal, effect } from '@preact/signals';

// ページタイトル
const pageTitle = signal('ホーム');

// 通知数
const notificationCount = signal(0);

DOM API と組み合わせて使います。

typescript// ページタイトルを自動更新
effect(() => {
  document.title = pageTitle.value;
});

// 通知数をfaviconに表示(バッジ機能)
effect(() => {
  const count = notificationCount.value;
  if (count > 0) {
    document.title = `(${count}) ${pageTitle.value}`;
  } else {
    document.title = pageTitle.value;
  }
});

スニペット 24:API 呼び出し

状態変更に応じて API を呼び出す場合も、effect が便利です。

typescriptimport { signal, effect } from '@preact/signals';

// 検索クエリ
const searchQuery = signal('');

// 検索結果
const searchResults = signal([]);
const isSearching = signal(false);

非同期処理を effect 内で実行します。

typescript// 検索クエリ変更時にAPI呼び出し
effect(() => {
  const query = searchQuery.value;

  // 空の場合は何もしない
  if (!query) {
    searchResults.value = [];
    return;
  }

  // API呼び出し
  const fetchData = async () => {
    isSearching.value = true;
    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`
      );
      const data = await response.json();
      searchResults.value = data;
    } catch (error) {
      console.error('検索エラー:', error);
    } finally {
      isSearching.value = false;
    }
  };

  fetchData();
});

スニペット 25:タイマー設定

遅延処理や定期実行も、effect で管理できます。

typescriptimport { signal, effect } from '@preact/signals';

// 自動保存のタイミング
const autoSaveContent = signal('');
const lastSaved = signal(null);

setTimeout や setInterval と組み合わせます。

typescript// 内容変更から3秒後に自動保存
effect(() => {
  const content = autoSaveContent.value;

  const timerId = setTimeout(() => {
    // 保存処理
    console.log('自動保存:', content);
    lastSaved.value = new Date();
  }, 3000);

  // クリーンアップ:タイマーをクリア
  return () => clearTimeout(timerId);
});

スニペット 26:イベントリスナー登録

グローバルなイベントリスナーの登録・解除も、effect で安全に管理できます。

typescriptimport { signal, effect } from '@preact/signals';

// モーダルの開閉状態
const isModalOpen = signal(false);

// ウィンドウサイズ
const windowWidth = signal(window.innerWidth);

イベントリスナーを登録し、クリーンアップ関数で解除します。

typescript// ESCキーでモーダルを閉じる
effect(() => {
  if (!isModalOpen.value) return;

  const handleEscape = (e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      isModalOpen.value = false;
    }
  };

  window.addEventListener('keydown', handleEscape);

  // クリーンアップ:リスナーを解除
  return () => {
    window.removeEventListener('keydown', handleEscape);
  };
});

// ウィンドウサイズの監視
effect(() => {
  const handleResize = () => {
    windowWidth.value = window.innerWidth;
  };

  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
});

スニペット 27:複数 signal の監視

複数の signal を同時に監視して、副作用を実行できます。

typescriptimport { signal, effect } from '@preact/signals';

// ユーザーIDと表示言語
const userId = signal(null);
const language = signal('ja');

// データ
const userData = signal(null);

複数の signal を参照すると、いずれかが変更された時に effect が実行されます。

typescript// ユーザーIDまたは言語が変更されたらデータ取得
effect(() => {
  const id = userId.value;
  const lang = language.value;

  if (!id) {
    userData.value = null;
    return;
  }

  // APIから言語に応じたユーザーデータを取得
  fetch(`/api/users/${id}?lang=${lang}`)
    .then((res) => res.json())
    .then((data) => {
      userData.value = data;
    });
});

スニペット 28:条件付き実行

特定の条件下でのみ副作用を実行したい場合は、if 文で制御します。

typescriptimport { signal, effect } from '@preact/signals';

// ログイン状態とユーザー行動
const isLoggedIn = signal(false);
const userAction = signal('');

条件分岐で実行を制御できます。

typescript// ログイン中のみ、行動ログを送信
effect(() => {
  const action = userAction.value;

  // ログインしていない場合は何もしない
  if (!isLoggedIn.value) return;

  // 空の場合も何もしない
  if (!action) return;

  // 分析イベントを送信
  fetch('/api/analytics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ action, timestamp: Date.now() }),
  });
});

スニペット 29:デバウンス処理

連続した変更を制御し、一定時間経過後に一度だけ実行するデバウンス処理を実装できます。

typescriptimport { signal, effect } from '@preact/signals';

// リアルタイム検索入力
const searchInput = signal('');
const debouncedSearch = signal('');

タイマーを使ってデバウンスを実現します。

typescript// 入力から500ms後に検索実行
effect(() => {
  const input = searchInput.value;

  // 500msのデバウンス
  const timerId = setTimeout(() => {
    debouncedSearch.value = input;
  }, 500);

  // 次の入力があったらタイマーをクリア
  return () => clearTimeout(timerId);
});

// debouncedSearchの変更で実際の検索を実行
effect(() => {
  const query = debouncedSearch.value;

  if (!query) return;

  console.log('検索実行:', query);
  // 実際の検索処理...
});

スニペット 30:クリーンアップ

effect の戻り値として関数を返すと、次回の実行前やコンポーネントのアンマウント時に実行されます。

typescriptimport { signal, effect } from '@preact/signals';

// リアルタイム更新の有効/無効
const enableRealtime = signal(false);
const realtimeData = signal([]);

クリーンアップ関数でリソースを適切に解放します。

typescript// リアルタイム更新の購読と解除
effect(() => {
  if (!enableRealtime.value) return;

  console.log('リアルタイム更新を開始');

  // WebSocket接続を確立
  const ws = new WebSocket(
    'wss://api.example.com/realtime'
  );

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    realtimeData.value = [...realtimeData.value, data];
  };

  // クリーンアップ:WebSocket接続を閉じる
  return () => {
    console.log('リアルタイム更新を停止');
    ws.close();
  };
});

// enableRealtimeをfalseにすると、自動的にWebSocketが閉じられる

タイマーやイベントリスナーなど、リソースを使う副作用では必ずクリーンアップ関数を実装しましょう。メモリリークを防げますよ。

まとめ

Preact Signals の 3 つの基本 API(signal、computed、effect)を使いこなせば、驚くほどシンプルに状態管理を実装できます。

本記事でご紹介した 30 個のスニペットは、実務で頻繁に使うパターンを厳選しました。基本的な値の管理から、配列・オブジェクトの操作、フィルタリング、バリデーション、非同期処理、副作用の管理まで、幅広いユースケースをカバーしています。

signal は「ソースとなる状態」を保持し、computed は「派生する状態」を自動計算し、effect は「外部システムとの連携」を担当するという役割分担を意識すると、保守性の高いコードが書けるでしょう。

各スニペットは独立しているため、必要な部分だけを抜き出して使えます。ぜひコピー&ペーストして、あなたのプロジェクトに活用してください。

Preact Signals のシンプルさと高速性を体感して、より良いユーザー体験を提供していきましょう。

関連リンク