Skip to main content

v0.15: Vue 3, Collection.remove, useDebounce Upgrade

· 9 min read
Nathaniel Tucker
Creator of Reactive Data Client

New Platforms:

  • Vue 3 with full composables: useSuspense, useCache, useDLE, useController, useQuery

New Features:

Performance:

Breaking Changes:

Upgrade with an automated codemod for common breaking changes:

npx jscodeshift -t https://dataclient.io/codemods/v0.15.js --extensions=ts,tsx,js,jsx src/

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​

main.ts
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:

ArticleDetail.vue
<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:

Testing with MockPlugin​

test setup
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

Before
// 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
After
// 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 endpoints
  • collapseFixture - 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

Benchmarks over time | View benchmark

Other Improvements​

  • Fix getPage types 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 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

An automated codemod handles the breaking changes below:

npx jscodeshift -t https://dataclient.io/codemods/v0.15.js --extensions=ts,tsx,js,jsx src/

useDebounce() returns [val, isPending]​

useDebounce() now returns a tuple with pending state, matching patterns from React 19's useTransition. #3459

Before
import { useDebounce } from '@data-client/react';

const debouncedQuery = useDebounce(query, 100);
After
import { useDebounce } from '@data-client/react';

const [debouncedQuery] = useDebounce(query, 100);
// Or use the pending state
const [debouncedQuery, isPending] = useDebounce(query, 100);
tip

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

Before
import { MemoCache, denormalize } from '@data-client/normalizr';

const memo = new MemoCache();
// Auto-detected ImmutableJS
After
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()​

Before
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);
}
After
normalize(
input: any,
parent: any,
key: string | undefined,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): string {
delegate.mergeEntity(this, id, processedEntity);
}

queryKey()​

Before
queryKey(args, queryKey, getEntity, getIndex) {
getIndex(schema.key, indexName, value)[value];
getEntity(this.key, id);
return queryKey(this.schema, args, getEntity, getIndex);
}
After
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:

Before
const entities = delegate.getEntity(this.key);
if (entities)
Object.keys(entities).forEach(collectionPk => {
if (!filterCollections(JSON.parse(collectionPk))) return;
delegate.mergeEntity(this, collectionPk, normalizedValue);
});
After
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

Before
import { INVALID } from '@data-client/endpoint';

normalize(..., delegate) {
delegate.setEntity(this, pk, INVALID);
}

queryKey(args, unvisit, delegate) {
if (!found) return INVALID;
}
After
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

Before
if (action.key in this.fetched)
After
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​

Before
this.memo.buildQueryKey(schema, args, state.entities, state.indexes, key);
this.memo.query(schema, args, state.entities, state.indexes);
After
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 Chat or file a bug