Schema classes drive the cache in reactive/data-client
Async State Management without the Management. REST, GraphQL, SSE, Websockets
At a glance
- What is it?
- reactive/data-client turns REST, GraphQL and websocket payloads into typed entities and reads them through React hooks. The cost is a class-based schema you own, plus an install line that names only the React packages.
- Who is it for?
- reactive/data-client earns its place when a React app already models its data as classes and wants cache writes driven by the server rather than by updater functions, and the schema-first layout pays for itself once several screens share one article. It is the wrong shape for a codebase with a handful of endpoints, and the install line names only React packages even though npm listings include a Vue package, so check your framework's package first.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Star history
- reactive/data-client2,039 ★ · +1
| Repository | 2026-10-14 | 2026-10-23 |
|---|---|---|
| reactive/data-client | 2k | 2k |
1 Stars in the last 9 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Entity subclasses declare the shape every screen then reads
Most fetching libraries hand you untyped JSON and leave normalization to you. This one starts from a class: a user carries an id and a username, and an article adds a nested author plus a timestamp, with that nesting declared in a static schema property rather than in a reducer.
class Article extends Entity {
id = '';
title = '';
body = '';
author = User.fromJS();
createdAt = Temporal.Instant.fromEpochMilliseconds(0);
static schema = {
author: User,
createdAt: Temporal.Instant.from,
};
}Two details reward a close look. `User.fromJS()` is a placeholder factory rather than a fetch, so a missing author does not break construction, and `Temporal.Instant.from` upgrades a plain string into a temporal object at parse time instead of inside a component. Once the classes exist, `resource({` binds each one to a path and a schema, and that object is where the path template and the pagination config live.
Reading an article is one hook call with no local state copy
After the resource exists, a component does not hold an article in local component state, and it does not run an effect to fetch one. It calls `useSuspense(ArticleResource.get, { id })` and renders fields straight away.
const article = useSuspense(ArticleResource.get, { id });
return (
<article>
<h2>
{article.title} by {article.author.username}
</h2>
<p>{article.body}</p>
</article>
);Because every article sits under its own key, a second screen asking for the same id reads the same record, and `article.author.username` resolves through the normalized author entry rather than forcing a second round trip. The trade-off sits in the same line: reading stays cheap only if every writer went through the schema, and a raw fetch that bypasses the cache updates nothing already mounted.
Optimistic flag makes list pushes commit before the server answers
Both resources in the README set `optimistic: true`, and the mutation example sends a new article to `ArticleResource.getList.push` rather than to a dedicated create endpoint. The `.push` suffix is derived from the list endpoint, so one resource definition supplies the URL, the schema and the create semantics.
const ctrl = useController();
return (
<>
<CreateArticleForm
onSubmit={article =>
ctrl.fetch(ArticleResource.getList.push, article)
}
/>
<ProfileForm
onSubmit={user =>
ctrl.fetch(UserResource.update, { id: article.author.id }, user)
}
/>
</>
);Writes are typed against those same resource objects, so a wrong field name fails at the type level, and updates take the id as a separate argument, which is what lets a single call serve any record. What the README does not document is what happens when a server rejects an optimistic write, so read that reconciliation path in the source before depending on it.
Live subscriptions and websocket managers write into one cache
Polling is not the only alternative. `useLive(PriceResource.get, { symbol })` returns a live record and the render reads `price.value` directly.
const price = useLive(PriceResource.get, { symbol });
return price.value;For raw sockets, a class implements the `Manager` interface with an `init()` and a `middleware`, and each incoming message becomes `controller.set(Ticker, { id: msg.id }, msg)`, landing in the same normalized store the hooks read.
class TickerStream implements Manager {
middleware: Middleware = controller => {
this.handleMsg = msg => {
controller.set(Ticker, { id: msg.id }, msg);
};
return next => action => next(action);
};
init() {
this.websocket = new WebSocket('wss://ws-feed.myexchange.com');
}
}Read that block carefully, because it is cut off mid-example: the `cleanup()` line at the end carries no body and the message handler is not shown. The shape of a socket manager comes free, the teardown that decides whether your connection survives hot reloads does not.
Query objects derive totals and groups from cached collections
Some reads are computations rather than entities, and a `Query` wraps a `Collection` plus a reducer to hand back a number.
const queryTotalVotes = new Query(
new Collection([BlogPost]),
posts => posts.reduce((total, post) => total + post.votes, 0),
);
const totalVotes = useQuery(queryTotalVotes);
const totalVotesForUser = useQuery(queryTotalVotes, { userId });One query object is reused with a parameter object, so the second call is a keyed variant instead of a second definition. A later example groups rather than sums.
const groupTodoByUser = new Query(
TodoResource.getList.schema,
todos => Object.groupBy(todos, todo => todo.userId),
);
const todosByUser = useQuery(groupTodoByUser);That snippet pulls its schema straight off a resource, which is the pattern to copy: derive from an existing definition instead of restating entity types, and the normalization that makes the reduction cheap stays the same one the hooks use.
Cursor pagination and query typing both live in the resource object
The article resource shows the two configuration keys that most projects bolt on much later.
const ArticleResource = resource({
path: '/articles/:id',
schema: Article,
searchParams: {} as { author?: string },
optimistic: true,
paginationField: 'cursor',
});`searchParams` is a type assertion rather than a runtime validator, so it narrows what TypeScript accepts at a call site and nothing beyond that; a caller can still pass an unexpected key. `paginationField: 'cursor'` names the response field holding the next page cursor, and nothing in the README describes page merging, cache eviction order, or what a large page does to the store. Teams rendering long comment threads need those answers from the source first.
The one install line omits the Vue package the project ships
Setup is a single command from the README.
npm install --save @data-client/react @data-client/rest @data-client/testIt pulls the React bindings, the REST manager and a test package the README examples never reference, while the Vue package is absent even though the README names Vue among its rendering targets. A Vue user has to close that gap by hand, since no install snippet for it exists.
A second path is offered for agent-driven setup.
npx skills add reactive/data-clientAfterwards the README says to run the `data-client-setup` skill. The three most recent npm releases, `@data-client/[email protected]`, `@data-client/[email protected]` and `@data-client/[email protected]`, all carry 0.18.1 with a 2026-05-13 timestamp, while the repository itself is not archived and the last push landed on 2026-10-06.
Example workspaces exist partly to be measured against normalizr
The workspace list in `package.json` says more than the marketing line does. Alongside `examples/coin-app`, `examples/nextjs` and `examples/todo-app`, it registers `examples/normalizr-github`, `examples/normalizr-redux` and `examples/normalizr-relationships`, plus `examples/benchmark`, `examples/benchmark-react` and `examples/test-bundlesize`.
Those directories exist because the normalization layer descends from the normalizr model, so shipping equivalents side by side makes the comparison reproducible rather than rhetorical. A `normalizr-redux` example next to a todo app shows a Redux-shaped alternative for the same cache, which means the competition here is other caching libraries, not a different kind of tool.
What you do not get is numbers. No benchmark table ships in the README, and `examples/test-bundlesize` feeds a bundle size badge rather than a published figure. Render cost and payload size are precisely the claims a competing cache library has to justify, so anyone choosing between this setup and a normalizr-based one should read `examples/benchmark` and run it against their own data shapes.
Editorial conclusion
reactive/data-client earns its place when a React app already models its data as classes and wants cache writes driven by the server rather than by updater functions, and the schema-first layout pays for itself once several screens share one article. It is the wrong shape for a codebase with a handful of endpoints, and the install line names only React packages even though npm listings include a Vue package, so check your framework's package first. Then confirm your websocket feed can be written through a Manager middleware, because the README stops mid-example.
Frequently asked questions
How do I install reactive/data-client and render a first entity?
Run `npm install --save @data-client/react @data-client/rest @data-client/test`, define a class extending `Entity` with a static schema block, bind it with `resource({`, and read it with `useSuspense(ArticleResource.get, { id })`. The README sends further readers to the Getting Started docs for the rest.
Does reactive/data-client support Vue as well as React?
The README lists Vue among the rendering targets, and a `@data-client/vue` package appears in the npm releases, but the install command names only the React packages. A Vue user therefore gets no install line from the README.
How do I receive websocket updates in reactive/data-client instead of polling?
For a built-in subscription, `useLive(PriceResource.get, { symbol })` returns a record whose value updates. For a raw socket, implement the Manager interface, open the WebSocket in `init()` and call `controller.set(Ticker, { id: msg.id }, msg)` from the middleware so messages enter the same cache.
What replaces reducers and updater functions in reactive/data-client?
Normalization and updates are declared by class schemas rather than written by hand, and the README states the design as schema driven with zero updater functions. Out-of-band writes still exist as imperative calls such as `ctrl.set(Article, { id }, articleData)` and `ctrl.setResponse(ArticleResource.get, { id }, articleData)`.
How does reactive/data-client paginate a list endpoint?
A resource sets `paginationField: 'cursor'`, which names the response field holding the cursor for the next page. The README does not describe page merging, cache eviction order or how stale pages are refetched.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/reactive-data-client)Star history chart for your README
[](https://hysenlabs.com/projects/reactive-data-client)Was this useful?