{"_id":"@emeryld/rrroutes-ui","_rev":"2-e2164f2fb4e97e08e73e7463e4545837","name":"@emeryld/rrroutes-ui","dist-tags":{"latest":"1.2.0"},"versions":{"1.1.2":{"name":"@emeryld/rrroutes-ui","version":"1.1.2","_id":"@emeryld/rrroutes-ui@1.1.2","maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"dist":{"shasum":"823f7cf99f0a04a8ea957e65d12736db66f44295","tarball":"https://registry.npmjs.org/@emeryld/rrroutes-ui/-/rrroutes-ui-1.1.2.tgz","fileCount":31,"integrity":"sha512-rWLgc/znUUo2KzeGoR/4IIox21W2cpYE+MHl7/3NZPo7xmdPBk8xC7saf0VeNl5AnCQbr52y4zgLZzjXXoUsSQ==","signatures":[{"sig":"MEUCIB6OeNJETDeXqOuBdCCcRa9cWNvXzVMitZ4kWTf5VJ9hAiEAu+5QklViCkYO/pevQNsCwsioleDkRT2x9bil5FgL114=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":428414},"main":"dist/index.cjs","type":"module","_from":"file:emeryld-rrroutes-ui-1.1.2.tgz","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"},"./form":{"types":"./dist/form/index.d.ts","import":"./dist/form.mjs","require":"./dist/form.cjs"}},"private":false,"scripts":{"test":"NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.base.config.js --watchman=false --runInBand","build":"pnpm run clean && pnpm run build:js && pnpm run build:types","clean":"rimraf dist","build:js":"tsup --config tsup.config.ts","typecheck":"tsc -p tsconfig.json --noEmit","build:types":"tsc -p tsconfig.build.json"},"_npmUser":{"name":"emeryld","email":"karambiri.emery@gmail.com"},"_resolved":"/private/var/folders/5x/zf5c1y3x3fb758ncq40308m80000gn/T/b216285a01986de21cca49b8be009866/emeryld-rrroutes-ui-1.1.2.tgz","_integrity":"sha512-rWLgc/znUUo2KzeGoR/4IIox21W2cpYE+MHl7/3NZPo7xmdPBk8xC7saf0VeNl5AnCQbr52y4zgLZzjXXoUsSQ==","repository":{"url":"git+https://github.com/EmeryK-1/RRRoutes.git","type":"git"},"_npmVersion":"10.9.4","description":"Headless, fully-typed React helpers for consuming RRRoutes endpoints: query/feed/mutation boundaries, skeleton state, and a typed zod form binder. Renders nothing of its own — works identically on web and React Native.","directories":{},"_nodeVersion":"22.22.0","dependencies":{"@emeryld/rrroutes-contract":"^2.10.6"},"_hasShrinkwrap":false,"devDependencies":{"zod":"4.3.6","react":"^19.2.8","@types/react":"^19.2.18","@jest/globals":"^30.4.1","@tanstack/react-query":"^5.101.4","@emeryld/rrroutes-client":"^2.10.8","@emeryld/rrroutes-docs-tool":"0.1.0"},"peerDependencies":{"zod":"^4.0.0","react":">=18","@tanstack/react-query":"^5.87.4","@emeryld/rrroutes-client":"^2.10.8"},"peerDependenciesMeta":{"zod":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/rrroutes-ui_1.1.2_1786314866868_0.7804437356720715","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@emeryld/rrroutes-ui","version":"1.2.0","description":"Headless, fully-typed React helpers for consuming RRRoutes endpoints: query/feed/mutation boundaries, skeleton state, and a typed zod form binder. Renders nothing of its own — works identically on web and React Native.","private":false,"type":"module","main":"dist/index.cjs","module":"dist/index.mjs","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"},"./form":{"types":"./dist/form/index.d.ts","import":"./dist/form.mjs","require":"./dist/form.cjs"}},"dependencies":{"@emeryld/rrroutes-contract":"^2.11.0"},"peerDependencies":{"@tanstack/react-query":"^5.87.4","react":">=18","zod":"^4.0.0","@emeryld/rrroutes-client":"^2.10.9"},"peerDependenciesMeta":{"zod":{"optional":true}},"devDependencies":{"@jest/globals":"^30.4.1","@tanstack/react-query":"^5.101.4","@types/react":"^19.2.18","react":"^19.2.8","zod":"4.3.6","@emeryld/rrroutes-docs-tool":"0.1.0","@emeryld/rrroutes-client":"^2.10.9"},"repository":{"type":"git","url":"git+https://github.com/EmeryK-1/RRRoutes.git"},"scripts":{"clean":"rimraf dist","build":"pnpm run clean && pnpm run build:js && pnpm run build:types","build:js":"tsup --config tsup.config.ts","build:types":"tsc -p tsconfig.build.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.base.config.js --watchman=false --runInBand"},"_id":"@emeryld/rrroutes-ui@1.2.0","bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","_integrity":"sha512-OM1wKXh4gH2JzbbczzDV+2K8R23ac8AAgu5twLJqMWfhBnhuSS7WhPKp2xkhJACc+xJ3wUyKepdk851zeBlH8A==","_resolved":"/private/var/folders/5x/zf5c1y3x3fb758ncq40308m80000gn/T/ab10073b059c8daa162453d1f0255e4d/emeryld-rrroutes-ui-1.2.0.tgz","_from":"file:emeryld-rrroutes-ui-1.2.0.tgz","_nodeVersion":"22.22.0","_npmVersion":"10.9.4","dist":{"integrity":"sha512-OM1wKXh4gH2JzbbczzDV+2K8R23ac8AAgu5twLJqMWfhBnhuSS7WhPKp2xkhJACc+xJ3wUyKepdk851zeBlH8A==","shasum":"c04b7fe9ccc4c28d9fe94964c016a15bbd84cab5","tarball":"https://registry.npmjs.org/@emeryld/rrroutes-ui/-/rrroutes-ui-1.2.0.tgz","fileCount":31,"unpackedSize":434814,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDjky+rVYuAkf+dUDv94Allq0FdFdeL0H4ORhlJWsTppgIgH0OK6nVC/WQ1QnSvH43+DPYJxvDf75kpiUdUYcgFSE8="}]},"_npmUser":{"name":"emeryld","email":"karambiri.emery@gmail.com"},"directories":{},"maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/rrroutes-ui_1.2.0_1786381374233_0.7058128434282283"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-09T22:34:26.708Z","modified":"2026-08-10T17:02:54.541Z","1.1.2":"2026-08-09T22:34:27.015Z","1.2.0":"2026-08-10T17:02:54.380Z"},"bugs":{"url":"https://github.com/EmeryK-1/RRRoutes/issues"},"homepage":"https://github.com/EmeryK-1/RRRoutes#readme","repository":{"type":"git","url":"git+https://github.com/EmeryK-1/RRRoutes.git"},"description":"Headless, fully-typed React helpers for consuming RRRoutes endpoints: query/feed/mutation boundaries, skeleton state, and a typed zod form binder. Renders nothing of its own — works identically on web and React Native.","maintainers":[{"name":"emeryld","email":"karambiri.emery@gmail.com"}],"readme":"# @emeryld/rrroutes-ui\n\nHeadless React helpers for consuming RRRoutes endpoints: query, feed and mutation boundaries, progressive skeleton state, and schema-driven forms.\n\nNothing here renders an element of its own — no DOM, no `react-native`, no UI kit. Slots decide what a boundary looks like; this decides when. The same code runs on web and native.\n\n## Installation\n\n<!-- docs:installation:start -->\n\n```sh\npnpm add @emeryld/rrroutes-ui\n```\n\n<!-- docs:installation:end -->\n\n## Prerequisites\n\n<!-- docs:prerequisites:start -->\n\n- `@emeryld/rrroutes-client` workspace:^\n- `@tanstack/react-query` ^5.87.4\n- `react` >=18\n<!-- docs:prerequisites:end -->\n\n## Entry points\n\n<!-- docs:entrypoints:start -->\n\n| Import path                 | Purpose                                                                                                                  | Runtime                      | Status | Additional requirements |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | ------ | ----------------------- |\n| `@emeryld/rrroutes-ui`      | Query, feed, and mutation boundaries with a discriminated state union, slot-based chrome, and progressive skeleton rows. | react, react-native, browser | stable | —                       |\n| `@emeryld/rrroutes-ui/form` | Controlled forms bound to a Zod schema by typed path, including forms derived directly from an endpoint's body contract. | react, react-native, browser | stable | zod                     |\n\n<!-- docs:entrypoints:end -->\n\n## Boundaries\n\n```tsx\n<QueryBoundary\n  endpoint={api.users.get}\n  args={[{ params: { id } }]}\n  loader={<Spinner />}\n  error={({ error, refetch }) => (\n    <Notice message={error.message} onRetry={refetch} />\n  )}\n  empty={<Nothing />}\n>\n  {({ data }) => <ProfileCard user={data.out} />}\n</QueryBoundary>\n```\n\n`data` is non-nullable in the success slot and `error` is non-nullable in the error slot, because the state is a union discriminated on `status` rather than a bag of optional fields.\n\nThe hooks are the same thing without the JSX:\n\n```tsx\nconst feed = useFeedBoundary(api.timeline.list, [{ query: { tag: 'top' } }])\n\nif (feed.status === 'error') return <Retry onPress={feed.refetch} />\nreturn <Posts items={feed.items} onEnd={feed.fetchNextPage} />\n```\n\n### The error grace period\n\nQueries that don't retry surface a dropped connection as an error that fixes itself moments later, and flashing a red card for that reads as breakage. An error is held for `errorGraceMs` (4 seconds by default) before a boundary will show it; until then the boundary keeps rendering whatever it had — stale data if there is any, the loader if there isn't.\n\n`resolveBoundaryStatus` is exported as a pure function, so a hand-rolled boundary can follow the same rules.\n\n## Defaults\n\n```tsx\n<RRRoutesUIProvider\n  errorGraceMs={4000}\n  skeletonCount={3}\n  slots={{\n    loader: <Spinner />,\n    error: ({ refetch }) => <Retry onPress={refetch} />,\n  }}\n  onEvent={(event) => logger.info(event.type, event)}\n>\n  <App />\n</RRRoutesUIProvider>\n```\n\n`onEvent` is the seam for a logger or profiler: boundaries emit `status`, `error` and `page` events instead of hard-wiring one in.\n\n## Forms\n\n```tsx\nconst { form, submit, canSubmit } = useEndpointForm(api.users.create)\nconst name = form.bind('name')\n```\n\nThe schema and the payload type both come from the leaf's body contract. See [`./form`](src/form/index.docs.md) for typed paths, union branch binding, and `createZodComponent`.\n\n## Related\n\n- [`@emeryld/rrroutes-client`](../client) — the endpoints these boundaries bind to.\n- [`@emeryld/rrroutes-react-native/feed`](../react-native) — feed endpoints bound to a virtualized list.\n\n## Full-stack guide\n\nThis package's chapters of the [full-stack guide](../../docs/fullstack-guide.md) —\nthe screens of the web app, written against the endpoints\n[`@emeryld/rrroutes-client`](../client/README.md#full-stack-guide) set up in\nsteps 3.1 and 3.2.\n\n<!-- fullstack:step id=\"web-query\" group=\"client-react\" number=\"3.3\" title=\"Read one record\" runtimes=\"browser\" -->\n\nA query boundary is a four-state union discriminated on `status`, not a bag of\noptional flags — narrowing to `success` makes `data` non-nullable. The component\nbelow is also live: nothing here mentions sockets, because the endpoint it\nreads was already wrapped in step 2.3.\n\n```tsx title=\"apps/web/src/routes/PostScreen.tsx\"\nimport { useQueryBoundary } from '@emeryld/rrroutes-ui'\n\nimport { posts } from '../api'\n\nexport function PostScreen({ postId }: { postId: string }) {\n  const post = useQueryBoundary(posts.getPost, [{ params: { postId } }])\n\n  if (post.status === 'pending') return <PostSkeleton />\n  if (post.status === 'error') {\n    return <Notice message={post.error.message} onRetry={post.refetch} />\n  }\n\n  return (\n    <article>\n      <h1>{post.data.out.title}</h1>\n      <p>{post.data.out.body}</p>\n    </article>\n  )\n}\n```\n\n```tsx title=\"apps/web/src/routes/PostCard.tsx\"\nimport { QueryBoundary } from '@emeryld/rrroutes-ui'\n\nimport { posts } from '../api'\n\n/** The component form, when the chrome is what varies rather than the logic. */\nexport function PostCard({ postId }: { postId: string }) {\n  return (\n    <QueryBoundary\n      endpoint={posts.getPost}\n      args={[{ params: { postId } }]}\n      loader={<PostSkeleton />}\n      error={({ error, refetch }) => (\n        <Notice message={error.message} onRetry={refetch} />\n      )}\n      empty={<Nothing />}\n    >\n      {({ data }) => <PostBody post={data.out} />}\n    </QueryBoundary>\n  )\n}\n```\n\n> **Note — The error grace period**\n> A query that does not retry surfaces a dropped connection as an error that\n> fixes itself a moment later, and flashing a red card for that reads as\n> breakage. Errors are held for `errorGraceMs` (4s by default) while the\n> boundary keeps rendering stale data, or the loader if it has none.\n> `resolveBoundaryStatus` is exported if you want the same rules in hand-rolled\n> chrome.\n\n> **Reference** — `@emeryld/rrroutes-ui`\n\n<!-- fullstack:step:end -->\n\n<!-- fullstack:step id=\"web-mutation\" group=\"client-react\" number=\"3.4\" title=\"Write, and invalidate\" runtimes=\"browser\" -->\n\n`useEndpointForm` derives the form from the leaf's body schema: no schema is\nwritten at the call site and no payload type is declared. `form.bind(...)` is\nchecked against the contract, and so is what `submit` sends. Invalidation goes\nin `onSuccess` — though for the feed it is mostly belt and braces, since the\nserver's broadcast will have patched the cache already.\n\n```tsx title=\"apps/web/src/routes/Composer.tsx\"\nimport { useEndpointForm } from '@emeryld/rrroutes-ui/form'\n\nimport { posts } from '../api'\n\nexport function Composer() {\n  const { form, submit, canSubmit, state } = useEndpointForm(posts.createPost, {\n    onSuccess: () => posts.listPosts.invalidate(),\n  })\n\n  const title = form.bind('title')\n  const body = form.bind('body')\n\n  return (\n    <form\n      onSubmit={(event) => {\n        event.preventDefault()\n        void submit()\n      }}\n    >\n      <input\n        value={title.value}\n        onChange={(event) => title.onValueChange(event.target.value)}\n      />\n      {title.error ? <small>{title.error.issues[0]?.message}</small> : null}\n\n      <textarea\n        value={body.value}\n        onChange={(event) => body.onValueChange(event.target.value)}\n      />\n\n      <button disabled={!canSubmit}>Publish</button>\n      {state.status === 'error' ? (\n        <Notice message={state.error.message} />\n      ) : null}\n    </form>\n  )\n}\n```\n\n```tsx title=\"apps/web/src/routes/DeleteButton.tsx\"\nimport { useMutationBoundary } from '@emeryld/rrroutes-ui'\n\nimport { posts } from '../api'\n\n/** No body schema, no form — the boundary on its own. */\nexport function DeleteButton({ postId }: { postId: string }) {\n  const remove = useMutationBoundary(\n    posts.deletePost,\n    [{ params: { postId } }],\n    {\n      onSuccess: () =>\n        Promise.all([\n          posts.listPosts.invalidate(),\n          posts.getPost.invalidate({ params: { postId } }),\n        ]),\n    },\n  )\n\n  return (\n    <button\n      disabled={remove.status === 'pending'}\n      onClick={() => remove.submit()}\n    >\n      Delete\n    </button>\n  )\n}\n```\n\n> **Note — Mutations have no grace period**\n> Deliberately. A failed query might be a blip worth hiding for a moment; a\n> failed mutation is the direct answer to something the user just did, and\n> hiding it for four seconds is worse than showing it.\n\n> **Reference** — `@emeryld/rrroutes-ui/form`\n\n<!-- fullstack:step:end -->\n\n<!-- fullstack:step id=\"web-feed\" group=\"client-react\" number=\"3.5\" title=\"Page through the feed\" runtimes=\"browser\" -->\n\nThe feed boundary flattens every resolved page into `items`, de-duplicated, and\nguards its own fetchers so a fast scroll cannot queue the same page twice.\n`empty` is a status rather than a length check, so \"no posts yet\" and \"still\nloading\" are never confused.\n\n```tsx title=\"apps/web/src/routes/Timeline.tsx\"\nimport { useFeedBoundary } from '@emeryld/rrroutes-ui'\n\nimport { posts } from '../api'\n\nexport function Timeline({ tag }: { tag?: string }) {\n  const feed = useFeedBoundary(posts.listPosts, [{ query: { tag, limit: 20 } }])\n\n  if (feed.status === 'pending') return <TimelineSkeleton />\n  if (feed.status === 'error') return <Retry onPress={feed.refetch} />\n  if (feed.status === 'empty') return <Nothing />\n\n  return (\n    <>\n      {feed.items.map((post) => (\n        <PostRow key={post.id} post={post} />\n      ))}\n\n      {feed.hasNextPage ? (\n        <button\n          disabled={feed.isFetchingNextPage}\n          onClick={() => void feed.fetchNextPage()}\n        >\n          {feed.isFetchingNextPage ? 'Loading…' : 'Load more'}\n        </button>\n      ) : null}\n    </>\n  )\n}\n```\n\n> **Note — Args are reference-stable**\n> Boundaries run their args through `useStableEndpointArgs`, which keeps the\n> same reference while the query key hash is unchanged. Writing\n> `[{ query: { tag, limit: 20 } }]` inline does not churn every memo\n> downstream.\n\n> **Reference** — `@emeryld/rrroutes-ui`\n\n<!-- fullstack:step:end -->\n\n## Scripts (monorepo)\n\n```sh\npnpm --filter @emeryld/rrroutes-ui build\npnpm --filter @emeryld/rrroutes-ui typecheck\npnpm --filter @emeryld/rrroutes-ui test\n```\n","readmeFilename":"README.md"}