Using Plugins

Opt-in structure: methods, namespacing, lifecycle hooks.

.use() lives directly on the store returned by createStore; there's no separate function to import for it. Each .use() call adds capability, not a nesting level. The store's type is updated at each step, so TypeScript always knows exactly what's available.

Concepts

TermDefinition
PluginA plain object of methods, plus optional onActivated/onDestroy hooks. Each .use() call registers one.
MethodA function attached directly to the store, callable as store.name(...) or store.<namespace>.name(...).

Step 1: Colocate logic with methods

Move logic inside the store using .use(). Every method reaches the store through this, including sibling methods and methods from earlier .use() calls:

type TodoState = { todos: string[]; status: "idle" | "loading" | "failed" };
 
const todoStore = createStore({ todos: [], status: "idle" } as TodoState)
  .use({
    addTodo(text: string): void {
      this.merge((s) => ({ todos: [...s.todos, text] }));
    },
    async fetchTodos(): Promise<void> {
      this.merge({ status: "loading" });
      try {
        const todos = await api.getTodos();
        this.set({ todos, status: "idle" });
      } catch {
        this.merge({ status: "failed" });
      }
    },
  });
 
todoStore.addTodo("Buy groceries");
await todoStore.fetchTodos();

this is bound via Function.prototype.apply, which only rebinds regular functions. An arrow-function-valued method does not get this binding. Use regular method syntax (method() {}) for anything that needs this.

Step 2: Add plugins

Plugins can be namespaced (.use(namespace, plugin)) or top-level (.use(plugin)). Namespaced plugins live under their own key, no conflicts, no surprises:

import { history, persist } from "@kintools/store-plugins";
 
const todoStore = createStore({ todos: [], status: "idle" } as TodoState)
  .use("persist", persist({ key: "todos" }))
  .use("history", history())
  .use({
    addTodo(text: string): void {
      this.merge((s) => ({ todos: [...s.todos, text] }));
    },
  });
 
todoStore.addTodo("Buy groceries");
todoStore.history.undo();
await todoStore.persist.hydrate();

Guarding against race conditions

Methods don't sequence or cancel async work for you. If fetchTodos can be called again before the first call resolves, a slower first response can land after a faster second one and overwrite it with stale data. Guard against it with a request counter:

const todoStore = createStore({ todos: [], status: "idle" } as TodoState)
  .use({
    addTodo(text: string): void {
      this.merge((s) => ({ todos: [...s.todos, text] }));
    },
  });
 
let requestId = 0;
 
async function fetchTodos(): Promise<void> {
  const id = ++requestId;
  todoStore.merge({ status: "loading" });
  try {
    const todos = await api.getTodos();
    if (id !== requestId) return; // A newer call already resolved.
    todoStore.set({ todos, status: "idle" });
  } catch {
    if (id !== requestId) return;
    todoStore.merge({ status: "failed" });
  }
}

To cancel the in-flight request itself, rather than just ignoring its result, pass an AbortController's signal to fetch instead, aborting the previous controller at the start of each call:

let controller: AbortController | undefined;
 
async function fetchTodos(): Promise<void> {
  controller?.abort();
  controller = new AbortController();
  todoStore.merge({ status: "loading" });
  try {
    const todos = await api.getTodos({ signal: controller.signal });
    todoStore.set({ todos, status: "idle" });
  } catch (e) {
    if ((e as Error).name === "AbortError") return;
    todoStore.merge({ status: "failed" });
  }
}

The same pattern applies inside a .use()-registered method: keep the counter or controller in the plugin's closure and guard with this.set/ this.merge the same way.

Namespaced plugins with methods

Plugins can include their own methods, scoped under a namespace to prevent conflicts:

const store = createStore({ todos: [] as string[] }).use("todos", {
  add(text: string): void {
    this.merge((s) => ({ todos: [...s.todos, text] }));
  },
  clear(): void {
    this.set({ todos: [] });
  },
  async fetch(): Promise<void> {
    const resp = await fetch("/api/todos");
    const todos = await resp.json();
    this.todos.add(todos[0]);
  },
});
 
store.todos.add("Buy groceries");
store.todos.clear();
await store.todos.fetch();

Plugin shape

A plugin passed to .use() is a plain object with any combination of:

FieldDescription
MethodsNamed functions, attached directly to the store
onActivatedRuns once immediately after the plugin is registered
onDestroyRuns when store.destroy() is called

See Writing Plugins for reusable, shareable plugin factories.