👋

Web制作に最適!Astro × Nano Storesで学ぶ状態管理の基本

に公開

この記事を書こうと思った理由

  • 状態管理について理解が浅く、自分自身の学習のために Nano Stores を使ってみたかったため。
  • Astro を使っている同じような立場の、コーダー・エンジニアの参考になればと思ったため。
  • 普段の開発で React などのフレームワークを使わず、JS/TS 単体で実装している人向けに情報を共有したかったため。

ゴール

  • Nano Stores を使って、状態管理を体験してみる
  • 状態管理の基本的な考え方を少しでも理解する

対象者

  • 「状態管理って何?」というレベルの方
  • JavaScript/TypeScript 単体で Web 制作をしている方
  • React や Vue などのフレームワークを使わずに開発している方

状態管理のメリット

最初は状態管理?のレベルでしたが、まずメリットからお伝えします。
まだまだ勉強中の身ですが、以下のメリットがありました。

  • コードの保守性・見通しが上がる(コードが複雑化し、この処理どこで何してたんだっけ。を防げる)
  • 同じ要素を別ファイルから、操作したい時の管理が楽になる
    例えば、動画の再生を「ボタンのクリック」と「スクロールの監視」で制御したいとき。
    状態管理を使えば、DOM の取得や監視の処理を毎回書かなくても済みます。

状態の更新・DOM の取得・UI の更新を、別ファイルで管理できたので
ファイル数は増えましたが、「このファイルは何をしているか」 が明確になりました。

状態管理って何?

状態管理は、「React とかを使っているエンジニアのためのもの」と思っていました、、!
でも、サイト制作にも使えます。

まず「状態」とは何かを考えてみます。

