iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🪄

Nano Stores and the Magic of Cross-Framework State Management

に公開

What is nanostores

nanostores is a lightweight (approx 1KB) and framework-agnostic state management library. You can share the same store across React, Vue, Lit, Svelte, Vanilla JS, and more.

https://github.com/nanostores/nanostores

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

Understanding the Basics

atom (Minimum unit of state)

import { atom } from 'nanostores';

const $count = atom(0);

An atom has the following three basic operations:

$count.get();   // Get the current value → 0
$count.set(10); // Update the value
$count.subscribe((value) => {  // Monitor value changes
  console.log('Changed:', value);
});

subscribe executes a callback every time the value changes. It is mainly used for synchronization with the UI.

About the $ prefix

The $ prefix for variable names is a naming convention recommended by nanostores. It is a convention to make it easier to identify stores in the code and is not mandatory. This article follows the official convention by using the $.

computed (Memoization)

You can create states that are automatically calculated from other atoms.

import { atom, computed } from 'nanostores';

const $todos = atom([
  { id: 1, text: 'Shopping', done: false },
  { id: 2, text: 'Cleaning', done: true },
]);

// Automatically recalculate when $todos changes
const $doneCount = computed($todos, (todos) =>
  todos.filter((t) => t.done).length
);

$doneCount.get(); // 1

computed is read-only; you cannot call set directly on it.

persistentAtom (Persistence)

By using @nanostores/persistent, the state is automatically saved to localStorage.

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

// State is restored even after reloading the page
const $todos = persistentAtom('todos', []);

How to Use in Frameworks

About subscribe

In UI frameworks, a re-render is necessary when the state changes. Bindings for each framework wrap subscribe and trigger the re-render for you.

React

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

function Counter() {
  const count = useStore($count);  // Subscribing internally
  return <div>{count}</div>;
}

useStore does something like this internally:

// Inside useStore (conceptual)
function useStore(atom) {
  const [value, setValue] = useState(atom.get());

  useEffect(() => {
    const unsubscribe = atom.subscribe((newValue) => {
      setValue(newValue);  // Re-render when the value changes
    });
    return unsubscribe;
  }, [atom]);

  return value;  // Return the current value
}

In other words, useStore is simply a wrapper around subscribe.

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>`;
  }
}

Similarly, StoreController subscribes internally and calls requestUpdate().

Vanilla JS / EJS

If you are not using a framework, you update the DOM by subscribing directly.

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

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

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

In React/Lit, binding libraries handle the subscribe for you, so you don't need to write it directly. However, in template engine environments like EJS, you need to call subscribe yourself.

Understanding Global Singletons

Same Instance from Anywhere

nanostores state is exported as an ES module, and the same instance is referenced throughout the entire application.

// 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 (Same instance)

This is the strength of nanostores and the reason why state is shared even across frameworks.

The Problem of Changeability from Anywhere

On the other hand, because there are no restrictions, there is a risk of the state being modified from unexpected locations.

// Anyone can call set
$count.set(999);
$count.set(-1);

Design Policy

Restricting Public APIs

Instead of exporting atoms directly, group them into a store object and mediate operations through functions.

// 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));
  },
};

The consumer side uses the exposed API.

// ✅ Allowed operations
todoStore.add('New Task');
todoStore.toggle(1);

// ❌ Operations to avoid (but technically possible)
todoStore.$todos.set([]);

However, this is just enforcement by team rules. As long as $todos is exported, it is technically possible to call set directly. You need to handle this through operational measures, such as creating custom ESLint rules or ensuring compliance through code reviews.

Clarifying Domain Boundaries

stores/
├── global/           # State used app-wide
│   └── auth.ts
├── features/         # State scoped to specific features
│   ├── todo.ts
│   └── settings.ts
└── index.ts

Unifying Dependency Direction

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

If you want to share state between features, promote it to global.

Making it easier to use across frameworks

Challenge: Different syntax for each framework

nanostores is framework-agnostic, but the code on the consumer side differs for each framework.

// React
const todos = useStore(todoStore.$todos);
todoStore.add('Task');

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

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

Let's make it possible to handle this with a unified interface.

Solution: Key-based access

Standardize store definition files and provide bindings for each framework. By passing a key, the store for the corresponding domain can be retrieved, and type completion will also work.

Implementation

Store Definition

The store for each domain is defined by splitting it into atoms and actions.

// 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

Register the stores as a list to serve as the basis for type definitions.

// 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 Binding

// 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 Binding

// 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 Binding

// 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()); // Initial execution
    return () => unsubscribes.forEach((fn) => fn());
  };

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

Directory Structure

stores/
├── registry.ts              # Store list + type definitions
├── useStore.ts              # For React
├── createStoreController.ts # For Lit
├── bindStore.ts             # For EJS / Vanilla JS
└── todo.ts                  # Domain store

Usage Examples

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">Add</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)}>Delete</button>
          </li>
        ))}
      </ul>

      <p>Completed: {$doneCount}</p>
      <button onClick={clear}>Clear All</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">Add</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)}>Delete</button>
            </li>
          `
        )}
      </ul>

      <p>Completed: ${doneCount}</p>
      <button @click=${this.store.clear}>Clear All</button>
    `;
  }
}

EJS / Vanilla JS

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

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

<p>Completed: <span id="done-count"></span></p>
<button id="clear-btn">Clear All</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}">Delete</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>

Summary of Structure

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

createStoreController(this, 'todo') ──→ Referencing the same atoms

bindStore('todo') ────────────────┘
  • Store definition in one place (atoms + actions)
  • Absorb differences with framework-specific bindings
  • Type completion works with keys
  • Since they reference the same atoms, state is synchronized across frameworks

Conclusion

Concept Description
atom The smallest unit of state. get / set / subscribe
computed Derived state. Automatically calculated from other atoms
persistentAtom Atoms that are persisted in localStorage
useStore / StoreController Wraps subscribe to synchronize with the UI
Global Singleton The same instance from the same key

nanostores is a lightweight, framework-agnostic state management library, but due to its nature as a global singleton, design is important.

By using the key-based design pattern introduced in this article, you gain the following benefits:

  • Store definitions can be centralized in one place
  • Can be used with the same interface across frameworks
  • Safe to use because type completion works
  • State is synchronized even in environments where React/Lit/EJS coexist

Why did I write this article?

脚注
  1. I like React, so I included it as one pattern. ↩︎

Discussion