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:

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' },
},
],
});

Full Vue Guide

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

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