npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@thalesfp/snapstate

v0.10.0

Published

State management for React - testable, extensible, predictable class-based stores

Readme

Snapstate

Alpha. APIs may change between releases.

State management for React built around class-based stores: testable, extensible, and predictable by default.

npm install @thalesfp/snapstate

When To Use Snapstate

Snapstate exists to keep business logic out of React components. React components should focus on rendering and UI interactions, not application rules. Overusing useEffect and useState often leads to logic that is difficult to test, difficult to extend, and hard to reason about. By moving business logic into explicit stores, the UI stays simpler, the logic becomes easier to test, and the application remains less coupled to React itself.

  • Use it when you want testable store classes with minimal React coupling.
  • Use it when shared state, loading states, and view lifecycle need to stay predictable.
  • Use something smaller when local component state and a few hooks are enough.

Entry Points

| Import | Description | Requires | |---|---|---| | @thalesfp/snapstate | Core SnapStore, types, setHttpClient | None | | @thalesfp/snapstate/react | SnapStore with connect() HOC | react | | @thalesfp/snapstate/form | SnapFormStore with Zod validation and form lifecycle | react, zod | | @thalesfp/snapstate/url | createUrlParams, syncToUrl for URL search params | None |

React and Zod are optional peer dependencies, needed only when using their respective entry points. qs is bundled and requires no separate install.

Choose The Right API

  • Use connect() when you want simple store-to-props mapping for a shared store instance.
  • Use select when a component should subscribe to specific state paths instead of the whole mapped snapshot.
  • Use SnapStore.scoped() when each mounted view should get a fresh store instance.
  • Use SnapFormStore when the main concern is form values, validation, and submit lifecycle.
  • Use createUrlParams and syncToUrl when URL state should participate in the same store model.

Table Of Contents

Quick Start

1. Define a store

import { SnapStore } from "@thalesfp/snapstate/react";

interface State {
  todos: { id: string; text: string; done: boolean }[];
}

class TodoStore extends SnapStore<State, "load"> {
  constructor() {
    super({ todos: [] });
  }

  get todos() {
    return this.state.get("todos");
  }

  loadTodos() {
    return this.api.get({ key: "load", url: "/api/todos", target: "todos" });
  }

  addTodo(text: string) {
    this.state.append("todos", { id: crypto.randomUUID(), text, done: false });
  }

  complete(id: string) {
    this.state.patch("todos", (t) => t.id === id, { done: true });
  }
}

export const todoStore = new TodoStore();

2. Connect it to React

function TodoListView({ todos }: { todos: State["todos"] }) {
  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id} onClick={() => todoStore.complete(t.id)}>
          {t.done ? <s>{t.text}</s> : t.text}
        </li>
      ))}
    </ul>
  );
}

export const TodoList = todoStore.connect(TodoListView, {
  props: (s) => ({ todos: s.todos }),
  fetch: (s) => s.loadTodos(),
  loading: () => <p>Loading...</p>,
  error: ({ error }) => <p>Error: {error}</p>,
});

Stores

Stores hold state, expose methods, and notify subscribers. State changes use dot-paths ("user.name", "items.0.title") so listeners only fire when their specific path changes. Synchronous set() calls are auto-batched via queueMicrotask, and every set() preserves reference identity for unchanged subtrees.

SnapStore<T, K> is the base class. T is the state shape, K is the union of async operation keys.

State (this.state.*)

Scalar:

| Method | Description | |---|---| | get() | Full state object | | get(path) | Value at a dot-path | | set(path, value) | Set a value or pass an updater (prev) => next | | batch(fn) | Group multiple sets into a single notification | | merge(updates) | Set multiple top-level keys in a single batch | | computed(deps, fn) | Lazily-recomputed derived value from dependency paths | | reset() | Restore all state to initial values | | reset(...paths) | Restore only the specified paths to initial values |

Array:

| Method | Description | |---|---| | append(path, ...items) | Add items to end | | prepend(path, ...items) | Add items to start | | insertAt(path, index, ...items) | Insert at index | | patch(path, predicate, updates) | Shallow-merge into matching items | | remove(path, predicate) | Remove matching items | | removeAt(path, index) | Remove at index (supports negative) | | at(path, index) | Get item at index (supports negative) | | filter(path, predicate) | Return matching items (supports type predicates) | | find(path, predicate) | Return first match (supports type predicates) | | findIndexOf(path, predicate) | Index of first match, or -1 | | count(path, predicate) | Count matching items |

filter and find accept type predicates to narrow discriminated unions:

const completed = this.state.filter("todos", (t): t is CompletedTodo => t.completed);
// completed is CompletedTodo[], not Todo[]

computed derives a value from one or more state paths and recomputes lazily when any dependency changes. Create it once (in the constructor or as a class field) and call .get() to read it:

