# Schema classes drive the cache in reactive/data-client

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

**reactive/data-client** — Async State Management without the Management. REST, GraphQL, SSE, Websockets

- Repository: https://github.com/reactive/data-client
- Website: https://dataclient.io
- Stars: 2,039 · Forks: 98
- Language: TypeScript
- License: Apache-2.0
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/reactive-data-client

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

```typescript
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.

```tsx
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.

```tsx
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.

```tsx
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.

```ts
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.

```typescript
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.

```typescript
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.

```typescript
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.

```bash
npm install --save @data-client/react @data-client/rest @data-client/test
```

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

```bash
npx skills add reactive/data-client
```

Afterwards the README says to run the `data-client-setup` skill. The three most recent npm releases, `@data-client/react@0.18.1`, `@data-client/rest@0.18.1` and `@data-client/vue@0.18.1`, 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.

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

## FAQ

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

## Sources

- [License: Apache-2.0](https://github.com/reactive/data-client/blob/master/LICENSE)
- [Project website](https://dataclient.io)
- [reactive/data-client on GitHub](https://github.com/reactive/data-client)
- [README](https://github.com/reactive/data-client/blob/master/README.md)
- [Releases](https://github.com/reactive/data-client/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/reactive-data-client
