🪄

nano storesと不思議な横断状態管理

に公開

nanostoresとは

nanostoresは軽量(約1KB)でフレームワーク非依存の状態管理ライブラリです。React、Vue、Lit、Svelte、Vanilla JSなど、どこでも同じstoreを共有できます。

https://github.com/nanostores/nanostores

npm install nanostores
npm install @nanostores/react  # React用
npm install @nanostores/lit    # Lit用

基本を理解する

atom(状態の最小単位)

import { atom } from 'nanostores';

const $count = atom(0);

atomは以下の3つの基本操作を持ちます。

$count.get();   // 現在の値を取得 → 0
$count.set(10); // 値を更新
$count.subscribe((value) => {  // 値の変更を監視
  console.log('変わった:', value);
});

subscribeは値が変わるたびにコールバックを実行します。主にUIとの同期に使用します。

$プレフィックスについて

変数名の$プレフィックスはnanostoresの推奨する命名規約です。storeであることをコード上で識別しやすくするための慣習であり、必須ではありません。本記事では公式の規約に従って$をつけています。

computed(メモ化)

他のatomから自動計算される状態を作れます。

import { atom, computed } from 'nanostores';

const $todos = atom([
  { id: 1, text: '買い物', done: false },
  { id: 2, text: '掃除', done: true },
]);

// $todosが変わると自動再計算
const $doneCount = computed($todos, (todos) =>
  todos.filter((t) => t.done).length
);

$doneCount.get(); // 1

computedは読み取り専用で、直接setはできません。

persistentAtom(永続化)

@nanostores/persistentを使うと、状態がlocalStorageに自動保存されます。

npm install @nanostores/persistent
import { persistentAtom } from '@nanostores/persistent';

// ページをリロードしても状態が復元される
const $todos = persistentAtom('todos', []);

フレームワークでの使い方

subscribeについて

UIフレームワークでは、状態が変わったときに再レンダリングが必要です。各フレームワーク向けのバインディングは、subscribeをラップして再レンダリングをトリガーしてくれます。

React

import { useStore } from '@nanostores/react';
import { $count } from './stores/counter';

function Counter() {
  const count = useStore($count);  // 内部でsubscribeしている
  return <div>{count}</div>;
}

useStoreは内部でこのようなことをしています。

// useStoreの中身(イメージ)
function useStore(atom) {
  const [value, setValue] = useState(atom.get());

  useEffect(() => {
    const unsubscribe = atom.subscribe((newValue) => {
      setValue(newValue);  // 値が変わったら再レンダリング
    });
    return unsubscribe;
  }, [atom]);

  return value;  // 現在の値を返す
}

つまりuseStoresubscribeをラップしているだけです。

Lit

import { LitElement, html } from 'lit';
import { StoreController } from '@nanostores/lit';
import { $count } from './stores/counter';

class MyCounter extends LitElement {
  private count = new StoreController(this, $count);

  render() {
    return html`<div>${this.count.value}</div>`;
  }
}

StoreControllerも同様に、内部でsubscribeしてrequestUpdate()を呼んでいます。

Vanilla JS / EJS

フレームワークを使わない場合は、直接subscribeしてDOMを更新します。

import { $count } from './stores/counter';

$count.subscribe((value) => {
  document.getElementById('counter').textContent = String(value);
});

document.getElementById('increment').addEventListener('click', () => {
  $count.set($count.get() + 1);
});

React/Litではバインディングライブラリがsubscribeを代わりにやってくれるので直接書く必要はありませんが、EJSなどテンプレートエンジン環境では自分でsubscribeする必要があります。

グローバルシングルトンを理解する

どこからでも同じインスタンス

nanostoresの状態はESモジュールとしてエクスポートされ、アプリ全体で同一インスタンスを参照します。

// stores/counter.ts
export const $count = atom(0);

// componentA.ts
import { $count } from './stores/counter';
$count.set(10);