Web サイト制作でよくある「状態」は下記があります。

  • 動画が再生中かどうか(true / false
  • モーダルが開いているか(open / closed
  • メニューが展開中か(active / inactive など)
    上記のような「状態」は遭遇した事がある方も多いと思います。
    このような、「状態」を管理する事で先ほどのメリット(保守性・可読性の向上など)が生まれる。という結果になります。

Nano Stores って何?

  • 状態管理のための軽量なライブラリ
  • 小さくて高速
  • フレームワーク非依存で使える(Astro や素の JS でも使える)
    になります。

React や Vue などのフレームワークは使用せず、シンプルに状態管理を導入することができます

結局どんなことができるの?

文章だとイメージしづらいので、まずは簡単なデモを紹介します。

Nano Stores を Astro で使ってみる

今回は、動画の再生・停止の状態を管理し、クリックや、スクロールで再生・停止を操作 します。
添付動画にて、イメージを確認してみてください。

ちょっと分かりづらくすみませんが、
スクロールと、ボタンのクリックによって動画の再生・停止が行われている のが分かると思います。

イメージができたら、astro で Nano Stores をインストールします。

Nano Stores のインストール

npm install nanostores

Nano Stores の npmはこちら

html

特に変わったことは何もしていません。
js で取得する要素には data 属性を付与しています。
動画の管理は、id で行うので、data-video-id を付与しています。

<div data-container>
  <p class="scroll">スクロールしてください。</p>
  <div class="inner" data-video-targets>
    <button type="button" data-video-button>停止</button>

    <video loop muted data-video data-video-id="video1">
      <source src="/src/assets/video/sakura_movie.mp4" type="video/mp4" />
    </video>
  </div>
</div>

<style>
  * {
    margin: 0;
    padding: 0;
  }

  video {
    width: 100%;
    height: auto;
  }

  .inner {
    margin: 1000px 0;
  }

  .scroll {
    text-align: center;
    margin: 40px 0;
  }
</style>

typescript

/assets/scripts/video-state/
├─ click-video-event.ts        // クリックイベント処理
├─ handle-video-intersect.ts   // スクロール監視処理
├─ stores.ts                   // 状態の定義・更新
├─ elements.ts                     // セレクタ・DOM 取得
├─ video-operations.ts         // 再生・停止関数
├─ watch-video-state.ts        // 状態の監視と反映
└─ index.ts                    // VideoState クラス

各ファイルの責務を明確に分けることで、後から保守・拡張しやすい構成にしています。

各ファイルを確認していきます。

stores.ts 状態の定義・更新

import { atom } from 'nanostores';

// 動画の再生状態を保存しておく nanostoreを作成
export const $isVideoPlaying = atom<Record<string, boolean>>({});
export const buttonTextStore = atom<Record<string, string>>({});

// 動画の再生状態を保存
export const playVideo = (id: string) => {
  $isVideoPlaying.set({ ...$isVideoPlaying.get(), [id]: true });
};

// 動画の停止状態を保存
export const pauseVideo = (id: string) => {
  $isVideoPlaying.set({ ...$isVideoPlaying.get(), [id]: false });
};

// 動画の状態をトグルする
export const toggleVideoState = (id: string) => {
  const current = $isVideoPlaying.get();
  const currentState = current[id] ?? false;
  $isVideoPlaying.set({ ...current, [id]: !currentState });

  // ボタンテキストを更新
  buttonTextStore.set({ ...buttonTextStore.get(), [id]: currentState ? '再生' : '停止' });
};

Nano Stores を使って状態を保存しておきます。
まず、import { atom } from 'nanostores'atomをインポートします。
Nano Stores ではatomを使って状態を管理する。と覚えると良いと思います。
$を使った変数名は見慣れませんでしたが、 boolean 系の store を表すのに多く使われるようです。
ボタンのテキストは、booleanではなくstringになるため、buttonTextStoreと命名しています。

playVideo
$isVideoPlaying.set({ ...$isVideoPlaying.get(), [id]: true })
こちらは、$isVideoPlaying.get()で現在の状態を取得 [id]: trueidの値をtrueに変更します。
今回はdata-video-idから取得し管理しています。
$isVideoPlaying.set(...)
新しく作った状態を store に保存します。

pauseVideo
playVideoに同じ。

toggleVideoState
current[id] で該当の video の状態(再生中かどうか)を取得。
?? falseは、もし current[id]undefined の場合、false を設定します。基本的には取得した値を設定します。
$isVideoPlaying.set({ ...current, [id]: !currentState })で現在の状態を調べ、
trueだったらfalseに、falseだったらtrueにして保存する。という感じになります。

elements.ts セレクタ・DOM 取得

// elements.ts

export const selectors = {
  container: 'data-container',
  targets: 'data-video-targets',
  video: 'data-video',
  videoId: 'data-video-id',
  videoButton: 'data-video-button',
};

export const elements = {
  container: document.querySelector<HTMLDivElement>(`[${selectors.container}]`),
  targets: document.querySelectorAll<HTMLDivElement>(`[${selectors.targets}]`),
  videos: document.querySelectorAll<HTMLVideoElement>(`[${selectors.video}]`),
};

こちらは、各ファイルで使用する要素の取得だけをまとめたファイルになります。

click-video-event.ts handle-video-intersect.ts イベントの処理

// click-video-event.ts

import { toggleVideoState } from '@assets/scripts/video-state/stores';
import { elements, selectors } from '@assets/scripts/video-state/elements';

// clickイベント
export const clickVideoEvent = (): void => {
  if (!elements.container) return;
  const playButton = elements.container.querySelector<HTMLButtonElement>(`[${selectors.videoButton}]`);
  if (!playButton) return;

  const video = elements.container.querySelector<HTMLVideoElement>(`[${selectors.video}]`);
  if (!video) return;

  const videoId = video.getAttribute(selectors.videoId);
  if (!videoId) return;

  // 状態管理を更新する
  playButton.addEventListener('click', () => {
    toggleVideoState(videoId);
  });
};
// handle-video-intersect.ts

import { elements, selectors } from '@/assets/scripts/video-state/elements';
import { pauseVideo, playVideo } from '@assets/scripts/video-state/stores';

export const options = {
  root: null,
  rootMargin: '0px 0px',
  threshold: 0.5,
};

// 動画が画面内に入った事を検知し状態を更新
export const handleVideoIntersect = (entries: IntersectionObserverEntry[]): void => {
  entries.forEach((entry) => {
    elements.videos.forEach((video) => {
      const videoId = video.getAttribute(selectors.videoId);
      if (!videoId) return;

      if (entry.isIntersecting) {
        playVideo(videoId);
        console.log('再生');
      } else {
        pauseVideo(videoId);
        console.log('停止');
      }
    });
  });
};

これら 2 つのファイルは、クリックイベント、スクロールの監視で
動画の「状態」を変更します。
状態の定義はstores.tsで行なっているため、ここでは状態を呼び出して使う。という形になると思います。

video-operations.ts 再生・停止関数

// video-operations.ts

// dom操作 再生・停止処理
export const playVideo = (video: HTMLVideoElement): void => {
  video.play();
};

export const pauseVideo = (video: HTMLVideoElement): void => {
  video.pause();
};

こちらは、動画の再生・停止処理のみを扱う関数になります。

watch-video-state.ts 状態の監視と反映

// watch-video-state.ts

import { $isVideoPlaying } from '@assets/scripts/video-state/stores';
import { elements, selectors } from '@assets/scripts/video-state/elements';
import { pauseVideo, playVideo } from '@assets/scripts/video-state/video-operations';

// videoの状態を監視する関数
export const watchVideoState = () => {
  if (!elements.container) return;

  const videos = elements.videos;
  const playButton = elements.container.querySelector<HTMLButtonElement>(`[${selectors.videoButton}]`);

  $isVideoPlaying.subscribe((state) => {
    videos.forEach((video) => {
      const id = video.getAttribute(selectors.videoId);
      if (!id) return;

      // stores.tsから状態を取得
      const isPlaying = state[id];

      if (!playButton) return;
      if (isPlaying) {
        playVideo(video);
        playButton.textContent = '一時停止';
      } else {
        pauseVideo(video);
        playButton.textContent = '再生';
      }
    });
  });
};

$isVideoPlaying.subscribe((state) の部分で、
Nano Stores の $isVideoPlaying を 購読(subscribe)します。
状態が変更される毎に videos.forEach((video) => {以下を実行します。

const isPlaying = state[id];
statestores.tsで管理している動画の再生状態をまとめたオブジェクトです。
この辺りはconsoleで確認すると理解が深まりました。
動画の再生状態がfalseである事が分かります。

isPlayingがtrue ならplayVideo(video) で動画を再生し、ボタンのテキストを「一時停止」に変更

そうでなければpauseVideo(video) で動画を停止
ボタンのテキストを「再生」に変更

index.tsVideoState クラス

// index.ts

import { clickVideoEvent } from '@/assets/scripts/video-state/click-video-event';
import { elements } from '@/assets/scripts/video-state/elements';
import { watchVideoState } from '@/assets/scripts/video-state/watch-video-state';
import { handleVideoIntersect, options } from '@assets/scripts/video-state/handle-video-intersect';

export class VideoState {
  private observer: IntersectionObserver;

  constructor() {
    this.observer = new IntersectionObserver(handleVideoIntersect.bind(this), options);

    this.init();
  }

  private init(): void {
    elements.targets.forEach((target) => {
      this.observer.observe(target);
    });

    watchVideoState();
    clickVideoEvent();
  }
}

クラスを管理するファイルになります。

最後に

今回初めて Nano Stores を使い、状態管理にトライしました。
使い方自体は簡単そうですが、保存する状態によっては記述が複雑になりそう?なので
使いながら学んでいく必要があるなと思いました。
まだ分からないことも多いですが、業務でも使えるように慣れていきたいと思います。

参考

https://github.com/nanostores/nanostores

https://github.com/nanostores/nanostores?tab=readme-ov-file#vanilla-js

GitHubで編集を提案
株式会社D2C ID

Discussion