@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?)usesuseSyncExternalStoreWithSelector. With no selector it returns the whole state.equalityFndefaults toshallowEqual.usePart(...keys)picks those keys, then compares them withshallowEqual. 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
createReactStoreonce, next to theStore. 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.