// componentB.ts
import { $count } from './stores/counter';
console.log($count.get()); // 10(同じインスタンス)

これがnanostoresの強みであり、フレームワークを跨いでも状態が共有される理由です。

どこからでも変更できる問題

一方で、制限がないため想定外の箇所から状態を変更されるリスクがあります。

// 誰でもsetできてしまう
$count.set(999);
$count.set(-1);

設計の方針

公開APIを制限する

atomを直接exportせず、storeオブジェクトにまとめて操作関数を経由させます。

// stores/todo.ts
import { atom, computed } from 'nanostores';

type Todo = { id: number; text: string; done: boolean };

const $todos = atom<Todo[]>([]);

export const todoStore = {
  $todos,
  $doneCount: computed($todos, (todos) => todos.filter((t) => t.done).length),

  add: (text: string) => {
    const newTodo = { id: Date.now(), text, done: false };
    $todos.set([...$todos.get(), newTodo]);
  },
  toggle: (id: number) => {
    $todos.set(
      $todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  },
  remove: (id: number) => {
    $todos.set($todos.get().filter((t) => t.id !== id));
  },
};

使う側は公開されたAPIを使用します。

// ✅ 許可された操作
todoStore.add('新しいタスク');
todoStore.toggle(1);

// ❌ 避けるべき操作(だが技術的には可能)
todoStore.$todos.set([]);

ただし、これはあくまでチームのルールで縛るだけです。$todosをexportしている以上、setを直接呼ぶことは技術的には可能です。ESLintのカスタムルールを作成したり、コードレビューで担保するなど、運用でカバーする必要があります。

ドメイン境界を明確にする

stores/
├── global/           # アプリ全体で使う状態
│   └── auth.ts
├── features/         # 機能単位で閉じた状態
│   ├── todo.ts
│   └── settings.ts
└── index.ts

依存方向を統一する

global/auth ← features/todo      ✅ OK
global/auth ← features/settings  ✅ OK
features/todo ← features/settings  ❌ NG

feature間で状態を共有したい場合は、globalに昇格させます。

フレームワーク横断で使いやすくする

課題: フレームワークごとに書き方が違う

nanostoresはフレームワーク非依存ですが、使う側のコードはフレームワークごとに異なります。

// React
const todos = useStore(todoStore.$todos);
todoStore.add('タスク');

// Lit
private store = new StoreController(this, todoStore.$todos);
// render内で this.store.value

// EJS
todoStore.$todos.subscribe((todos) => { ... });

これを統一的なインターフェースで扱えるようにしましょう。

解決策: keyベースのアクセス

store定義ファイルは1つに統一し、フレームワークごとのバインディングを提供します。keyを渡すと該当するドメインのstoreが取得でき、型補完も効くようにします。

実装

Store定義

各ドメインのstoreはatomsactionsに分けて定義します。

// stores/todo.ts
import { atom, computed } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';

type Todo = { id: number; text: string; done: boolean };

const $todos = persistentAtom<Todo[]>('todos', []);
const $doneCount = computed($todos, (todos) => todos.filter((t) => t.done).length);

const actions = {
  add: (text: string) => {
    const newTodo = { id: Date.now(), text, done: false };
    $todos.set([...$todos.get(), newTodo]);
  },
  toggle: (id: number) => {
    $todos.set(
      $todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  },
  remove: (id: number) => {
    $todos.set($todos.get().filter((t) => t.id !== id));
  },
  clear: () => {
    $todos.set([]);
  },
};

export const todoStore = {
  atoms: { $todos, $doneCount },
  actions,
};

Registry

storeを一覧で登録し、型定義の元とします。

// stores/registry.ts
import { todoStore } from './todo';

export const stores = {
  todo: todoStore,
} as const;

export type StoreKey = keyof typeof stores;
export type StoreDefinition<K extends StoreKey> = (typeof stores)[K];

React用バインディング

// stores/useStore.ts
import { useStore as useNanoStore } from '@nanostores/react';
import { stores, StoreKey, StoreDefinition } from './registry';

type AtomValue<A> = A extends { get: () => infer V } ? V : never;

type UseStoreResult<K extends StoreKey> = {
  [P in keyof StoreDefinition<K>['atoms']]: AtomValue<StoreDefinition<K>['atoms'][P]>;
} & StoreDefinition<K>['actions'];

export const useStore = <K extends StoreKey>(key: K): UseStoreResult<K> => {
  const { atoms, actions } = stores[key];
  const result: Record<string, unknown> = {};

  for (const [name, atom] of Object.entries(atoms)) {
    result[name] = useNanoStore(atom);
  }

  return { ...result, ...actions } as UseStoreResult<K>;
};

Lit用バインディング

// stores/createStoreController.ts
import { ReactiveControllerHost } from 'lit';
import { StoreController } from '@nanostores/lit';
import { stores, StoreKey, StoreDefinition } from './registry';

type AtomController<A> = A extends { get: () => infer V }
  ? StoreController<V>
  : never;

type ControllerResult<K extends StoreKey> = {
  [P in keyof StoreDefinition<K>['atoms']]: AtomController<
    StoreDefinition<K>['atoms'][P]
  >;
} & StoreDefinition<K>['actions'];

export const createStoreController = <K extends StoreKey>(
  host: ReactiveControllerHost,
  key: K
): ControllerResult<K> => {
  const { atoms, actions } = stores[key];
  const result: Record<string, unknown> = {};

  for (const [name, atom] of Object.entries(atoms)) {
    result[name] = new StoreController(host, atom);
  }

  return { ...result, ...actions } as ControllerResult<K>;
};

EJS / Vanilla JS用バインディング

// stores/bindStore.ts
import { stores, StoreKey, StoreDefinition } from './registry';

type AtomValue<A> = A extends { get: () => infer V } ? V : never;

type BoundStoreResult<K extends StoreKey> = {
  subscribe: (
    callback: (values: {
      [P in keyof StoreDefinition<K>['atoms']]: AtomValue<StoreDefinition<K>['atoms'][P]>;
    }) => void
  ) => () => void;
} & StoreDefinition<K>['actions'];

export const bindStore = <K extends StoreKey>(key: K): BoundStoreResult<K> => {
  const { atoms, actions } = stores[key];

  const getValues = () => {
    const values: Record<string, unknown> = {};
    for (const [name, atom] of Object.entries(atoms)) {
      values[name] = (atom as { get: () => unknown }).get();
    }
    return values;
  };

  const subscribe = (callback: (values: Record<string, unknown>) => void) => {
    const unsubscribes: (() => void)[] = [];

    for (const atom of Object.values(atoms)) {
      const unsubscribe = (
        atom as { subscribe: (fn: () => void) => () => void }
      ).subscribe(() => {
        callback(getValues());
      });
      unsubscribes.push(unsubscribe);
    }

    callback(getValues()); // 初回実行
    return () => unsubscribes.forEach((fn) => fn());
  };

  return { subscribe, ...actions } as BoundStoreResult<K>;
};

ディレクトリ構造

stores/
├── registry.ts              # store一覧 + 型定義
├── useStore.ts              # React用
├── createStoreController.ts # Lit用
├── bindStore.ts             # EJS / Vanilla JS用
└── todo.ts                  # ドメインstore

使用例

React[1]

import { useStore } from '@/stores/useStore';

function TodoApp() {
  const { $todos, $doneCount, add, toggle, remove, clear } = useStore('todo');
  const [text, setText] = useState('');

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (text.trim()) {
      add(text);
      setText('');
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button type="submit">追加</button>
      </form>

      <ul>
        {$todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggle(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => remove(todo.id)}>削除</button>
          </li>
        ))}
      </ul>

      <p>完了: {$doneCount}</p>
      <button onClick={clear}>全て削除</button>
    </div>
  );
}

