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

@tduylib/react

v0.0.5

Published

React hooks for `@tduylib/core` `Store`, cache, and form. The package holds no state of its own. Each function takes a core instance (or `FormStore` options) and subscribes with `useSyncExternalStore`.

Readme

@tduylib/react

React hooks for @tduylib/core Store, cache, and form. The package holds no state of its own. Each function takes a core instance (or FormStore options) and subscribes with useSyncExternalStore.

import { createReactStore, createUseCache, useForm, useField } from "@tduylib/react";

| Area | API | What it does | | ----- | --------------------- | ------------------------------------------------------------ | | Store | createReactStore | Wraps a Store<T> and returns use, usePart, usePartBy | | Cache | createUseCache | Wraps a CacheClient and returns a useCache hook | | Form | useForm, useField | Wraps FormStore: the whole form, and one field by path |

@tduylib/core and react are peer dependencies. use-sync-external-store is a dependency. tsup marks all three external, so they are not bundled into dist. Tests alias @tduylib/core to core/src, so core does not need to be built first.

Run tests with pnpm test at the repo root, or pnpm -F @tduylib/react test.

Source

The public entry is src/index.ts. Each area has its own folder. The folder index.ts only re-exports.

src/
  index.ts
  store/
    create-store.ts    createReactStore
    test/
  cache/
    useCache.ts        createUseCache, UseCacheResult
    test/
  form/
    use-form.ts        useForm
    use-field.ts       useField
    test/

createReactStore and createUseCache bind to an instance that lives outside the component, usually at module scope. useForm creates its FormStore in a useRef on the first render of that component.

Store

createReactStore takes a Store already created in core. The returned object only has hooks. get, set, update, and subscribe stay on the Store.

import { Store } from "@tduylib/core";
import { createReactStore } from "@tduylib/react";

const store = new Store({ count: 0, label: "a" });
const counter = createReactStore(store);

function Count() {
  const count = counter.use((state) => state.count);
  return <span>{count}</span>;
}
  • use(selector?, equalityFn?) uses useSyncExternalStoreWithSelector. With no selector it returns the whole state. equalityFn defaults to shallowEqual.
  • usePart(...keys) picks those keys, then compares them with shallowEqual. A new object on each select does not re-render when those keys are unchanged.
  • usePartBy(selector, ...keys) runs the selector first, picks keys from that result, and shallow-compares the picked keys.
  • The server snapshot and the client snapshot are both store.get().
  • Call createReactStore once, next to the Store. Calling it in a component body creates a new set of hooks on every render.

Cache

createUseCache takes a CacheClient. The returned hook takes a key and CacheOptions (func, optional staleTime). It reads the entry with getOrCreate and calls run for that entry.

import { CacheClient } from "@tduylib/core";
import { createUseCache } from "@tduylib/react";

const client = new CacheClient({ staleTime: 5_000 });
const useCache = createUseCache(client);

function User({ id }: { id: string }) {
  const query = useCache(`user:${id}`, {
    func: () => fetchUser(id),
  });

  if (query.isPending) return null;
  return <span>{query.data?.name}</span>;
}

UseCacheResult<T>:

| Field | Source | | ------------------------- | ---------------------------------------------------------------------------------- | | data, error, status | the entry snapshot | | isPending | status === "pending" | | isFetching | fetchStatus === "fetching", or this render will fetch an entry that is not fresh | | isSuccess | status === "success" | | isError | status === "error" | | isStale | !cache.isFresh() when that result was selected | | refetch() | cache.run() | | remove() | cacheClient.remove(key) |

status is pending, success, or error. The hook subscribes to the derived result and compares it with shallowEqual. A new snapshot does not render when that result is unchanged. The first render of an entry that is not fresh already reports isFetching, so the later fetchStatus: "fetching" update does not render again. A settled request updates status and fetchStatus together.

Pass select to subscribe to part of the result. An update that leaves the selected value unchanged does not render. Time passing does not re-render by itself. React StrictMode in development can render more than once. A fetcher typed as () => Promise<unknown> makes data any, so reading a field type-checks. A fetcher that returns a concrete type keeps data as T | undefined.

const name = useCache(`user:${id}`, { func: () => fetchUser(id) }, (query) => query.data?.name);

const count = useCache("count", fetchCount, 5_000, (query) => query.data);

The hook calls getOrCreate and then run when that cache entry is first used. A later render with the same key keeps the stored func. refetch runs that stored function. Pass a new key to store a new function. remove deletes the client entry and leaves the current snapshot in place until the component renders again. That next render creates a new entry and fetches it.

Form

useForm(options) creates one FormStore for that component. Options are initialValues, optional validate, and optional validationMode (onChange, onBlur, or onSubmit; core defaults to onSubmit). The hook subscribes to the whole form state.

The return value includes store, values, errors, touched, isSubmitting, isSubmitted, submitCount, and methods forwarded to the store: getValue, getFieldState, setValue, setValues, setTouched, setError, setValidator, setValidationMode, registerFieldValidator, validateField, validate, handleSubmit, reset.

useField(form, path, options?) subscribes to one path. form is { store } from useForm, or any object with a FormStore. A path is a dot string to a leaf, including nested objects, using core's FieldPath. The listener is subscribeField. The snapshot is that field's revision, so a change to another field does not re-render the component that called useField. A parent that calls useForm still re-renders, and it re-renders its children unless that child is skipped by React.

function validateEmail(value: string) {
  return value.includes("@") ? undefined : "Invalid email";
}

function ProfileForm() {
  const form = useForm({
    initialValues: { email: "", profile: { name: "" } },
    validationMode: "onBlur",
  });

  const email = useField(form, "email", { validate: validateEmail });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void form.handleSubmit(async (values) => {
          await save(values);
        });
      }}
    >
      <input value={email.value} onChange={(event) => email.setValue(event.target.value)} onBlur={email.onBlur} />
      {email.error}
    </form>
  );
}

useField returns value, error, touched, and isDirty from getFieldState, plus setValue, onBlur (marks the field touched), and validate. options.validate is registered with registerFieldValidator in an effect and removed when the validator changes or the component unmounts. Pass a stable function. A new function on each render unregisters the previous one and drops a validation that has not finished.

useForm reads initialValues, validate, and validationMode on the first render only. Later renders keep the same FormStore.

Test

| File | Covers | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | src/exports.test.ts | createReactStore, createUseCache, useForm, useField | | src/store/test/create-store.test.tsx | use, usePart, usePartBy, a custom equality function, unsubscribe on unmount | | src/store/test/create-store.ssr.test.tsx | renderToString reads the current store snapshot | | src/cache/test/useCache.test.tsx | fetch flags, render count, select, shared in-flight key, stored func, refetch, key change, error, remove | | src/cache/data-type.check.ts | an untyped fetch is any; a concrete fetch stays T \| undefined. The package tsconfig includes this file. Vitest does not run it | | src/form/test/use-form.test.tsx | stable store, forwarded writes, handleSubmit, validator and mode | | src/form/test/use-field.test.tsx | one-path subscription, onBlur, validate, validator cleanup, useForm store |

When adding a hook, add a case next to it and a row to this table.