v0.15: Vue 3, Collection.remove, useDebounce Upgrade
New Platforms:
- Vue 3 with full composables: useSuspense, useCache, useDLE, useController, useQuery
New Features:
- Collection.remove for removing items from collections
- RestEndpoint.remove for combined PATCH + collection removal
- Unions can query() without type discriminator
- Invalidate supports Unions for polymorphic delete operations
- mockInitialState() for simpler test setup
Performance:
- 10-20% improvement for get/denormalize operations
- useDebounce() returns [val, isPending]
- ImmutableJS support moved to /imm exports
- Schema delegate interface consolidation
Vue 3 Support
The new @data-client/vue package brings the full power of Reactive Data Client to Vue 3 with native composables. #3549, #3585
Installation
npm install @data-client/vue @data-client/rest
Setup
import { createApp } from 'vue';
import { DataClientPlugin } from '@data-client/vue';
const app = createApp(App);
app.use(DataClientPlugin);
app.mount('#app');
Composables
All the familiar hooks are available as Vue composables:
<script setup lang="ts">
import { useSuspense } from '@data-client/vue';
import { ArticleResource } from './resources';
const props = defineProps<{ id: string }>();
const article = await useSuspense(
ArticleResource.get,
computed(() => ({ id: props.id })),
);
</script>
<template>
<article>
<h1>{{ article.title }}</h1>
<p>{{ article.content }}</p>
</article>
</template>
Available composables:
useSuspense()- Suspense-enabled data fetchinguseCache()- Read from cache without fetchinguseDLE()- Data/Loading/Error pattern without Suspense #3592useController()- Access controller for mutationsuseQuery()- Direct schema queries
Testing with MockPlugin
import { createApp } from 'vue';
import { DataClientPlugin } from '@data-client/vue';
import { MockPlugin } from '@data-client/vue/test';
const app = createApp(App);
app.use(DataClientPlugin);
app.use(MockPlugin, {
fixtures: [
{
endpoint: MyResource.get,
args: [{ id: 1 }],
response: { id: 1, name: 'Test' },
},
],
});
Collection.remove
New schema for removing items from Collections without deleting the entity itself. #3560
import { schema } from '@data-client/rest';
const TodoCollection = new schema.Collection([Todo]);
const TodoResource = resource({
path: '/todos/:id',
schema: Todo,
}).extend({
getList: { schema: TodoCollection },
});
// Remove from collection without deleting
ctrl.set(TodoResource.getList.schema.remove, { id: '123' });
This is useful when an item should be removed from a list view but still exist elsewhere:
// Remove user from a specific group list
ctrl.set(UserResource.getList.schema.remove, { id: userId, group: 'admins' });
RestEndpoint.remove
RestEndpoint.remove combines PATCH update with collection removal - perfect for "archive" or "move" operations. #3623
const getTodos = new RestEndpoint({
path: '/todos',
schema: new schema.Collection([Todo]),
});
// Removes Todo from collection AND updates it with new data
await ctrl.fetch(
getTodos.remove,
{},
{ id: '123', title: 'Done', completed: true, archived: true }
);
// Move user between groups
await ctrl.fetch(
UserResource.getList.remove,
{ group: 'team-a' },
{ id: 2, username: 'bob', group: 'team-b' }
);
// User removed from 'team-a' list AND entity updated with group: 'team-b'
Union Queries
Unions can now be queried without specifying the type discriminator. #3558
// Type discriminator required
// @ts-expect-error - missing 'type'
const event = useQuery(EventUnion, { id });
// event is undefined
const newsEvent = useQuery(EventUnion, { id, type: 'news' });
// newsEvent is found
// Works without type discriminator
const event = useQuery(EventUnion, { id });
// event is found!
const newsEvent = useQuery(EventUnion, { id, type: 'news' });
// newsEvent is also found
Invalidate supports Unions
schema.Invalidate now accepts Union schemas for polymorphic delete operations. Additionally, resource().delete automatically wraps Union schemas with Invalidate. #3559
const FeedUnion = new schema.Union(
{ posts: Post, comments: Comment },
'type',
);
// resource() automatically handles Union delete
const FeedResource = resource({
path: '/feed/:id',
schema: FeedUnion,
});
await ctrl.fetch(FeedResource.delete, { id: '123' });
// For standalone endpoints, use schema.Invalidate directly
const deleteFeedItem = new RestEndpoint({
path: '/feed/:id',
method: 'DELETE',
schema: new schema.Invalidate(FeedUnion),
});
await ctrl.fetch(deleteFeedItem, { id: '123' });
Testing Utilities
mockInitialState()
New utility for creating pre-populated state in tests. a4092a1
import { mockInitialState } from '@data-client/react/mock';
import { ArticleResource } from './resources';
const state = mockInitialState([
{
endpoint: ArticleResource.get,
args: [{ id: 5 }],
response: { id: 5, title: 'Hello', content: 'World' },
},
]);
@data-client/core/mock
New mock entrypoint with utilities for building custom mock implementations:
import {
MockController,
collapseFixture,
createFixtureMap,
} from '@data-client/core/mock';
MockController- Controller wrapper for mocking endpointscollapseFixture- Resolves fixture responses (handles function responses)createFixtureMap- Separates fixtures into static map and interceptors
Performance
Removing ImmutableJS auto-detection overhead and optimizing denormalization code paths provides a 10-20% improvement for get/denormalize operations. #3421, #3468
Other Improvements
- Fix
getPagetypes when paginationField is in body - Fix schema.All() polymorphic handling of Invalidated entities
- Fix controller.get and controller.getQueryMeta 'state' argument types
Migration guide
This upgrade requires updating all package versions simultaneously.
- NPM
- Yarn
- pnpm
- esm.sh
yarn add @data-client/react@^0.15.0 @data-client/rest@^0.15.0 @data-client/test@^0.15.0 @data-client/img@^0.15.0
npm install --save @data-client/react@^0.15.0 @data-client/rest@^0.15.0 @data-client/test@^0.15.0 @data-client/img@^0.15.0
pnpm add @data-client/react@^0.15.0 @data-client/rest@^0.15.0 @data-client/test@^0.15.0 @data-client/img@^0.15.0
<script type="module">
import * from 'https://esm.sh/@data-client/react@^0.15.0';
import * from 'https://esm.sh/@data-client/rest@^0.15.0';
import * from 'https://esm.sh/@data-client/test@^0.15.0';
import * from 'https://esm.sh/@data-client/img@^0.15.0';
</script>
useDebounce() returns [val, isPending]
useDebounce() now returns a tuple with pending state, matching patterns from React 19's useTransition. #3459
import { useDebounce } from '@data-client/react';
const debouncedQuery = useDebounce(query, 100);
import { useDebounce } from '@data-client/react';
const [debouncedQuery] = useDebounce(query, 100);
// Or use the pending state
const [debouncedQuery, isPending] = useDebounce(query, 100);
The previous signature is still available via @data-client/react/next for gradual migration.
ImmutableJS moved to /imm exports
ImmutableJS support is no longer auto-detected. Use explicit /imm exports for ImmutableJS state handling. #3421, #3468
import { MemoCache, denormalize } from '@data-client/normalizr';
const memo = new MemoCache();
// Auto-detected ImmutableJS
import { MemoCache } from '@data-client/normalizr';
import { MemoPolicy, denormalize, normalize } from '@data-client/normalizr/imm';
const memo = new MemoCache(MemoPolicy);
The /imm entrypoint now includes both normalize() and denormalize() for complete ImmutableJS state handling:
import { normalize } from '@data-client/normalizr/imm';
import { fromJS } from 'immutable';
const result = normalize(Article, responseData, args, {
entities: fromJS({}),
indexes: fromJS({}),
entitiesMeta: fromJS({}),
});
// result.entities is an ImmutableJS Map
This change provides a 10-20% performance improvement by removing ImmutableJS detection overhead for users not using it.
Schema delegate interface changes
If you have custom schemas that override normalize() or queryKey(), the callback arguments have been consolidated into a delegate object. #3449
normalize()
normalize(
input: any,
parent: any,
key: string | undefined,
args: any[],
visit: (...args: any) => any,
addEntity: any,
getEntity: any,
checkLoop: any,
): string {
addEntity(this, processedEntity, id);
}
normalize(
input: any,
parent: any,
key: string | undefined,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): string {
delegate.mergeEntity(this, id, processedEntity);
}
queryKey()
queryKey(args, queryKey, getEntity, getIndex) {
getIndex(schema.key, indexName, value)[value];
getEntity(this.key, id);
return queryKey(this.schema, args, getEntity, getIndex);
}
queryKey(args, unvisit, delegate) {
delegate.getIndex(schema.key, indexName, value);
delegate.getEntity(this.key, id);
return unvisit(this.schema, args);
}
delegate.getEntity → delegate.getEntities #3501
When iterating over all entities of a type, use delegate.getEntities() which returns a restricted interface with keys() and entries() iterator methods:
const entities = delegate.getEntity(this.key);
if (entities)
Object.keys(entities).forEach(collectionPk => {
if (!filterCollections(JSON.parse(collectionPk))) return;
delegate.mergeEntity(this, collectionPk, normalizedValue);
});
const entities = delegate.getEntities(this.key);
if (entities)
for (const collectionKey of entities.keys()) {
if (!filterCollections(JSON.parse(collectionKey))) continue;
delegate.mergeEntity(this, collectionKey, normalizedValue);
}
INVALID symbol removed
The INVALID symbol is no longer exported. Use delegate methods instead. #3461
import { INVALID } from '@data-client/endpoint';
normalize(..., delegate) {
delegate.setEntity(this, pk, INVALID);
}
queryKey(args, unvisit, delegate) {
if (!found) return INVALID;
}
normalize(..., delegate) {
delegate.invalidate({ key: this.key }, pk);
}
queryKey(args, unvisit, delegate) {
if (!found) return delegate.INVALID;
}
state.entityMeta -> state.entitiesMeta
Internal state property renamed for consistency. This only affects direct store access. #3451
NetworkManager data structure changes
NetworkManager fetched, rejectors, resolvers, fetchedAt
have been consolidated into fetching. #3394
if (action.key in this.fetched)
if (this.fetching.has(action.key))
MemoCache API changes (normalizr)
For direct @data-client/normalizr users:
MemoCache.query() and buildQueryKey() take state as one argument #3372
this.memo.buildQueryKey(schema, args, state.entities, state.indexes, key);
this.memo.query(schema, args, state.entities, state.indexes);
this.memo.buildQueryKey(schema, args, state, key);
this.memo.query(schema, args, state);
MemoCache.query() returns { data, paths }
const { data } = this.memo.query(schema, args, state);
return typeof data === 'symbol' ? undefined : (data as any);
Upgrade support
As usual, if you have any troubles or questions, feel free to join our or file a bug