class TodoStore extends SnapStore<State, never> {
  private remaining = this.state.computed(["todos"], (s) =>
    s.todos.filter((t) => !t.done).length
  );

  getRemainingCount() {
    return this.remaining.get();
  }
}

Call .destroy() to stop tracking if you need to tear it down early; otherwise it cleans up with the store.

Async operations (this.api.*)

Tracked async operations use take-latest semantics by key: if a newer request starts for the same key, the older one stops updating status and state.

All methods take a single params object. When key is provided, the operation is tracked via getStatus(key). When key is omitted, the request runs without status tracking.

| Method | Description | |---|---| | fetch({ key?, fn }) | Run async function, optionally tracked. Returns the value from fn. | | all({ key?, requests }) | Parallel requests, each stored at a target path | | get({ key?, url, target?, fallback?, onSuccess?, onError? }) | GET request | | post({ key?, url, body?, target?, onSuccess?, onError? }) | POST request | | put / patch / delete | Same params as post |

Pass target to store the response directly at a state path, or onSuccess for custom handling. The two are mutually exclusive; target takes precedence. Pass fallback with target on get to set a default value on error (suppresses the error). When onError is provided, the error is handled and does not propagate to the caller -- unless onError itself throws, in which case the thrown error propagates. Without onError, errors are rethrown.

Status tracking: getStatus(key) returns { status, error } where status has boolean flags: isIdle, isLoading, isReady, isError. Call resetStatus(key) to return a single operation to idle, or resetStatus() with no arguments to reset all operations at once.

// Reset a single operation (e.g. before a retry)
store.resetStatus("fetchUsers");

// Reset all operations (e.g. when the store is reused for a different context)
store.resetStatus();

Parallel requests (api.all)

Load multiple endpoints in parallel under a single tracked operation. Each request stores its response at the specified target path. Requests default to GET but support any HTTP method:

async fetchDashboard() {
  await this.api.all({ key: "dashboard", requests: [
    { url: "/api/todos", target: "todos" },
    { url: "/api/stats", target: "stats" },
    { url: "/api/search", target: "results", method: "POST", body: { query: "active" } },
  ]});
}

Individual requests can have their own onError for per-request fallbacks. Requests with onError don't fail the batch:

async loadSettings() {
  await this.api.all({ key: "settings", requests: [
    { url: "/api/teams", target: "teams" },
    { url: "/api/credentials", target: "credStatus" },
    { url: "/api/linear-teams", target: "linearTeams", onError: () => this.state.set("linearTeams", []) },
  ]});
}

Targets are type-safe: each must be a valid state path.

Raw HTTP access (this.http)

Use this.http inside api.fetch to make HTTP calls through the store's configured client without creating a separate tracked operation. api.fetch returns the value from fn:

async refreshOrgCount(phaseId: string): Promise<number> {
  const { count } = await this.api.fetch({ key: "refreshOrgCount", fn: async () =>
    this.http.request<{ count: number }>(`/api/phases/${phaseId}/org-count`)
  });
  return count;
}

Cross-store derivation (this.derive)

Keep a local state key in sync with a value selected from another store. Subscribes to the source, applies an Object.is change guard, and cleans up on destroy().

class ProjectsStore extends SnapStore<{ companyId: string; projects: Project[] }, "fetch"> {
  constructor(company: Subscribable<{ currentCompany: { id: string } }>) {
    super({ companyId: "", projects: [] });
    this.derive("companyId", company, (s) => s.currentCompany.id);
  }
}

The source accepts any Subscribable (every SnapStore satisfies this), so stores stay testable in isolation. Pass a real store or a minimal mock.

Public interface

| Method | Description | |---|---| | subscribe(callback) | Subscribe to all changes. Returns unsubscribe function | | subscribe(path, callback) | Subscribe to a specific path | | getSnapshot() | Current state (compatible with useSyncExternalStore) | | getStatus(key) | Operation status | | resetStatus(key?) | Reset operation to idle | | destroy() | Tear down subscriptions and derivations |

React Integration

SnapStore from @thalesfp/snapstate/react extends the core store with a connect() HOC.

connect()

Use the shorthand form when you only need to map props. Pass the mapper function directly as the second argument:

const UserName = userStore.connect(
  ({ name }: { name: string }) => <span>{name}</span>,
  (store) => ({ name: store.getSnapshot().user.name }),
);

Use the object form when you need lifecycle options (fetch, setup, cleanup, loading, error, deps):

const UserProfile = userStore.connect(ProfileView, {
  props: (s) => ({ user: s.getSnapshot().user }),
  fetch: (s) => s.loadUser(),
  loading: () => <Skeleton />,
  error: ({ error }) => <p>{error}</p>,
});

Granular subscriptions

For top-level keys, pass an array:

const TodoApp = todoStore.connect(TodoView, {
  select: ["todos", "filter"],
});

For nested paths, use the callback form with pick:

const UserCard = userStore.connect(CardView, {
  select: (pick) => ({
    name: pick("user.name"),
    avatar: pick("user.avatar"),
  }),
});

Both forms subscribe to the specified paths only, so the component re-renders only when those values change. select supports all lifecycle options (fetch, setup, cleanup, loading, error, deps).

Lifecycle

const Dashboard = dashboardStore.connect(DashboardView, {
  props: (s) => ({ stats: s.getSnapshot().stats }),
  setup: (s) => s.initPolling(),
  fetch: (s) => s.loadStats(),
  cleanup: (s) => s.stopPolling(),
  loading: () => <Skeleton />,
});

| Option | When it runs | Typical use | |---|---|---| | setup | Before fetch, on mount (or when deps change) | Start timers, subscriptions, AbortControllers | | fetch | After setup, on mount (or when deps change) | Load data from the API | | cleanup | On unmount (or before re-running on deps change) | Stop timers, reset store state | | loading | While fetch is in progress | Render a spinner or skeleton | | error | When fetch fails | Render an error message |

All lifecycle options are safe in React StrictMode.

Dependencies

const ProjectDetail = projectStore.connect(ProjectView, {
  select: ["project"],
  fetch: (s, props) => s.fetchProject(props.id),
  cleanup: (s) => s.reset(),
  deps: (props) => [props.id],
  loading: () => <Skeleton />,
});

deps returns a dependency array from the component's own props (and optionally URL params; see URL Parameters). When values change, cleanup runs for the previous deps, then fetch and setup re-run. Without deps, lifecycle callbacks run once on mount.

Template

Wrap the connected component in a layout that also receives store-derived props:

function TodoLayout({ remaining, children }: { remaining: number; children: React.ReactNode }) {
  return (
    <div className="app">
      <h1>Todos ({remaining})</h1>
      {children}
    </div>
  );
}

function TodoAppInner({ remaining }: { remaining: number }) {
  return <p>{remaining} items left</p>;
}

const TodoApp = todoStore.connect(TodoAppInner, {
  select: ["remaining"],
  fetch: (s) => s.loadTodos(),
  template: TodoLayout,
  loading: () => <Skeleton />,
});

The template component receives the same mapped props as the inner component, plus children (the rendered inner component). It renders after fetch guards, so children is always the ready component. Works with props, select, and scoped.

Scoped stores

SnapStore.scoped() creates a store when the component mounts and destroys it on unmount, giving each instance its own isolated state. Use it for detail views, forms, or modals that need fresh state on every mount.

import { SnapStore } from "@thalesfp/snapstate/react";

class TodoDetailStore extends SnapStore<{ todo: Todo | null }, "fetch"> {
  constructor() {
    super({ todo: null });
  }

  fetchTodo(id: string) {
    return this.api.get({ key: "fetch", url: `/api/todos/${id}`, target: "todo" });
  }
}

const TodoDetail = SnapStore.scoped(TodoDetailView, {
  factory: () => new TodoDetailStore(),
  props: (store) => ({ todo: store.getSnapshot().todo }),
  fetch: (store, props) => store.fetchTodo(props.id),
  deps: (props) => [props.id],
  loading: () => <Skeleton />,
});

No manual cleanup or reset() needed. destroy() runs automatically on unmount. All lifecycle options (setup, cleanup, fetch, deps, loading, error) work the same as in connect.

Forms

SnapFormStore<V, K> extends SnapStore. Available from @thalesfp/snapstate/form. Requires zod peer dependency.

import { SnapFormStore } from "@thalesfp/snapstate/form";
import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type LoginValues = z.infer<typeof schema>;

class LoginStore extends SnapFormStore<LoginValues, "login"> {
  constructor() {
    super(schema, { email: "", password: "" }, { validationMode: "onBlur" });
  }

  login() {
    return this.submit("login", async (values) => {
      await this.api.post({ key: "login", url: "/api/login", body: values });
    });
  }
}

register()

Returns props to spread onto form elements. Handles ref tracking, value sync, and event binding:

const loginStore = new LoginStore();

function LoginFormView({ errors }: { errors: FormErrors<LoginValues> }) {
  return (
    <form onSubmit={(e) => { e.preventDefault(); loginStore.login(); }}>
      <input {...loginStore.register("email")} />
      {errors.email && <span>{errors.email[0]}</span>}

      <input type="password" {...loginStore.register("password")} />
      {errors.password && <span>{errors.password[0]}</span>}

      <button type="submit">Log in</button>
    </form>
  );
}

export const LoginForm = loginStore.connect(LoginFormView, (s) => ({
  errors: s.errors,
}));

Validation modes