Lit

import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { createStoreController } from '@/stores/createStoreController';

@customElement('todo-app')
export class TodoApp extends LitElement {
  private store = createStoreController(this, 'todo');
  @state() private text = '';

  private handleSubmit(e: Event) {
    e.preventDefault();
    if (this.text.trim()) {
      this.store.add(this.text);
      this.text = '';
    }
  }

  render() {
    const todos = this.store.$todos.value;
    const doneCount = this.store.$doneCount.value;

    return html`
      <form @submit=${this.handleSubmit}>
        <input
          .value=${this.text}
          @input=${(e: Event) => (this.text = (e.target as HTMLInputElement).value)}
        />
        <button type="submit">追加</button>
      </form>

      <ul>
        ${todos.map(
          (todo) => html`
            <li>
              <input
                type="checkbox"
                ?checked=${todo.done}
                @change=${() => this.store.toggle(todo.id)}
              />
              <span>${todo.text}</span>
              <button @click=${() => this.store.remove(todo.id)}>削除</button>
            </li>
          `
        )}
      </ul>

      <p>完了: ${doneCount}件</p>
      <button @click=${this.store.clear}>全て削除</button>
    `;
  }
}

EJS / Vanilla JS

<!-- todo.ejs -->
<form id="todo-form">
  <input type="text" id="todo-input" />
  <button type="submit">追加</button>
