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.4.0

Published

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

Readme

Snapstate

State management for React. Class-based stores that are easy to test, easy to extend, and predictable by default.

npm install @thalesfp/snapstate

Quick Start

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

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

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

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

  loadTodos() {
    return this.api.get("load", "/api/todos", (data) => this.state.set("todos", data));
  }

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

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

export const todoStore = new TodoStore();
function TodoListView({ todos }: { todos: State["todos"] }) {
  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id} onClick={() => todoStore.toggle(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") tracked by a trie, so listeners only fire when their 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 | | 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 | | find(path, predicate) | Return first match | | findIndexOf(path, predicate) | Index of first match, or -1 | | count(path, predicate) | Count matching items |

Async operations (this.api.*)

Every operation is keyed. Concurrent calls to the same key use take-latest semantics -- stale responses are silently discarded.

| Method | Description | |---|---| | fetch(key, fn) | Run async function with tracked status | | get(key, url, onSuccess?) | GET request | | post(key, url, options?) | POST request | | put(key, url, options?) | PUT request | | patch(key, url, options?) | PATCH request | | delete(key, url, options?) | DELETE request |

Options: { body?, headers?, onSuccess?(data)?, onError?(error)? }

Status tracking: getStatus(key) returns { status, error } where status has boolean flags: isIdle, isLoading, isReady, isError. Call resetStatus(key) to return an operation to idle, distinguishing "never loaded" from "loaded empty".

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 ReactSnapStore<{ 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 |

Connect

ReactSnapStore extends SnapStore with a connect() HOC. Available from @thalesfp/snapstate/react.

Map store to props

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

Data fetching

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

Granular subscriptions

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

pick(path) subscribes to that exact path -- the component only re-renders 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: (pick) => ({ project: pick("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. When values change, cleanup runs for the previous deps, then fetch and setup re-run. Without deps, lifecycle callbacks run once on mount.

Scoped stores

ReactSnapStore.scoped() creates a store when the component mounts and destroys it on unmount. Each instance gets its own isolated store — useful for detail views, forms, or modals that need fresh state on every mount.

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

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

  fetchTodo(id: string) {
    return this.api.get("fetch", `/api/todos/${id}`, (todo) => this.state.set("todo", todo));
  }
}

const TodoDetail = ReactSnapStore.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 ReactSnapStore. 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("login", "/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) | { ref, name, defaultValue, onBlur, onChange } for form elements | | 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 | | 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.

Configuration

Entry points

| Import | Description | |---|---| | @thalesfp/snapstate | Core SnapStore, types, setHttpClient | | @thalesfp/snapstate/react | ReactSnapStore with connect() HOC | | @thalesfp/snapstate/form | SnapFormStore with Zod validation and form lifecycle |

React and Zod are optional peer dependencies -- only needed if you use their respective entry points.

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

Example App

A full Vite + React 19 demo lives in example/ with todos, auth, and account profile features.

npm run build              # Build library first
cd example && npm install  # Install example deps
npm run example:dev        # Start dev server

License

MIT