| Mode | Behavior | |---|---| | onSubmit | Validate only when submit() is called (default) | | onBlur | Validate field on blur | | onChange | Validate field on every change |

Form methods

| Method | Description | |---|---| | register(field) | Props for form elements: ref, name, onBlur, onChange, and defaultValue (or defaultChecked for boolean fields) | | setValue(field, value) | Set field value | | getValue(field) | Get current field value (reads from DOM ref if registered) | | getValues() | Get all current values | | validate() | Validate full form, returns parsed data or null | | validateField(field) | Validate single field | | submit(key, handler) | Validate then call handler with async status tracking. Use this.http for HTTP calls inside the handler -- api.* methods cause double status tracking. | | reset() | Reset to initial values | | clear() | Clear to type-appropriate zero values | | setInitialValues(values) | Update initial values | | isDirty / isFieldDirty(field) | Dirty tracking (supports Date and array equality) | | errors / isValid | Field-level error arrays |

Supported elements: text inputs, number, checkbox, textarea, select, range, radio, date/time/datetime-local, select multiple, and file inputs.

URL Parameters

@thalesfp/snapstate/url provides reactive URL search parameter reading and writing.

Reading URL params

createUrlParams<T>() returns a typed Subscribable that parses window.location.search. It detects navigation via popstate and by patching history.pushState and history.replaceState globally, since the browser does not fire popstate for those.

import { createUrlParams } from "@thalesfp/snapstate/url";

const urlParams = createUrlParams<{ filter?: string; page?: string }>();

urlParams.getSnapshot(); // { filter: "active", page: "2" } from ?filter=active&page=2

Use it with derive() to sync URL params into store state:

class AppStore extends SnapStore<{ filter: string }> {
  constructor() {
    super({ filter: "all" });
    this.derive("filter", urlParams, (p) => (p.filter as string) ?? "all");
  }
}

Or pass it to connect() so fetch, setup, and deps receive typed params automatically:

const TodoApp = todoStore.connect(TodoAppView, {
  props: (s) => ({ todos: s.filteredTodos }),
  urlParams,
  fetch: (store, props, params) => {
    // params.filter is typed as string | undefined
    if (params.filter) store.setFilter(params.filter);
    return store.loadTodos();
  },
  deps: (props, params) => [params.filter],
  loading: () => <Spinner />,
});

Writing state to URL

syncToUrl() subscribes to a store and mirrors selected state into URL search params:

import { syncToUrl } from "@thalesfp/snapstate/url";

const unsub = syncToUrl(todoStore, {
  params: {
    filter: (s) => s.filter,
    page: (s) => s.page,
  },
  history: "replace", // default; use "push" for back-button navigation
});

Empty, null, and undefined values are omitted from the URL. The subscriber skips qs.stringify entirely when the tracked params haven't changed.

Parsing features

Powered by qs, supports nested objects, arrays, dot notation, and depth/parameter limits:

?user[name]=John          → { user: { name: "John" } }
?colors[]=red&colors[]=blue → { colors: ["red", "blue"] }
?user.name=John           → { user: { name: "John" } }

Options

createUrlParams({
  initialParams: { filter: "all" },  // SSR/testing (bypass window)
  listen: true,                       // Listen to navigation events (default: true in browser)
  depth: 5,                           // Max nesting depth (default: 5)
  parameterLimit: 1000,               // Max params to parse (default: 1000)
  arrayFormat: "brackets",            // "brackets" | "indices" | "comma" | "repeat"
});

Cleanup

urlParams.destroy();  // Remove event listeners
unsub();              // Stop syncing to URL (return value of syncToUrl)

Custom HTTP Client

By default, snapstate uses the native fetch API, sets no auth headers, and throws on non-2xx responses. Override it globally with setHttpClient, or per-store via constructor options. setDefaultHeaders works with both the default and custom clients -- headers are merged at the API method level before reaching the client.

import { setHttpClient } from "@thalesfp/snapstate";

setHttpClient({
  async request(url, init) {
    const res = await fetch(url, {
      ...init,
      headers: { ...init?.headers, Authorization: `Bearer ${getToken()}` },
      body: init?.body ? JSON.stringify(init.body) : undefined,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const text = await res.text();
    return text ? JSON.parse(text) : undefined;
  },
});

Pass httpClient via constructor options to override the global client for that store only. Useful for testing:

const mockClient: HttpClient = {
  async request(url) { return { id: "1", name: "Test" }; },
};

const store = new UserStore({ httpClient: mockClient });

Example App

A full Vite + React 19 demo lives in example/ and shows shared stores, scoped detail views, form submission, auth state, and URL-backed todo filters.

npm run build            # Build the library used by the example
npm run example:install  # Install example deps (first time)
npm run example:dev      # Start the Vite app and mock API together

Docs

Benchmarks

See BENCHMARKS.md for detailed performance numbers.

License

MIT