</form>

<ul id="todo-list"></ul>

<p>完了: <span id="done-count"></span>件</p>
<button id="clear-btn">全て削除</button>

<script type="module">
  import { bindStore } from '@/stores/bindStore.js';

  const store = bindStore('todo');

  store.subscribe(({ $todos, $doneCount }) => {
    document.getElementById('todo-list').innerHTML = $todos
      .map(
        (todo) => `
        <li>
          <input type="checkbox" data-id="${todo.id}" ${todo.done ? 'checked' : ''} />
          <span>${todo.text}</span>
          <button data-remove="${todo.id}">削除</button>
        </li>
      `
      )
      .join('');
    document.getElementById('done-count').textContent = $doneCount;
  });

  document.getElementById('todo-form').addEventListener('submit', (e) => {
    e.preventDefault();
    const input = document.getElementById('todo-input');
    if (input.value.trim()) {
      store.add(input.value);
      input.value = '';
    }
  });

  document.getElementById('todo-list').addEventListener('change', (e) => {
    if (e.target.type === 'checkbox') {
      store.toggle(Number(e.target.dataset.id));
    }
  });

  document.getElementById('todo-list').addEventListener('click', (e) => {
    if (e.target.dataset.remove) {
      store.remove(Number(e.target.dataset.remove));
    }
  });

  document.getElementById('clear-btn').addEventListener('click', () => {
    store.clear();
  });
</script>

構造のまとめ

useStore('todo') ─────────────────┐

createStoreController(this, 'todo') ──→ 同じ atoms を参照

bindStore('todo') ────────────────┘
  • store定義は1箇所(atoms + actions)
  • フレームワークごとのバインディングで差異を吸収
  • keyで型補完が効く
  • 参照するatomは同じなのでフレームワーク間で状態が同期される

まとめ

概念 説明
atom 状態の最小単位。get / set / subscribe
computed 派生状態。他のatomから自動計算
persistentAtom localStorageに永続化されるatom
useStore / StoreController subscribeをラップしてUIと同期
グローバルシングルトン 同じkeyからは同じインスタンス

nanostoresはフレームワーク非依存の軽量な状態管理ライブラリですが、グローバルシングルトンという性質上、設計が重要です。

本記事で紹介したkeyベースの設計パターンを使うことで、以下のメリットがあります。

  • store定義を1箇所に集約できる
  • フレームワーク間で同じインターフェースで使える
  • 型補完が効くので安全に使える
  • React/Lit/EJSが混在する環境でも状態が同期される

なぜこんな記事を書いたか?

脚注
  1. Reactは好きなので1パターンとして入れてみました。 ↩︎

Discussion